diff --git a/.gitignore b/.gitignore index f6049b3640..44803f44fc 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ tsconfig.tsbuildinfo api docs/api docs/plans +/notes/ yarn.lock e2e/videos/* e2e/screenshots/* @@ -47,4 +48,7 @@ CLAUDE.md # For bison generated files used by ShaderLab *.tab.c -*.output \ No newline at end of file +*.output + +# Local-only test assets (third-party game art, not for redistribution) +examples/public/spine/ diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 2453b0531d..1d9d8de532 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -8,24 +8,28 @@ label: Core Node cloning is a common runtime feature, and node cloning also includes cloning its bound components. For example, during the initialization phase, dynamically create a certain number of identical entities based on configuration, and then place them in different positions in the scene according to logical rules. Here, the details of script cloning will be explained in detail. ## Entity Cloning + It's very simple, just call the entity's [clone()](/apis/design/#IClone-clone) method to complete the cloning of the entity and its attached components. + ```typescript const cloneEntity = entity.clone(); ``` ## Script Cloning -Scripts are essentially components, so when we call the entity's [clone()](/apis/design/#IClone-clone) function, the engine will not only clone the built-in components but also clone custom scripts. The cloning rules for built-in components have been customized by the official team, and similarly, we have also opened up the cloning capabilities and rules for scripts to developers. The default cloning method for script fields is shallow copy. For example, if we modify the field values of the script and then clone it, the cloned script will retain the modified values without any additional coding. Below is an example of custom script cloning: + +Scripts are essentially components, so when we call the entity's [clone()](/apis/design/#IClone-clone) function, the engine will not only clone the built-in components but also clone custom scripts. The cloning rules for built-in components have been customized by the official team, and similarly, we have also opened up the cloning capabilities and rules for scripts to developers. Script fields are cloned automatically, and how each field value is cloned is resolved by the value's type (see the default rules below). For example, if we modify the field values of the script and then clone it, the cloned script will retain the modified values without any additional coding. Below is an example of custom script cloning: + ```typescript // define a custom script -class CustomScript extends Script{ +class CustomScript extends Script { /** boolean type.*/ - a:boolean = false; - + a: boolean = false; + /** number type.*/ - b:number = 1; - + b: number = 1; + /** class type.*/ - c:Vector3 = new Vector3(0,0,0); + c: Vector3 = new Vector3(0, 0, 0); } // Init entity and script @@ -33,44 +37,99 @@ const entity = engine.createEntity(); const script = entity.addComponent(CustomScript); script.a = true; script.b = 2; -script.c.set(1,1,1); +script.c.set(1, 1, 1); // Clone logic const cloneEntity = entity.clone(); const cloneScript = cloneEntity.getComponent(CustomScript); console.log(cloneScript.a); // output is true. console.log(cloneScript.b); // output is 2. -console.log(cloneScript.c); // output is (1,1,1). +console.log(cloneScript.c); // output is (1,1,1), and it is an independent copy — Vector3 is a math value type and is deep cloned by default. ``` + +### Default Cloning Rules + +When a field has no clone decorator, the engine resolves how to clone its value by the value's type: + +| Value Type | Default Cloning Behavior | +| :-- | :-- | +| Primitive types (`number`, `string`, `boolean`, ...) | Copy the value. | +| Assets (`Texture`, `Mesh`, `Material`, `Sprite`, `Font` and other `ReferResource` types) | Share the reference — the clone uses the same asset as the source. | +| `Entity` / `Component` references | Automatically remapped to the corresponding clone within the cloned subtree; references pointing outside the subtree keep the original reference. | +| Math value types (`Vector2`, `Vector3`, `Vector4`, `Quaternion`, `Matrix`, `Color`, ...) and other value-semantic data | Deep cloned — the clone gets a fully independent copy. | +| Containers (`Array`, `Map`, `Set`, TypedArray, `DataView`, plain objects) | Deep cloned — a fresh container is created, and each member (for `Map`, keys included) is cloned again according to its own type semantics. | +| Runtime containers (internal transient state such as `UpdateFlagManager`) | Ignored — the clone keeps its own constructor-built value. | +| Function values | The clone keeps its own constructor-built function; if it has none, the source's function is shared. | +| Other objects | Share the reference (assignment). | + +A class opts into the deep-clone default in one of two ways: extend [DataObject](/apis/core/#DataObject) (data classes — the engine clones them field by field), or expose a `copyFrom` method (value types like the math classes — the engine copies through it). Either way the type must support argument-less construction: when a slot has no reusable preset instance, the engine bare-constructs one with `new Type()` before populating it, and a constructor that cannot run bare throws. + +So without any decorator, plain data objects and arrays get independent deep copies, assets remain shared, and entity references are automatically remapped: + +```typescript +// define a custom script +class CustomScript extends Script { + /** Entity reference.*/ + target: Entity; + + /** Plain data object.*/ + config = { speed: 1, offsets: [0, 1, 2] }; + + /** Asset.*/ + texture: Texture2D; +} + +// Init entity and script +const entity = engine.createEntity(); +const child = entity.createChild("child"); +const script = entity.addComponent(CustomScript); +script.target = child; +script.texture = new Texture2D(engine, 128, 128); + +// Clone logic +const cloneEntity = entity.clone(); +const cloneScript = cloneEntity.getComponent(CustomScript); +console.log(cloneScript.target === cloneEntity.findByName("child")); // output is true, entity references are remapped to the corresponding clone in the cloned subtree. +console.log(cloneScript.config === script.config); // output is false, plain data objects are deep cloned into independent copies. +console.log(cloneScript.texture === script.texture); // output is true, assets are shared between the source and the clone. +``` + ### Clone Decorators -In addition to the default cloning method, the engine also provides "clone decorators" to customize the cloning method for script fields. The engine has four built-in clone decorators: + +In addition to the default type-driven rules, the engine also provides "clone decorators" to customize the cloning method for script fields. Field decorators have the highest priority — they override the value type's default cloning behavior and apply wherever the clone walks fields: at the component's top level and inside any object that is itself deep cloned (no field walk happens inside a value that stays shared, so no decorator is consulted there). The engine has three built-in clone decorators: | Decorator Name | Decorator Description | -| :--- | :--- | -| [ignoreClone](/apis/core/#ignoreClone) | Ignore the field during cloning. | -| [assignmentClone](/apis/core/#assignmentClone) | (Default value, equivalent to not adding any clone decorator) Assign the field during cloning. If it is a basic type, the value will be copied; if it is a reference type, the reference address will be copied. | -| [shallowClone](/apis/core/#shallowClone) | Shallow clone the field during cloning. After cloning, it will maintain its own independent reference and clone all its internal fields by assignment (if the internal field is a basic type, the value will be copied; if the internal field is a reference type, the reference address will be copied). | -| [deepClone](/apis/core/#deepClone) | Deep clone the field during cloning. After cloning, it will maintain its own independent reference, and all its internal deep fields will remain completely independent. | +| :-- | :-- | +| [ignoreClone](/apis/core/#ignoreClone) | Ignore the field during cloning; the clone keeps its own constructor-built value. | +| [assignmentClone](/apis/core/#assignmentClone) | Assign the field during cloning. If it is a basic type, the value will be copied; if it is a reference type, the clone will share the reference with the source. | +| [deepClone](/apis/core/#deepClone) | Deep clone the field's whole subtree. The deep intent carries into members, while assets inside stay shared, entity references remap, and runtime state keeps the clone's own. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference, an asset, engine runtime state (such as `UpdateFlagManager`), or a function throws — remove the decorator to get the default behavior, or copy an asset via its own clone API. | + + + `@shallowClone` has been removed. Its old semantics — copy the container itself but share its members — no longer + exist. Migrate by intent: use `@deepClone` if the field should be independent, or `@assignmentClone` if it should be + shared. + + +We slightly modify the above example and add different "clone decorators" to the four fields in `CustomScript`. Since arrays are already deep cloned by default, we use `assignmentClone` on field `c` to force sharing, and `deepClone` on field `d` to make the default behavior explicit, with additional print output for further explanation. -We slightly modify the above example and add different "clone decorators" to the four fields in `CustomScript`. Since `shallowClone` and `deepClone` are more complex, we add additional print output to the fields `c` and `d` for further explanation. ```typescript // define a custom script -class CustomScript extends Script{ +class CustomScript extends Script { /** boolean type.*/ @ignoreClone - a:boolean = false; - + a: boolean = false; + /** number type.*/ @assignmentClone - b:number = 1; - + b: number = 1; + /** class type.*/ - @shallowClone - c:Vector3[] = [new Vector3(0,0,0)]; - + @assignmentClone + c: Vector3[] = [new Vector3(0, 0, 0)]; + /** class type.*/ @deepClone - d:Vector3[] = [new Vector3(0,0,0)]; + d: Vector3[] = [new Vector3(0, 0, 0)]; } // Init entity and script @@ -78,26 +137,31 @@ const entity = engine.createEntity(); const script = entity.addComponent(CustomScript); script.a = true; script.b = 2; -script.c[0].set(1,1,1); -script.d[0].set(1,1,1); +script.c[0].set(1, 1, 1); +script.d[0].set(1, 1, 1); // Clone logic const cloneEntity = entity.clone(); const cloneScript = cloneEntity.getComponent(CustomScript); -console.log(cloneScript.a); // output is false,ignoreClone will ignore the value. +console.log(cloneScript.a); // output is false,ignoreClone keeps the clone's own constructor-built value. console.log(cloneScript.b); // output is 2,assignmentClone is just assignment the origin value. -console.log(cloneScript.c[0]); // output is Vector3(1,1,1),shallowClone clone the array shell,but use the same element. +console.log(cloneScript.c[0]); // output is Vector3(1,1,1),assignmentClone shares the same array instance with the source. console.log(cloneScript.d[0]); // output is Vector3(1,1,1),deepClone clone the array shell and also clone the element. -cloneScript.c[0].set(2,2,2); // change the field c[0] value to (2,2,2). -cloneScript.d[0].set(2,2,2); // change the field d[0] value to (2,2,2). +cloneScript.c[0].set(2, 2, 2); // change the field c[0] value to (2,2,2). +cloneScript.d[0].set(2, 2, 2); // change the field d[0] value to (2,2,2). -console.log(script.c[0]); // output is (2,2,2). bacause shallowClone let c[0] use the same reference with cloneScript's c[0]. +console.log(script.c[0]); // output is (2,2,2). bacause assignmentClone let c use the same array reference with cloneScript's c. console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's d[0]. ``` + - Note: - - `shallowClone` and `deepClone` are usually used for *Object*, *Array*, and *Class* types. - - `shallowClone` will maintain its own independent reference after cloning and clone all its internal fields by assignment (if the internal field is a basic type, the value will be copied; if the internal field is a reference type, the reference address will be copied). - - `deepClone` is a deep clone that will recursively clone the properties deeply. How the sub-properties of the properties are cloned depends on the decorators of the sub-properties. - - If the clone decorators do not meet the requirements, you can implement the [_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning. + - Field decorators have the highest priority and also apply to the fields of nested objects, wherever the clone walks fields. + - `deepClone` deep-clones the whole subtree: the intent carries into members, so a member class instance with no deep default of its own is copied too (it must support argument-less construction). Assets inside stay shared, entity references are remapped, runtime state keeps the clone's own, and nested field decorators still win. + - `deepClone` cannot deep clone engine-bound objects: `@deepClone` on an `Entity` / `Component` reference, an asset, engine runtime state, or a function throws, because the explicit intent can't be honored. Remove the decorator to fall back to the default, or use the asset's own clone API for a real copy. + - If the clone decorators do not meet the requirements, you can implement the [\_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning. + +## Cloning and Asset Reference Counting + +When a component's top-level field shares a ref-counted asset (a `ReferResource` such as `Texture`, `Mesh`, or `Material`) during cloning, the engine automatically adds one reference for the clone, so destroying the source never pulls the asset out from under it. Built-in components release their references when destroyed. An asset referenced by custom script fields is kept alive by its clones — destroy the asset itself via its `destroy()` when it is no longer needed. diff --git a/docs/en/how-to-contribute.mdx b/docs/en/how-to-contribute.mdx index 988d96a68e..b5f5e6319a 100644 --- a/docs/en/how-to-contribute.mdx +++ b/docs/en/how-to-contribute.mdx @@ -120,7 +120,7 @@ class CustomScript extends Script { a:boolean = false; @assignmentClone b:number = 1; - @shallowClone + @deepClone c:Vector3[] = [new Vector3(0,0,0)]; } ``` @@ -133,7 +133,7 @@ class CustomScript extends Script{ a:boolean = false; @assignmentClone b:number = 1; - @shallowClone + @deepClone c:Vector3[] = [new Vector3(0,0,0)]; } diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index 4cf66c382d..dc5ca73704 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -5,28 +5,31 @@ type: 核心 label: Core --- - 节点克隆是运行时的常用功能,同时节点克隆也会附带克隆其绑定的组件。例如在初始化阶段根据配置动态创建一定数量相同的实体,然后根据逻辑规则摆放到场景不同的位置。这里会对脚本的克隆细节进行详细讲解。 ## 实体的克隆 -非常简单,直接调用实体的 [clone()](/apis/design/#IClone-clone) 方法即可完成实体以及附属组件的克隆。 + +非常简单,直接调用实体的 [clone()](/apis/design/#IClone-clone) 方法即可完成实体以及附属组件的克隆。 + ```typescript const cloneEntity = entity.clone(); ``` ## 脚本的克隆 -脚本的本质也是组件,所以当我们调用实体的 [clone()](/apis/design/#IClone-clone) 函数时,引擎不仅会对引擎内置组件进行克隆,还会对自定义脚本进行克隆。引擎内置组件的克隆规则官方已经完成定制,同样我们也将脚本的克隆能力和规则定制开放给了开发者。脚本字段默认的克隆方式为浅拷贝,例如我们对脚本的字段值进行修改后再克隆,克隆后的脚本将保持修改后的值,无需增加任何额外的编码。以下为自定义脚本的克隆案例: + +脚本的本质也是组件,所以当我们调用实体的 [clone()](/apis/design/#IClone-clone) 函数时,引擎不仅会对引擎内置组件进行克隆,还会对自定义脚本进行克隆。引擎内置组件的克隆规则官方已经完成定制,同样我们也将脚本的克隆能力和规则定制开放给了开发者。脚本字段会被自动克隆,每个字段值采用哪种克隆方式由值的类型决定(见下方默认克隆规则)。例如我们对脚本的字段值进行修改后再克隆,克隆后的脚本将保持修改后的值,无需增加任何额外的编码。以下为自定义脚本的克隆案例: + ```typescript // define a custom script -class CustomScript extends Script{ +class CustomScript extends Script { /** boolean type.*/ - a:boolean = false; - + a: boolean = false; + /** number type.*/ - b:number = 1; - + b: number = 1; + /** class type.*/ - c:Vector3 = new Vector3(0,0,0); + c: Vector3 = new Vector3(0, 0, 0); } // Init entity and script @@ -34,44 +37,98 @@ const entity = engine.createEntity(); const script = entity.addComponent(CustomScript); script.a = true; script.b = 2; -script.c.set(1,1,1); +script.c.set(1, 1, 1); // Clone logic const cloneEntity = entity.clone(); const cloneScript = cloneEntity.getComponent(CustomScript); console.log(cloneScript.a); // output is true. console.log(cloneScript.b); // output is 2. -console.log(cloneScript.c); // output is (1,1,1). +console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 —— Vector3 属于数学值类型,默认深克隆。 ``` + +### 默认克隆规则 + +当字段没有添加任何克隆装饰器时,引擎会根据字段值的类型决定克隆方式: + +| 值类型 | 默认克隆行为 | +| :-- | :-- | +| 基本类型(`number`、`string`、`boolean` 等) | 拷贝值。 | +| 资产(`Texture`、`Mesh`、`Material`、`Sprite`、`Font` 等 `ReferResource` 类型) | 共享引用 —— 克隆体与源使用同一份资产。 | +| `Entity` / `Component` 引用 | 自动重映射到克隆子树内对应的克隆对象;指向子树外的引用保持原引用不变。 | +| 数学值类型(`Vector2`、`Vector3`、`Vector4`、`Quaternion`、`Matrix`、`Color` 等)及其他值语义数据 | 深克隆 —— 克隆体获得完全独立的拷贝。 | +| 容器(`Array`、`Map`、`Set`、TypedArray、`DataView`、普通对象) | 深克隆 —— 创建全新的容器,每个成员(`Map` 连键一起)再按各自的类型语义继续克隆。 | +| 运行时容器(`UpdateFlagManager` 等内部瞬态状态) | 忽略 —— 克隆体保留自身构造时的值。 | +| 函数值 | 克隆体保留自身构造时的函数;没有则共享源的函数。 | +| 其他对象 | 共享引用(赋值)。 | + +一个类可以通过两种方式获得"默认深克隆"能力:继承 [DataObject](/apis/core/#DataObject)(数据类 —— 引擎逐字段克隆),或提供 `copyFrom` 方法(数学类这样的值类型 —— 引擎经由它拷贝)。两种方式都要求类型支持无参构造:当槽位上没有可复用的预置实例时,引擎会先 `new Type()` 裸构造一个再填充,无法无参构造的类型会直接报错。 + +因此在不加装饰器的情况下,普通数据对象和数组默认会得到独立的深拷贝,资产依旧共享,实体引用则自动重映射: + +```typescript +// define a custom script +class CustomScript extends Script { + /** Entity reference.*/ + target: Entity; + + /** Plain data object.*/ + config = { speed: 1, offsets: [0, 1, 2] }; + + /** Asset.*/ + texture: Texture2D; +} + +// Init entity and script +const entity = engine.createEntity(); +const child = entity.createChild("child"); +const script = entity.addComponent(CustomScript); +script.target = child; +script.texture = new Texture2D(engine, 128, 128); + +// Clone logic +const cloneEntity = entity.clone(); +const cloneScript = cloneEntity.getComponent(CustomScript); +console.log(cloneScript.target === cloneEntity.findByName("child")); // output is true,实体引用被重映射到克隆子树内对应的克隆对象。 +console.log(cloneScript.config === script.config); // output is false,普通数据对象被深克隆为独立拷贝。 +console.log(cloneScript.texture === script.texture); // output is true,资产在源与克隆体之间共享。 +``` + ### 克隆装饰器 -除了默认的克隆方式外,引擎还提供了“克隆装饰器“对脚本字段的克隆方式进行定制。引擎内置四种克隆装饰: + +除了默认的类型驱动规则外,引擎还提供了“克隆装饰器“对脚本字段的克隆方式进行定制。字段装饰器优先级最高 —— 会覆盖值类型的默认克隆行为,并在克隆走字段遍历的任何地方生效:组件顶层,以及任何本身被深克隆的对象内部(默认共享的值不会被逐字段遍历,其内部的装饰器也就不会被查询)。引擎内置三种克隆装饰: | 装饰器名称 | 装饰器释义 | -| :--- | :--- | -| [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略。 | -| [assignmentClone](/apis/core/#assignmentClone) | ( 默认值,和不添加任何克隆装饰器等效) 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型则会拷贝其引用地址。 | -| [shallowClone](/apis/core/#shallowClone) | 克隆时对字段进行浅克隆。克隆后会保持自身引用独立,并使用赋值的方式克隆其内部所有字段(如果内部字段是基本类型则会拷贝值,如果内部字段是引用类型则会拷贝其引用地址)。| -| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。克隆后会保持自身引用独立,并且其内部所有深层字段均保持完全独立。| +| :-- | :-- | +| [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略,克隆体保留自身构造时的值。 | +| [assignmentClone](/apis/core/#assignmentClone) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | +| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产、引擎运行时状态(如 `UpdateFlagManager`)或函数使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。 | + + + `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 + `@deepClone`,希望共享就使用 `@assignmentClone`。 + + +我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于数组默认就会被深克隆,我们对字段 `c` 使用 `assignmentClone` 来强制共享,对字段 `d` 使用 `deepClone` 显式声明默认行为,并增加了额外的打印输出进行进一步讲解。 -我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于 `shallowClone` 和 `deepCone`  较复杂,我们对字段 `c` 和 `d` 增加了额外的打印输出进行进一步讲解。 ```typescript // define a custom script -class CustomScript extends Script{ +class CustomScript extends Script { /** boolean type.*/ @ignoreClone - a:boolean = false; - + a: boolean = false; + /** number type.*/ @assignmentClone - b:number = 1; - + b: number = 1; + /** class type.*/ - @shallowClone - c:Vector3[] = [new Vector3(0,0,0)]; - + @assignmentClone + c: Vector3[] = [new Vector3(0, 0, 0)]; + /** class type.*/ @deepClone - d:Vector3[] = [new Vector3(0,0,0)]; + d: Vector3[] = [new Vector3(0, 0, 0)]; } // Init entity and script @@ -79,27 +136,31 @@ const entity = engine.createEntity(); const script = entity.addComponent(CustomScript); script.a = true; script.b = 2; -script.c[0].set(1,1,1); -script.d[0].set(1,1,1); +script.c[0].set(1, 1, 1); +script.d[0].set(1, 1, 1); // Clone logic const cloneEntity = entity.clone(); const cloneScript = cloneEntity.getComponent(CustomScript); -console.log(cloneScript.a); // output is false,ignoreClone will ignore the value. +console.log(cloneScript.a); // output is false,ignoreClone 使克隆体保留自身构造时的值。 console.log(cloneScript.b); // output is 2,assignmentClone is just assignment the origin value. -console.log(cloneScript.c[0]); // output is Vector3(1,1,1),shallowClone clone the array shell,but use the same element. +console.log(cloneScript.c[0]); // output is Vector3(1,1,1),assignmentClone 与源共享同一个数组实例。 console.log(cloneScript.d[0]); // output is Vector3(1,1,1),deepClone clone the array shell and also clone the element. -cloneScript.c[0].set(2,2,2); // change the field c[0] value to (2,2,2). -cloneScript.d[0].set(2,2,2); // change the field d[0] value to (2,2,2). +cloneScript.c[0].set(2, 2, 2); // change the field c[0] value to (2,2,2). +cloneScript.d[0].set(2, 2, 2); // change the field d[0] value to (2,2,2). -console.log(script.c[0]); // output is (2,2,2). bacause shallowClone let c[0] use the same reference with cloneScript's c[0]. +console.log(script.c[0]); // output is (2,2,2). bacause assignmentClone let c use the same array reference with cloneScript's c. console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's d[0]. ``` -- 注意: - - `shallowClone` 和 `deepClone` 通常用于 *Object*、*Array* 和 *Class* 类型。 - - `shallowClone` 克隆后会保持自身引用独立,并使用赋值的方式克隆其内部所有字段(如果内部字段是基本类型则会拷贝值,如果内部字段是引用类型则会拷贝其引用地址)。 - - `deepClone` 为深克隆,会对属性进行深度递归,至于属性的子属性如何克隆,取决于子属性的装饰器。 - - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 +- 注意: + + - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。 + - `deepClone` 会深克隆整棵子树:深意图会传播进成员,没有深克隆默认的成员类实例同样被拷贝(因此必须支持无参构造)。子树内的资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,嵌套字段上的装饰器依然优先。 + - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产、引擎运行时状态或函数使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 + - 如果克隆装饰器不能满足诉求,可以通过实现 [\_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 + +## 克隆与资产引用计数 +克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数,因此销毁源对象不会让资产在克隆体脚下被回收。内置组件会在销毁时释放自己持有的引用;被自定义脚本字段引用的资产则由克隆体一直保活 —— 不再需要时,调用资产自身的 `destroy()` 释放。 diff --git a/docs/zh/how-to-contribute.mdx b/docs/zh/how-to-contribute.mdx index 6a995b5d89..e55d4c23c8 100644 --- a/docs/zh/how-to-contribute.mdx +++ b/docs/zh/how-to-contribute.mdx @@ -121,7 +121,7 @@ class CustomScript extends Script { a:boolean = false; @assignmentClone b:number = 1; - @shallowClone + @deepClone c:Vector3[] = [new Vector3(0,0,0)]; } ``` @@ -135,7 +135,7 @@ class CustomScript extends Script{ a:boolean = false; @assignmentClone b:number = 1; - @shallowClone + @deepClone c:Vector3[] = [new Vector3(0,0,0)]; } ``` diff --git a/e2e/.dev/public/spineboy.atlas b/e2e/.dev/public/spineboy.atlas new file mode 100644 index 0000000000..eca542b711 --- /dev/null +++ b/e2e/.dev/public/spineboy.atlas @@ -0,0 +1,94 @@ +spineboy.png + size: 1024, 256 + filter: Linear, Linear + scale: 0.5 +crosshair + bounds: 352, 7, 45, 45 +eye-indifferent + bounds: 862, 105, 47, 45 +eye-surprised + bounds: 505, 79, 47, 45 +front-bracer + bounds: 826, 66, 29, 40 +front-fist-closed + bounds: 786, 65, 38, 41 +front-fist-open + bounds: 710, 51, 43, 44 + rotate: 90 +front-foot + bounds: 210, 6, 63, 35 +front-shin + bounds: 665, 128, 41, 92 + rotate: 90 +front-thigh + bounds: 2, 2, 23, 56 + rotate: 90 +front-upper-arm + bounds: 250, 205, 23, 49 +goggles + bounds: 665, 171, 131, 83 +gun + bounds: 798, 152, 105, 102 +head + bounds: 2, 27, 136, 149 +hoverboard-board + bounds: 2, 178, 246, 76 +hoverboard-thruster + bounds: 722, 96, 30, 32 + rotate: 90 +hoverglow-small + bounds: 275, 81, 137, 38 +mouth-grind + bounds: 614, 97, 47, 30 +mouth-oooo + bounds: 612, 65, 47, 30 +mouth-smile + bounds: 661, 64, 47, 30 +muzzle-glow + bounds: 382, 54, 25, 25 +muzzle-ring + bounds: 275, 54, 25, 105 + rotate: 90 +muzzle01 + bounds: 911, 95, 67, 40 + rotate: 90 +muzzle02 + bounds: 792, 108, 68, 42 +muzzle03 + bounds: 956, 171, 83, 53 + rotate: 90 +muzzle04 + bounds: 275, 7, 75, 45 +muzzle05 + bounds: 140, 3, 68, 38 +neck + bounds: 250, 182, 18, 21 +portal-bg + bounds: 140, 43, 133, 133 +portal-flare1 + bounds: 554, 65, 56, 30 +portal-flare2 + bounds: 759, 112, 57, 31 + rotate: 90 +portal-flare3 + bounds: 554, 97, 58, 30 +portal-shade + bounds: 275, 121, 133, 133 +portal-streaks1 + bounds: 410, 126, 126, 128 +portal-streaks2 + bounds: 538, 129, 125, 125 +rear-bracer + bounds: 857, 67, 28, 36 +rear-foot + bounds: 663, 96, 57, 30 +rear-shin + bounds: 414, 86, 38, 89 + rotate: 90 +rear-thigh + bounds: 756, 63, 28, 47 +rear-upper-arm + bounds: 60, 5, 20, 44 + rotate: 90 +torso + bounds: 905, 164, 49, 90 diff --git a/e2e/.dev/public/spineboy.json b/e2e/.dev/public/spineboy.json new file mode 100644 index 0000000000..c0eb0ae927 --- /dev/null +++ b/e2e/.dev/public/spineboy.json @@ -0,0 +1,8723 @@ +{ + "skeleton": { + "hash": "dr3Kr/vMgPA", + "spine": "4.2.22", + "x": -188.63, + "y": -7.94, + "width": 418.45, + "height": 686.2, + "images": "./images/", + "audio": "" + }, + "bones": [ + { "name": "root", "rotation": 0.05 }, + { "name": "hip", "parent": "root", "y": 247.27 }, + { "name": "crosshair", "parent": "root", "x": 302.83, "y": 569.45, "color": "ff3f00ff", "icon": "circle" }, + { + "name": "aim-constraint-target", + "parent": "hip", + "length": 26.24, + "rotation": 19.61, + "x": 1.02, + "y": 5.62, + "color": "abe323ff" + }, + { "name": "rear-foot-target", "parent": "root", "x": 61.91, "y": 0.42, "color": "ff3f00ff", "icon": "ik" }, + { "name": "rear-leg-target", "parent": "rear-foot-target", "x": -33.91, "y": 37.34, "color": "ff3f00ff", "icon": "ik" }, + { + "name": "rear-thigh", + "parent": "hip", + "length": 85.72, + "rotation": -72.54, + "x": 8.91, + "y": -5.63, + "color": "ff000dff" + }, + { + "name": "rear-shin", + "parent": "rear-thigh", + "length": 121.88, + "rotation": -19.83, + "x": 86.1, + "y": -1.33, + "color": "ff000dff" + }, + { + "name": "rear-foot", + "parent": "rear-shin", + "length": 51.58, + "rotation": 45.78, + "x": 121.46, + "y": -0.76, + "color": "ff000dff" + }, + { + "name": "back-foot-tip", + "parent": "rear-foot", + "length": 50.3, + "rotation": -0.85, + "x": 51.17, + "y": 0.24, + "inherit": "noRotationOrReflection", + "color": "ff000dff" + }, + { "name": "board-ik", "parent": "root", "x": -131.78, "y": 69.09, "color": "4c56ffff", "icon": "arrows" }, + { "name": "clipping", "parent": "root" }, + { + "name": "hoverboard-controller", + "parent": "root", + "rotation": -0.28, + "x": -329.69, + "y": 69.82, + "color": "ff0004ff", + "icon": "arrowsB" + }, + { "name": "exhaust1", "parent": "hoverboard-controller", "rotation": 3.02, "x": -249.68, "y": 53.39 }, + { "name": "exhaust2", "parent": "hoverboard-controller", "rotation": 26.34, "x": -191.6, "y": -22.92 }, + { + "name": "exhaust3", + "parent": "hoverboard-controller", + "rotation": -12.34, + "x": -236.03, + "y": 80.54, + "scaleX": 0.7847, + "scaleY": 0.7847 + }, + { "name": "portal-root", "parent": "root", "x": 12.9, "y": 328.54, "scaleX": 2.0334, "scaleY": 2.0334 }, + { "name": "flare1", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare10", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare2", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare3", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare4", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare5", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare6", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare7", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare8", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { "name": "flare9", "parent": "portal-root", "x": -6.34, "y": -161.57, "icon": "particles" }, + { + "name": "torso", + "parent": "hip", + "length": 42.52, + "rotation": 103.82, + "x": -1.62, + "y": 4.9, + "color": "e0da19ff" + }, + { "name": "torso2", "parent": "torso", "length": 42.52, "x": 42.52, "color": "e0da19ff" }, + { "name": "torso3", "parent": "torso2", "length": 42.52, "x": 42.52, "color": "e0da19ff" }, + { "name": "front-shoulder", "parent": "torso3", "rotation": 255.89, "x": 18.72, "y": 19.33, "color": "00ff04ff" }, + { "name": "front-upper-arm", "parent": "front-shoulder", "length": 69.45, "rotation": -87.51, "color": "00ff04ff" }, + { + "name": "front-bracer", + "parent": "front-upper-arm", + "length": 40.57, + "rotation": 18.3, + "x": 68.8, + "y": -0.68, + "color": "00ff04ff" + }, + { + "name": "front-fist", + "parent": "front-bracer", + "length": 65.39, + "rotation": 12.43, + "x": 40.57, + "y": 0.2, + "color": "00ff04ff" + }, + { "name": "front-foot-target", "parent": "root", "x": -13.53, "y": 0.04, "color": "ff3f00ff", "icon": "ik" }, + { "name": "front-leg-target", "parent": "front-foot-target", "x": -28.4, "y": 29.06, "color": "ff3f00ff", "icon": "ik" }, + { + "name": "front-thigh", + "parent": "hip", + "length": 74.81, + "rotation": -95.51, + "x": -17.46, + "y": -11.64, + "color": "00ff04ff" + }, + { + "name": "front-shin", + "parent": "front-thigh", + "length": 128.77, + "rotation": -2.21, + "x": 78.69, + "y": 1.6, + "color": "00ff04ff" + }, + { + "name": "front-foot", + "parent": "front-shin", + "length": 41.01, + "rotation": 51.27, + "x": 128.76, + "y": -0.34, + "color": "00ff04ff" + }, + { + "name": "front-foot-tip", + "parent": "front-foot", + "length": 56.03, + "rotation": -1.68, + "x": 41.42, + "y": -0.09, + "inherit": "noRotationOrReflection", + "color": "00ff04ff" + }, + { "name": "back-shoulder", "parent": "torso3", "rotation": -104.11, "x": 7.32, "y": -19.22, "color": "ff000dff" }, + { "name": "rear-upper-arm", "parent": "back-shoulder", "length": 51.94, "rotation": -65.45, "color": "ff000dff" }, + { "name": "rear-bracer", "parent": "rear-upper-arm", "length": 34.56, "rotation": 23.15, "x": 51.36, "color": "ff000dff" }, + { + "name": "gun", + "parent": "rear-bracer", + "length": 43.11, + "rotation": -5.43, + "x": 34.42, + "y": -0.45, + "color": "ff000dff" + }, + { "name": "gun-tip", "parent": "gun", "rotation": 7.1, "x": 200.78, "y": 52.5, "color": "ff0000ff" }, + { + "name": "neck", + "parent": "torso3", + "length": 25.45, + "rotation": -31.54, + "x": 42.46, + "y": -0.31, + "color": "e0da19ff" + }, + { + "name": "head", + "parent": "neck", + "length": 131.79, + "rotation": 26.1, + "x": 27.66, + "y": -0.26, + "color": "e0da19ff" + }, + { + "name": "hair1", + "parent": "head", + "length": 47.23, + "rotation": -49.1, + "x": 149.83, + "y": -59.77, + "color": "e0da19ff" + }, + { + "name": "hair2", + "parent": "hair1", + "length": 55.57, + "rotation": 50.42, + "x": 47.23, + "y": 0.19, + "color": "e0da19ff" + }, + { + "name": "hair3", + "parent": "head", + "length": 62.22, + "rotation": -32.17, + "x": 164.14, + "y": 3.68, + "color": "e0da19ff" + }, + { + "name": "hair4", + "parent": "hair3", + "length": 80.28, + "rotation": 83.71, + "x": 62.22, + "y": -0.04, + "color": "e0da19ff" + }, + { "name": "hoverboard-thruster-front", "parent": "hoverboard-controller", "rotation": -29.2, "x": 95.77, "y": -2.99, "inherit": "noRotationOrReflection" }, + { "name": "hoverboard-thruster-rear", "parent": "hoverboard-controller", "rotation": -29.2, "x": -76.47, "y": -4.88, "inherit": "noRotationOrReflection" }, + { "name": "hoverglow-front", "parent": "hoverboard-thruster-front", "rotation": 0.17, "x": -1.78, "y": -37.79 }, + { "name": "hoverglow-rear", "parent": "hoverboard-thruster-rear", "rotation": 0.17, "x": 1.06, "y": -35.66 }, + { + "name": "muzzle", + "parent": "rear-bracer", + "rotation": 3.06, + "x": 242.34, + "y": 34.26, + "color": "ffb900ff", + "icon": "muzzleFlash" + }, + { "name": "muzzle-ring", "parent": "muzzle", "color": "ffb900ff" }, + { "name": "muzzle-ring2", "parent": "muzzle", "color": "ffb900ff" }, + { "name": "muzzle-ring3", "parent": "muzzle", "color": "ffb900ff" }, + { "name": "muzzle-ring4", "parent": "muzzle", "color": "ffb900ff" }, + { "name": "portal", "parent": "portal-root" }, + { "name": "portal-shade", "parent": "portal-root" }, + { "name": "portal-streaks1", "parent": "portal-root" }, + { "name": "portal-streaks2", "parent": "portal-root" }, + { "name": "side-glow1", "parent": "hoverboard-controller", "x": -110.56, "y": 2.62, "color": "000effff" }, + { + "name": "side-glow2", + "parent": "hoverboard-controller", + "x": -110.56, + "y": 2.62, + "scaleX": 0.738, + "scaleY": 0.738, + "color": "000effff" + }, + { "name": "head-control", "parent": "head", "x": 110.21, "color": "00a220ff", "icon": "arrows" } + ], + "slots": [ + { "name": "portal-bg", "bone": "portal" }, + { "name": "portal-shade", "bone": "portal-shade" }, + { "name": "portal-streaks2", "bone": "portal-streaks2", "blend": "additive" }, + { "name": "portal-streaks1", "bone": "portal-streaks1", "blend": "additive" }, + { "name": "portal-flare8", "bone": "flare8", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare9", "bone": "flare9", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare10", "bone": "flare10", "color": "c3cbffff", "blend": "additive" }, + { "name": "clipping", "bone": "clipping" }, + { "name": "exhaust3", "bone": "exhaust3", "color": "5eb4ffff", "blend": "additive" }, + { "name": "hoverboard-thruster-rear", "bone": "hoverboard-thruster-rear" }, + { "name": "hoverboard-thruster-front", "bone": "hoverboard-thruster-front" }, + { "name": "hoverboard-board", "bone": "hoverboard-controller" }, + { "name": "side-glow1", "bone": "side-glow1", "color": "ff8686ff", "blend": "additive" }, + { "name": "side-glow3", "bone": "side-glow1", "color": "ff8686ff", "blend": "additive" }, + { "name": "side-glow2", "bone": "side-glow2", "color": "ff8686ff", "blend": "additive" }, + { "name": "hoverglow-front", "bone": "hoverglow-front", "color": "5eb4ffff", "blend": "additive" }, + { "name": "hoverglow-rear", "bone": "hoverglow-rear", "color": "5eb4ffff", "blend": "additive" }, + { "name": "exhaust1", "bone": "exhaust2", "color": "5eb4ffff", "blend": "additive" }, + { "name": "exhaust2", "bone": "exhaust1", "color": "5eb4ffff", "blend": "additive" }, + { "name": "rear-upper-arm", "bone": "rear-upper-arm", "attachment": "rear-upper-arm" }, + { "name": "rear-bracer", "bone": "rear-bracer", "attachment": "rear-bracer" }, + { "name": "gun", "bone": "gun", "attachment": "gun" }, + { "name": "rear-foot", "bone": "rear-foot", "attachment": "rear-foot" }, + { "name": "rear-thigh", "bone": "rear-thigh", "attachment": "rear-thigh" }, + { "name": "rear-shin", "bone": "rear-shin", "attachment": "rear-shin" }, + { "name": "neck", "bone": "neck", "attachment": "neck" }, + { "name": "torso", "bone": "torso", "attachment": "torso" }, + { "name": "front-upper-arm", "bone": "front-upper-arm", "attachment": "front-upper-arm" }, + { "name": "head", "bone": "head", "attachment": "head" }, + { "name": "eye", "bone": "head", "attachment": "eye-indifferent" }, + { "name": "front-thigh", "bone": "front-thigh", "attachment": "front-thigh" }, + { "name": "front-foot", "bone": "front-foot", "attachment": "front-foot" }, + { "name": "front-shin", "bone": "front-shin", "attachment": "front-shin" }, + { "name": "mouth", "bone": "head", "attachment": "mouth-smile" }, + { "name": "goggles", "bone": "head", "attachment": "goggles" }, + { "name": "front-bracer", "bone": "front-bracer", "attachment": "front-bracer" }, + { "name": "front-fist", "bone": "front-fist", "attachment": "front-fist-closed" }, + { "name": "muzzle", "bone": "muzzle" }, + { "name": "head-bb", "bone": "head" }, + { "name": "portal-flare1", "bone": "flare1", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare2", "bone": "flare2", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare3", "bone": "flare3", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare4", "bone": "flare4", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare5", "bone": "flare5", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare6", "bone": "flare6", "color": "c3cbffff", "blend": "additive" }, + { "name": "portal-flare7", "bone": "flare7", "color": "c3cbffff", "blend": "additive" }, + { "name": "crosshair", "bone": "crosshair" }, + { "name": "muzzle-glow", "bone": "gun-tip", "color": "ffffff00", "blend": "additive" }, + { "name": "muzzle-ring", "bone": "muzzle-ring", "color": "d8baffff", "blend": "additive" }, + { "name": "muzzle-ring2", "bone": "muzzle-ring2", "color": "d8baffff", "blend": "additive" }, + { "name": "muzzle-ring3", "bone": "muzzle-ring3", "color": "d8baffff", "blend": "additive" }, + { "name": "muzzle-ring4", "bone": "muzzle-ring4", "color": "d8baffff", "blend": "additive" } + ], + "ik": [ + { + "name": "aim-ik", + "order": 13, + "bones": [ "rear-upper-arm" ], + "target": "crosshair", + "mix": 0 + }, + { + "name": "aim-torso-ik", + "order": 8, + "bones": [ "aim-constraint-target" ], + "target": "crosshair" + }, + { + "name": "board-ik", + "order": 1, + "bones": [ "hoverboard-controller" ], + "target": "board-ik" + }, + { + "name": "front-foot-ik", + "order": 6, + "bones": [ "front-foot" ], + "target": "front-foot-target" + }, + { + "name": "front-leg-ik", + "order": 4, + "bones": [ "front-thigh", "front-shin" ], + "target": "front-leg-target", + "bendPositive": false + }, + { + "name": "rear-foot-ik", + "order": 7, + "bones": [ "rear-foot" ], + "target": "rear-foot-target" + }, + { + "name": "rear-leg-ik", + "order": 5, + "bones": [ "rear-thigh", "rear-shin" ], + "target": "rear-leg-target", + "bendPositive": false + } + ], + "transform": [ + { + "name": "aim-front-arm-transform", + "order": 11, + "bones": [ "front-upper-arm" ], + "target": "aim-constraint-target", + "rotation": -180, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "aim-head-transform", + "order": 10, + "bones": [ "head" ], + "target": "aim-constraint-target", + "rotation": 84.3, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "aim-rear-arm-transform", + "order": 12, + "bones": [ "rear-upper-arm" ], + "target": "aim-constraint-target", + "x": 57.7, + "y": 56.4, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "aim-torso-transform", + "order": 9, + "bones": [ "torso" ], + "target": "aim-constraint-target", + "rotation": 69.5, + "shearY": -36, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "front-foot-board-transform", + "order": 2, + "bones": [ "front-foot-target" ], + "target": "hoverboard-controller", + "x": -69.8, + "y": 20.7, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "rear-foot-board-transform", + "order": 3, + "bones": [ "rear-foot-target" ], + "target": "hoverboard-controller", + "x": 86.6, + "y": 21.3, + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "shoulder", + "bones": [ "back-shoulder" ], + "target": "front-shoulder", + "x": 40.17, + "y": -1.66, + "mixRotate": 0, + "mixX": -1, + "mixScaleX": 0, + "mixShearY": 0 + }, + { + "name": "toes-board", + "order": 14, + "bones": [ "front-foot-tip", "back-foot-tip" ], + "target": "hoverboard-controller", + "mixRotate": 0, + "mixX": 0, + "mixScaleX": 0, + "mixShearY": 0 + } + ], + "skins": [ + { + "name": "default", + "attachments": { + "clipping": { + "clipping": { + "type": "clipping", + "end": "head-bb", + "vertexCount": 9, + "vertices": [ 66.76, 509.48, 19.98, 434.54, 5.34, 336.28, 22.19, 247.93, 77.98, 159.54, 182.21, -97.56, 1452.26, -99.8, 1454.33, 843.61, 166.57, 841.02 ], + "color": "ce3a3aff" + } + }, + "crosshair": { + "crosshair": { "width": 89, "height": 89 } + }, + "exhaust1": { + "hoverglow-small": { "scaleX": 0.4629, "scaleY": 0.8129, "rotation": -83.07, "width": 274, "height": 75 } + }, + "exhaust2": { + "hoverglow-small": { + "x": 0.01, + "y": -0.76, + "scaleX": 0.4208, + "scaleY": 0.8403, + "rotation": -89.25, + "width": 274, + "height": 75 + } + }, + "exhaust3": { + "hoverglow-small": { "scaleX": 0.4629, "scaleY": 0.8129, "rotation": -83.07, "width": 274, "height": 75 } + }, + "eye": { + "eye-indifferent": { + "type": "mesh", + "uvs": [ 1, 1, 0, 1, 0, 0, 1, 0 ], + "triangles": [ 1, 3, 0, 1, 2, 3 ], + "vertices": [ 2, 66, -36.8, -91.35, 0.3, 46, 73.41, -91.35, 0.7, 2, 66, -87.05, -13.11, 0.70968, 46, 23.16, -13.11, 0.29032, 2, 66, -12.18, 34.99, 0.82818, 46, 98.03, 34.99, 0.17182, 2, 66, 38.07, -43.25, 0.59781, 46, 148.28, -43.25, 0.40219 ], + "hull": 4, + "edges": [ 0, 2, 2, 4, 4, 6, 0, 6 ], + "width": 93, + "height": 89 + }, + "eye-surprised": { + "type": "mesh", + "uvs": [ 1, 1, 0, 1, 0, 0, 1, 0 ], + "triangles": [ 1, 2, 3, 1, 3, 0 ], + "vertices": [ 2, 66, -46.74, -89.7, 0.3, 46, 63.47, -89.7, 0.7, 2, 66, -77.58, -1.97, 0.71, 46, 32.63, -1.97, 0.29, 2, 66, 6.38, 27.55, 0.83, 46, 116.59, 27.55, 0.17, 2, 66, 37.22, -60.19, 0.6, 46, 147.44, -60.19, 0.4 ], + "hull": 4, + "edges": [ 0, 2, 2, 4, 4, 6, 0, 6 ], + "width": 93, + "height": 89 + } + }, + "front-bracer": { + "front-bracer": { "x": 12.03, "y": -1.68, "rotation": 79.6, "width": 58, "height": 80 } + }, + "front-fist": { + "front-fist-closed": { "x": 35.5, "y": 6, "rotation": 67.16, "width": 75, "height": 82 }, + "front-fist-open": { "x": 39.57, "y": 7.76, "rotation": 67.16, "width": 86, "height": 87 } + }, + "front-foot": { + "front-foot": { + "type": "mesh", + "uvs": [ 0.59417, 0.23422, 0.62257, 0.30336, 0.6501, 0.37036, 0.67637, 0.38404, 0.72068, 0.4071, 0.76264, 0.42894, 1, 0.70375, 1, 1, 0.65517, 1, 0.46923, 0.99999, 0, 1, 0, 0.39197, 0.17846, 0, 0.49796, 0 ], + "triangles": [ 8, 9, 3, 4, 8, 3, 5, 8, 4, 6, 8, 5, 8, 6, 7, 11, 1, 10, 0, 12, 13, 0, 11, 12, 0, 1, 11, 9, 2, 3, 1, 2, 10, 9, 10, 2 ], + "vertices": [ 2, 38, 18.17, 41.57, 0.7896, 39, 12.46, 46.05, 0.2104, 2, 38, 24.08, 40.76, 0.71228, 39, 16.12, 41.34, 0.28772, 2, 38, 29.81, 39.98, 0.55344, 39, 19.67, 36.78, 0.44656, 2, 38, 32.81, 41.67, 0.38554, 39, 23, 35.89, 0.61446, 2, 38, 37.86, 44.52, 0.25567, 39, 28.61, 34.4, 0.74433, 2, 38, 42.65, 47.22, 0.17384, 39, 33.92, 32.99, 0.82616, 1, 39, 64.15, 14.56, 1, 1, 39, 64.51, -5.87, 1, 1, 39, 21.08, -6.64, 1, 2, 38, 44.67, -6.77, 0.5684, 39, -2.34, -6.97, 0.4316, 1, 38, 3.1, -48.81, 1, 1, 38, -26.73, -19.31, 1, 1, 38, -30.15, 15.69, 1, 1, 38, -1.84, 44.32, 1 ], + "hull": 14, + "edges": [ 14, 16, 16, 18, 18, 20, 4, 18, 20, 22, 24, 26, 22, 24, 12, 14, 10, 12, 2, 4, 2, 20, 4, 6, 6, 16, 2, 0, 0, 26, 6, 8, 8, 10 ], + "width": 126, + "height": 69 + } + }, + "front-shin": { + "front-shin": { + "type": "mesh", + "uvs": [ 0.90031, 0.05785, 1, 0.12828, 1, 0.21619, 0.9025, 0.31002, 0.78736, 0.35684, 0.78081, 0.39874, 0.77215, 0.45415, 0.77098, 0.51572, 0.84094, 0.63751, 0.93095, 0.7491, 0.95531, 0.7793, 0.78126, 0.87679, 0.5613, 1, 0.2687, 1, 0, 1, 0.00279, 0.96112, 0.01358, 0.81038, 0.02822, 0.60605, 0.08324, 0.45142, 0.18908, 0.31882, 0.29577, 0.2398, 0.30236, 0.14941, 0.37875, 0.05902, 0.53284, 0, 0.70538, 0, 0.41094, 0.71968, 0.40743, 0.54751, 0.41094, 0.4536, 0.4724, 0.35186, 0.33367, 0.27829, 0.50226, 0.31664, 0.65328, 0.67507, 0.60762, 0.52716, 0.6006, 0.45125, 0.62747, 0.37543, 0.6573, 0.3385, 0.27843, 0.32924, 0.18967, 0.45203, 0.16509, 0.58586, 0.18265, 0.7682, 0.50532, 0.24634, 0.59473, 0.17967, 0.60161, 0.10611, 0.51392, 0.04327, 0.72198, 0.28849, 0.82343, 0.20266, 0.86814, 0.11377, 0.79592, 0.04634, 0.44858, 0.15515, 0.25466, 0.96219, 0.53169, 0.9448, 0.7531, 0.8324 ], + "triangles": [ 24, 0, 47, 43, 23, 24, 47, 43, 24, 43, 22, 23, 42, 43, 47, 46, 47, 0, 42, 47, 46, 46, 0, 1, 48, 22, 43, 48, 43, 42, 21, 22, 48, 41, 48, 42, 45, 42, 46, 41, 42, 45, 46, 1, 2, 45, 46, 2, 40, 48, 41, 48, 20, 21, 29, 48, 40, 29, 20, 48, 44, 41, 45, 40, 41, 44, 3, 45, 2, 44, 45, 3, 30, 29, 40, 35, 30, 40, 36, 19, 20, 36, 20, 29, 44, 35, 40, 28, 29, 30, 4, 44, 3, 35, 44, 4, 34, 30, 35, 5, 35, 4, 34, 28, 30, 33, 28, 34, 37, 19, 36, 18, 19, 37, 27, 29, 28, 27, 28, 33, 36, 29, 27, 37, 36, 27, 5, 34, 35, 6, 34, 5, 33, 34, 6, 6, 32, 33, 7, 32, 6, 26, 37, 27, 38, 18, 37, 38, 37, 26, 17, 18, 38, 31, 32, 7, 31, 7, 8, 32, 25, 26, 38, 26, 25, 27, 33, 32, 32, 26, 27, 39, 38, 25, 17, 38, 39, 16, 17, 39, 51, 31, 8, 51, 8, 9, 11, 51, 9, 11, 9, 10, 31, 50, 25, 31, 25, 32, 50, 31, 51, 49, 39, 25, 49, 25, 50, 15, 16, 39, 49, 15, 39, 13, 49, 50, 14, 15, 49, 13, 14, 49, 12, 50, 51, 12, 51, 11, 13, 50, 12 ], + "vertices": [ -23.66, 19.37, -11.73, 28.98, 4.34, 30.83, 22.41, 24.87, 32.05, 16.48, 39.77, 16.83, 49.98, 17.3, 61.25, 18.5, 82.85, 26.78, 102.4, 36.46, 107.69, 39.09, 127.15, 26.97, 151.74, 11.65, 154.49, -12.18, 157.02, -34.07, 149.89, -34.66, 122.23, -36.97, 84.75, -40.09, 55.97, -38.88, 30.73, -33.05, 15.29, -26.03, -1.3, -27.41, -18.54, -23.09, -30.78, -11.79, -32.4, 2.27, 101.92, -6.52, 70.48, -10.44, 53.28, -12.14, 34.11, -9.28, 21.96, -22.13, 27.39, -7.59, 91.48, 12.28, 64.88, 5.44, 51.07, 3.26, 36.95, 3.85, 29.92, 5.5, 31.8, -25.56, 55.08, -30.19, 79.77, -29.37, 112.93, -24.09, 14.51, -8.83, 1.48, -2.95, -12.03, -3.94, -22.69, -12.41, 20.17, 9.71, 3.53, 16.16, -13.14, 17.93, -24.78, 10.62, -1.62, -15.37, 147.71, -14.13, 141.93, 8.07, 119.3, 23.74 ], + "hull": 25, + "edges": [ 8, 6, 6, 4, 4, 2, 2, 0, 0, 48, 46, 48, 46, 44, 44, 42, 42, 40, 40, 38, 38, 36, 36, 34, 32, 34, 50, 52, 52, 54, 54, 56, 40, 58, 58, 60, 8, 10, 20, 22, 22, 24, 62, 64, 64, 66, 66, 68, 8, 70, 70, 60, 68, 70, 58, 72, 72, 74, 74, 76, 76, 78, 24, 26, 26, 28, 58, 80, 80, 82, 82, 84, 84, 86, 86, 44, 70, 88, 88, 90, 90, 92, 92, 94, 94, 48, 80, 88, 88, 6, 82, 90, 90, 4, 84, 92, 92, 2, 86, 94, 94, 0, 56, 60, 10, 12, 12, 14, 14, 16, 28, 30, 30, 32, 26, 98, 98, 78, 30, 98, 24, 100, 100, 50, 98, 100, 22, 102, 102, 62, 100, 102, 16, 18, 18, 20, 102, 18 ], + "width": 82, + "height": 184 + } + }, + "front-thigh": { + "front-thigh": { "x": 42.48, "y": 4.45, "rotation": 84.87, "width": 45, "height": 112 } + }, + "front-upper-arm": { + "front-upper-arm": { "x": 28.31, "y": 7.37, "rotation": 97.9, "width": 46, "height": 97 } + }, + "goggles": { + "goggles": { + "type": "mesh", + "uvs": [ 0.53653, 0.04114, 0.72922, 0.16036, 0.91667, 0.33223, 0.97046, 0.31329, 1, 0.48053, 0.95756, 0.5733, 0.88825, 0.6328, 0.86878, 0.78962, 0.77404, 0.8675, 0.72628, 1, 0.60714, 0.93863, 0.49601, 0.88138, 0.41558, 0.75027, 0.32547, 0.70084, 0.2782, 0.58257, 0.1721, 0.63281, 0.17229, 0.75071, 0.10781, 0.79898, 0, 0.32304, 0, 0.12476, 0.07373, 0.07344, 0.15423, 0.10734, 0.23165, 0.13994, 0.30313, 0.02256, 0.34802, 0, 0.42979, 0.69183, 0.39476, 0.51042, 0.39488, 0.31512, 0.45878, 0.23198, 0.56501, 0.28109, 0.69961, 0.39216, 0.82039, 0.54204, 0.85738, 0.62343, 0.91107, 0.51407, 0.72639, 0.32147, 0.58764, 0.19609, 0.48075, 0.11269, 0.37823, 0.05501, 0.3287, 0.17866, 0.319, 0.305, 0.36036, 0.53799, 0.40327, 0.70072, 0.30059, 0.55838, 0.21957, 0.2815, 0.09963, 0.28943, 0.56863, 0.4368, 0.4911, 0.37156, 0.51185, 0.52093, 0.67018, 0.59304, 0.7619, 0.68575, 0.73296, 0.43355 ], + "triangles": [ 18, 44, 15, 21, 19, 20, 17, 18, 15, 44, 19, 21, 2, 3, 4, 18, 19, 44, 2, 33, 34, 33, 2, 4, 5, 33, 4, 5, 6, 33, 7, 32, 6, 31, 50, 33, 32, 31, 33, 6, 32, 33, 31, 49, 50, 49, 31, 32, 49, 32, 7, 8, 49, 7, 33, 50, 34, 17, 15, 16, 9, 48, 8, 49, 48, 50, 50, 48, 45, 47, 45, 48, 50, 45, 30, 45, 47, 46, 45, 46, 29, 30, 45, 29, 30, 29, 34, 30, 34, 50, 47, 26, 46, 25, 10, 11, 12, 25, 11, 41, 12, 42, 42, 44, 43, 43, 21, 22, 41, 40, 25, 41, 42, 40, 29, 35, 34, 40, 26, 25, 25, 26, 47, 37, 24, 0, 36, 37, 0, 42, 43, 39, 42, 39, 40, 28, 38, 36, 40, 39, 26, 28, 27, 38, 26, 39, 27, 37, 38, 23, 39, 43, 38, 38, 37, 36, 27, 39, 38, 43, 22, 38, 37, 23, 24, 22, 23, 38, 36, 0, 35, 28, 36, 35, 29, 28, 35, 27, 28, 46, 26, 27, 46, 35, 0, 1, 34, 35, 1, 12, 41, 25, 47, 10, 25, 44, 21, 43, 42, 14, 44, 14, 15, 44, 13, 14, 42, 12, 13, 42, 46, 28, 29, 47, 48, 10, 48, 9, 10, 49, 8, 48, 2, 34, 1 ], + "vertices": [ 2, 66, 61.88, 22.81, 0.832, 46, 172.09, 22.81, 0.168, 2, 66, 59.89, -31.19, 0.6855, 46, 170.1, -31.19, 0.3145, 2, 66, 49.2, -86.8, 0.32635, 46, 159.41, -86.8, 0.67365, 2, 66, 56.82, -99.01, 0.01217, 46, 167.03, -99.01, 0.98783, 1, 46, 143.4, -115.48, 1, 2, 66, 15, -110.14, 0.0041, 46, 125.21, -110.14, 0.9959, 2, 66, -0.32, -96.36, 0.07948, 46, 109.89, -96.36, 0.92052, 2, 66, -26.56, -100.19, 0.01905, 46, 83.65, -100.19, 0.98095, 2, 66, -46.96, -81.16, 0.4921, 46, 63.26, -81.16, 0.50791, 2, 66, -71.84, -76.69, 0.56923, 46, 38.37, -76.69, 0.43077, 2, 66, -72.54, -43.98, 0.74145, 46, 37.67, -43.98, 0.25855, 2, 66, -73.2, -13.47, 0.87929, 46, 37.01, -13.47, 0.12071, 2, 66, -59.63, 13.55, 0.864, 46, 50.58, 13.55, 0.136, 2, 66, -59.69, 38.45, 0.85289, 46, 50.52, 38.45, 0.14711, 2, 66, -45.26, 56.6, 0.74392, 46, 64.95, 56.6, 0.25608, 2, 66, -62.31, 79.96, 0.624, 46, 47.9, 79.96, 0.376, 2, 66, -80.76, 73.42, 0.616, 46, 29.45, 73.42, 0.384, 2, 66, -93.9, 86.64, 0.288, 46, 16.31, 86.64, 0.712, 1, 46, 81.51, 139.38, 1, 1, 46, 112.56, 150.3, 1, 2, 66, 16.76, 134.97, 0.02942, 46, 126.97, 134.97, 0.97058, 2, 66, 18.42, 113.28, 0.36147, 46, 128.63, 113.28, 0.63853, 2, 66, 20.02, 92.43, 0.7135, 46, 130.23, 92.43, 0.2865, 2, 66, 44.58, 81.29, 0.69603, 46, 154.79, 81.29, 0.30397, 2, 66, 52, 71.48, 0.848, 46, 162.21, 71.48, 0.152, 2, 66, -49.25, 13.27, 0.8, 46, 60.96, 13.27, 0.2, 2, 66, -23.88, 31.88, 0.896, 46, 86.33, 31.88, 0.104, 2, 66, 6.72, 42.6, 0.928, 46, 116.93, 42.6, 0.072, 2, 66, 25.26, 31.44, 0.8, 46, 135.47, 31.44, 0.2, 2, 66, 26.77, 2.59, 0.75, 46, 136.98, 2.59, 0.25, 2, 66, 21.02, -36.66, 0.54887, 46, 131.23, -36.66, 0.45113, 2, 66, 8.01, -74.65, 0.36029, 46, 118.22, -74.65, 0.63971, 2, 66, -1.52, -88.24, 0.1253, 46, 108.69, -88.24, 0.8747, 2, 66, 20.25, -95.44, 0.08687, 46, 130.46, -95.44, 0.91313, 2, 66, 34.42, -39.36, 0.72613, 46, 144.63, -39.36, 0.27387, 2, 66, 42.03, 1.7, 0.824, 46, 152.25, 1.7, 0.176, 2, 66, 45.85, 32.6, 0.856, 46, 156.06, 32.6, 0.144, 1, 66, 46.01, 61.02, 1, 1, 66, 22.35, 66.41, 1, 1, 66, 1.73, 61.84, 1, 2, 66, -31.17, 38.83, 0.928, 46, 79.04, 38.83, 0.072, 2, 66, -52.94, 19.31, 0.79073, 46, 57.27, 19.31, 0.20927, 2, 66, -39.54, 52.42, 0.912, 46, 70.67, 52.42, 0.088, 2, 66, -3.2, 87.61, 0.744, 46, 107.02, 87.61, 0.256, 2, 66, -14.82, 116.7, 0.6368, 46, 95.4, 116.7, 0.3632, 2, 66, 2.7, -6.87, 0.856, 46, 112.91, -6.87, 0.144, 2, 66, 6.21, 15.8, 0.744, 46, 116.42, 15.8, 0.256, 2, 66, -15.39, 2.47, 0.856, 46, 94.82, 2.47, 0.144, 2, 66, -12.98, -40.48, 0.72102, 46, 97.24, -40.48, 0.27898, 2, 66, -19.55, -68.16, 0.59162, 46, 90.66, -68.16, 0.40838, 2, 66, 17.44, -47.15, 0.53452, 46, 127.65, -47.15, 0.46548 ], + "hull": 25, + "edges": [ 36, 34, 34, 32, 32, 30, 30, 28, 28, 26, 26, 24, 24, 22, 18, 16, 16, 14, 14, 12, 12, 10, 10, 8, 8, 6, 6, 4, 4, 2, 2, 0, 0, 48, 48, 46, 46, 44, 36, 38, 40, 38, 24, 50, 50, 52, 52, 54, 54, 56, 56, 58, 58, 60, 62, 64, 64, 12, 8, 66, 66, 68, 68, 70, 70, 72, 72, 74, 74, 76, 76, 78, 78, 80, 80, 82, 82, 24, 24, 84, 84, 86, 86, 44, 40, 42, 42, 44, 42, 88, 88, 30, 58, 90, 90, 92, 92, 94, 18, 20, 20, 22, 94, 20, 18, 96, 96, 98, 60, 100, 100, 62, 98, 100 ], + "width": 261, + "height": 166 + } + }, + "gun": { + "gun": { "x": 77.3, "y": 16.4, "rotation": 60.83, "width": 210, "height": 203 } + }, + "head": { + "head": { + "type": "mesh", + "uvs": [ 0.75919, 0.06107, 0.88392, 0.17893, 0.90174, 0.30856, 0.94224, 0.1966, 1, 0.26584, 1, 0.422, 0.95864, 0.46993, 0.92118, 0.51333, 0.85957, 0.5347, 0.78388, 0.65605, 0.74384, 0.74838, 0.85116, 0.75151, 0.84828, 0.82564, 0.81781, 0.85367, 0.75599, 0.85906, 0.76237, 0.90468, 0.65875, 1, 0.38337, 1, 0.1858, 0.85404, 0.12742, 0.81091, 0.06025, 0.69209, 0, 0.58552, 0, 0.41021, 0.0853, 0.20692, 0.24243, 0.14504, 0.5, 0.1421, 0.50324, 0.07433, 0.41738, 0, 0.57614, 0, 0.85059, 0.36087, 0.73431, 0.43206, 0.68481, 0.31271, 0.72165, 0.16718, 0.55931, 0.04154, 0.44764, 0.22895, 0.23926, 0.26559, 0.71272, 0.44036, 0.56993, 0.383, 0.41678, 0.33511, 0.293, 0.31497, 0.70802, 0.44502, 0.56676, 0.38976, 0.41521, 0.34416, 0.28754, 0.33017, 0.88988, 0.50177, 0.30389, 0.73463, 0.2646, 0.65675, 0.21414, 0.61584, 0.14613, 0.62194, 0.10316, 0.66636, 0.10358, 0.72557, 0.14505, 0.79164, 0.20263, 0.81355, 0.27873, 0.80159, 0.34947, 0.7376, 0.23073, 0.57073, 0.08878, 0.60707, 0.29461, 0.8129, 0.73006, 0.87883, 0.69805, 0.87348, 0.66166, 0.79681, 0.22468, 0.69824, 0.14552, 0.67405 ], + "triangles": [ 50, 49, 62, 34, 25, 31, 39, 35, 34, 38, 39, 34, 37, 38, 34, 42, 39, 38, 43, 39, 42, 32, 2, 31, 31, 37, 34, 42, 38, 37, 41, 42, 37, 43, 22, 39, 30, 31, 29, 36, 37, 31, 30, 36, 31, 40, 41, 37, 36, 40, 37, 36, 30, 44, 55, 22, 43, 55, 48, 56, 47, 48, 55, 46, 55, 54, 42, 55, 43, 47, 55, 46, 62, 49, 48, 61, 47, 46, 62, 48, 47, 61, 62, 47, 46, 54, 45, 42, 41, 55, 61, 46, 45, 55, 41, 54, 61, 51, 50, 61, 50, 62, 60, 41, 40, 54, 41, 60, 53, 61, 45, 52, 51, 61, 57, 53, 45, 57, 45, 54, 53, 52, 61, 52, 19, 51, 57, 18, 52, 57, 52, 53, 17, 54, 60, 57, 54, 17, 18, 57, 17, 19, 50, 51, 33, 27, 28, 26, 27, 33, 0, 33, 28, 32, 33, 0, 32, 0, 1, 33, 25, 26, 33, 32, 25, 31, 25, 32, 2, 32, 1, 2, 3, 4, 29, 31, 2, 2, 4, 5, 29, 2, 5, 6, 29, 5, 30, 29, 6, 44, 30, 6, 18, 19, 52, 49, 56, 48, 34, 24, 25, 35, 23, 24, 35, 24, 34, 39, 22, 35, 22, 23, 35, 7, 44, 6, 8, 36, 44, 40, 36, 8, 8, 44, 7, 56, 21, 22, 55, 56, 22, 9, 40, 8, 20, 21, 56, 20, 56, 49, 9, 60, 40, 10, 60, 9, 20, 50, 19, 12, 10, 11, 13, 10, 12, 14, 60, 10, 13, 14, 10, 59, 60, 14, 58, 59, 14, 58, 14, 15, 16, 17, 60, 59, 16, 60, 15, 16, 59, 15, 59, 58, 20, 49, 50 ], + "vertices": [ 2, 50, 41.97, -41.8, 0.94074, 66, 165.41, -22.6, 0.05926, 4, 48, 73.47, 27.54, 0.26795, 50, -5.75, -51.71, 0.4738, 49, 112.99, -11.41, 0.12255, 66, 143.5, -66.13, 0.1357, 4, 48, 38.23, 10.99, 0.6831, 50, -41.01, -35.22, 0.07866, 49, 92.73, -44.66, 0.04872, 66, 108.65, -83.49, 0.18952, 2, 48, 73.35, 10.89, 0.8455, 66, 143.77, -82.78, 0.1545, 2, 48, 58.59, -10.38, 0.91607, 66, 129.5, -104.39, 0.08393, 3, 46, 195.82, -119.82, 0.104, 47, 75.49, -4.55, 0.09191, 48, 14.36, -24.8, 0.80409, 4, 46, 178.62, -113.98, 0.19022, 47, 59.82, -13.72, 0.33409, 48, -2.7, -18.57, 0.46643, 66, 68.41, -113.98, 0.00926, 4, 46, 163.06, -108.69, 0.18724, 47, 45.64, -22.03, 0.3133, 48, -18.14, -12.93, 0.47469, 66, 52.84, -108.69, 0.02477, 2, 46, 151.52, -95.05, 0.91122, 66, 41.31, -95.05, 0.08878, 2, 46, 110.61, -87.69, 0.70564, 66, 0.4, -87.69, 0.29436, 2, 46, 81.05, -86.58, 0.63951, 66, -29.16, -86.58, 0.36049, 2, 46, 89.82, -114.32, 0.57, 66, -20.39, -114.32, 0.43, 2, 46, 68.72, -120.91, 0.57, 66, -41.49, -120.91, 0.43, 2, 46, 58.1, -115.9, 0.57, 66, -52.11, -115.9, 0.43, 2, 46, 51.03, -100.63, 0.64242, 66, -59.18, -100.63, 0.35758, 2, 46, 38.79, -106.76, 0.81659, 66, -71.43, -106.76, 0.18341, 2, 46, 2.68, -89.7, 0.77801, 66, -107.53, -89.7, 0.22199, 2, 46, -22.07, -19.3, 0.823, 66, -132.28, -19.3, 0.177, 2, 46, 1.2, 45.63, 0.51204, 66, -109.01, 45.63, 0.48796, 2, 46, 8.07, 64.81, 0.60869, 66, -102.14, 64.81, 0.39131, 2, 46, 35.44, 93.73, 0.80009, 66, -74.77, 93.73, 0.19991, 2, 46, 59.98, 119.66, 0.93554, 66, -50.23, 119.66, 0.06446, 2, 46, 109.26, 136.99, 0.99895, 66, -0.95, 136.99, 0.00105, 1, 46, 174.07, 135.27, 1, 3, 46, 205.59, 101.22, 0.80778, 49, -16.84, 104.63, 0.15658, 66, 95.38, 101.22, 0.03564, 3, 50, 58.94, 30.5, 0.43491, 49, 38.36, 61.89, 0.28116, 66, 119.35, 35.65, 0.28393, 2, 50, 75.56, 19.01, 0.92164, 66, 138.68, 41.52, 0.07836, 1, 50, 106.7, 26.9, 1, 1, 50, 83.79, -9.51, 1, 5, 47, 44.51, 27.24, 0.15139, 48, 19.12, 19.33, 0.44847, 50, -46.82, -15.19, 0.05757, 49, 72.19, -48.24, 0.1149, 66, 89.35, -75.58, 0.22767, 3, 47, 7.42, 19.08, 0.37772, 49, 34.32, -45.24, 0.09918, 66, 58.9, -52.89, 0.52311, 2, 49, 45.94, -9.07, 0.4826, 66, 87.99, -28.45, 0.5174, 2, 50, 20.62, -16.35, 0.7435, 66, 132.21, -23.49, 0.2565, 2, 50, 75.74, 0.94, 0.97172, 66, 152.95, 30.42, 0.02828, 4, 46, 200.45, 40.46, 0.18809, 50, 44.6, 56.29, 0.05831, 49, 11.15, 50.46, 0.14366, 66, 90.24, 40.46, 0.60994, 2, 46, 171.41, 90.12, 0.48644, 66, 61.2, 90.12, 0.51356, 2, 46, 164.84, -48.18, 0.43217, 66, 54.62, -48.18, 0.56783, 4, 46, 168.13, -6.02, 0.01949, 47, -28.65, 49.02, 0.02229, 49, 8.54, -6.09, 0.12791, 66, 57.92, -6.02, 0.83031, 2, 46, 167.84, 37.87, 0.15, 66, 57.63, 37.87, 0.85, 2, 46, 162.36, 71.5, 0.24107, 66, 52.15, 71.5, 0.75893, 2, 46, 163.11, -47.44, 0.41951, 66, 52.9, -47.44, 0.58049, 2, 46, 165.94, -5.87, 0.16355, 66, 55.73, -5.87, 0.83645, 2, 46, 165.14, 37.38, 0.15, 66, 54.93, 37.38, 0.85, 2, 46, 157.6, 71.4, 0.21735, 66, 47.39, 71.4, 0.78265, 3, 46, 163.5, -99.54, 0.61812, 47, 39.01, -15.71, 0.30445, 66, 53.29, -99.54, 0.07744, 2, 46, 45.38, 27.24, 0.16741, 66, -64.83, 27.24, 0.83259, 2, 46, 63.74, 44.98, 0.15, 66, -46.47, 44.98, 0.85, 2, 46, 70.7, 61.92, 0.22175, 66, -39.51, 61.92, 0.77825, 2, 46, 62.88, 78.71, 0.38, 66, -47.34, 78.71, 0.62, 2, 46, 46.53, 85.3, 0.51, 66, -63.68, 85.3, 0.49, 2, 46, 29.92, 79.34, 0.388, 66, -80.29, 79.34, 0.612, 2, 46, 15.08, 62.21, 0.38, 66, -95.13, 62.21, 0.62, 2, 46, 14.09, 45.32, 0.41, 66, -96.12, 45.32, 0.59, 2, 46, 24.3, 27.06, 0.192, 66, -85.91, 27.06, 0.808, 1, 66, -61.57, 15.3, 1, 2, 46, 84.87, 62.14, 0.16757, 66, -25.34, 62.14, 0.83243, 2, 46, 61.9, 94.84, 0.68145, 66, -48.31, 94.84, 0.31855, 2, 46, 22.54, 21.88, 0.16, 66, -87.67, 21.88, 0.84, 2, 46, 43.15, -95.95, 0.73445, 66, -67.06, -95.95, 0.26555, 2, 46, 41.77, -87.24, 0.67858, 66, -68.44, -87.24, 0.32142, 2, 46, 60.05, -70.36, 0.50195, 66, -50.16, -70.36, 0.49805, 2, 46, 48.49, 51.09, 0.25, 66, -61.72, 51.09, 0.75, 2, 46, 48.17, 73.71, 0.15634, 66, -62.04, 73.71, 0.84366 ], + "hull": 29, + "edges": [ 10, 8, 8, 6, 6, 4, 4, 2, 2, 0, 0, 56, 54, 56, 54, 52, 52, 50, 50, 48, 48, 46, 46, 44, 42, 44, 32, 34, 4, 58, 58, 60, 62, 64, 64, 66, 66, 54, 50, 68, 68, 70, 70, 44, 60, 72, 62, 74, 72, 74, 74, 76, 76, 78, 78, 44, 16, 80, 80, 82, 82, 84, 84, 86, 86, 44, 14, 88, 88, 72, 14, 16, 10, 12, 12, 14, 12, 60, 90, 92, 92, 94, 94, 96, 96, 98, 98, 100, 100, 102, 102, 104, 104, 106, 106, 90, 108, 110, 110, 112, 38, 40, 40, 42, 112, 40, 34, 36, 36, 38, 36, 114, 114, 108, 30, 32, 30, 28, 24, 26, 28, 26, 22, 24, 22, 20, 20, 18, 18, 16, 28, 116, 116, 118, 118, 120, 120, 20 ], + "width": 271, + "height": 298 + } + }, + "head-bb": { + "head": { + "type": "boundingbox", + "vertexCount": 6, + "vertices": [ -19.14, -70.3, 40.8, -118.08, 257.78, -115.62, 285.17, 57.18, 120.77, 164.95, -5.07, 76.95 ] + } + }, + "hoverboard-board": { + "hoverboard-board": { + "type": "mesh", + "uvs": [ 0.13865, 0.56624, 0.11428, 0.51461, 0.07619, 0.52107, 0.02364, 0.52998, 0.01281, 0.53182, 0, 0.37979, 0, 0.2206, 0.00519, 0.10825, 0.01038, 0.10726, 0.03834, 0.10194, 0.05091, 0, 0.08326, 0, 0.10933, 0.04206, 0.1382, 0.08865, 0.18916, 0.24067, 0.22234, 0.4063, 0.23886, 0.44063, 0.83412, 0.44034, 0.88444, 0.38296, 0.92591, 0.32639, 0.95996, 0.28841, 0.98612, 0.28542, 1, 0.38675, 0.99494, 0.47104, 0.97883, 0.53251, 0.94409, 0.62135, 0.90206, 0.69492, 0.86569, 0.71094, 0.82822, 0.70791, 0.81286, 0.77127, 0.62931, 0.77266, 0.61364, 0.70645, 0.47166, 0.70664, 0.45901, 0.77827, 0.27747, 0.76986, 0.2658, 0.70372, 0.24976, 0.71381, 0.24601, 0.77827, 0.23042, 0.84931, 0.20926, 0.90956, 0.17299, 1, 0.15077, 0.99967, 0.12906, 0.90192, 0.10369, 0.73693, 0.10198, 0.62482, 0.09131, 0.47272, 0.09133, 0.41325, 0.15082, 0.41868, 0.21991, 0.51856, 0.06331, 0.10816, 0.08383, 0.21696, 0.08905, 0.37532, 0.15903, 0.58726, 0.17538, 0.65706, 0.20118, 0.8029, 0.17918, 0.55644, 0.22166, 0.5802, 0.86259, 0.57962, 0.92346, 0.48534, 0.96691, 0.36881, 0.0945, 0.13259, 0.12688, 0.17831, 0.15986, 0.24682, 0.18036, 0.31268, 0.20607, 0.4235, 0.16074, 0.85403, 0.13624, 0.70122, 0.12096, 0.64049, 0.02396, 0.21811, 0.02732, 0.37839, 0.02557, 0.4972, 0.14476, 0.45736, 0.18019, 0.51689, 0.19692, 0.56636 ], + "triangles": [ 10, 11, 12, 9, 10, 12, 49, 9, 12, 60, 49, 12, 13, 60, 12, 61, 60, 13, 50, 49, 60, 50, 60, 61, 68, 8, 9, 68, 9, 49, 68, 49, 50, 7, 8, 68, 6, 7, 68, 61, 13, 14, 62, 61, 14, 50, 61, 62, 63, 62, 14, 59, 20, 21, 19, 20, 59, 51, 50, 62, 51, 62, 63, 51, 69, 68, 51, 68, 50, 6, 68, 69, 5, 6, 69, 18, 19, 59, 15, 63, 14, 59, 21, 22, 47, 51, 63, 47, 46, 51, 47, 63, 64, 15, 64, 63, 64, 15, 16, 71, 46, 47, 23, 59, 22, 69, 51, 70, 45, 46, 71, 70, 51, 2, 58, 18, 59, 58, 59, 23, 17, 18, 58, 70, 5, 69, 2, 51, 46, 1, 45, 71, 47, 48, 71, 47, 64, 48, 48, 72, 71, 1, 71, 72, 16, 48, 64, 45, 2, 46, 2, 45, 1, 70, 4, 5, 3, 70, 2, 3, 4, 70, 24, 58, 23, 72, 0, 1, 73, 55, 72, 55, 0, 72, 48, 73, 72, 57, 17, 58, 25, 57, 58, 56, 48, 16, 73, 48, 56, 56, 16, 17, 56, 17, 57, 52, 0, 55, 24, 25, 58, 44, 0, 52, 67, 44, 52, 52, 56, 53, 73, 52, 55, 56, 52, 73, 67, 52, 53, 26, 57, 25, 66, 67, 53, 56, 32, 35, 53, 56, 35, 56, 57, 32, 28, 31, 57, 57, 31, 32, 57, 27, 28, 26, 27, 57, 36, 53, 35, 43, 44, 67, 43, 67, 66, 34, 35, 32, 29, 31, 28, 30, 31, 29, 53, 54, 66, 53, 36, 54, 33, 34, 32, 37, 54, 36, 65, 43, 66, 38, 54, 37, 54, 65, 66, 39, 65, 54, 42, 43, 65, 38, 39, 54, 40, 42, 65, 40, 41, 42, 65, 39, 40 ], + "vertices": [ -189.36, 15.62, -201.35, 23.47, -220.09, 22.49, -245.95, 21.13, -251.28, 20.86, -257.58, 43.96, -257.57, 68.16, -255.02, 85.24, -252.47, 85.39, -238.71, 86.2, -232.52, 101.69, -216.61, 101.69, -203.78, 95.3, -189.58, 88.21, -164.51, 65.1, -148.19, 39.93, -140.06, 34.71, 152.82, 34.73, 177.57, 43.45, 197.97, 52.05, 214.72, 57.82, 227.6, 58.27, 234.42, 42.87, 231.94, 30.06, 224.01, 20.72, 206.91, 7.21, 186.23, -3.97, 168.34, -6.4, 149.9, -5.94, 142.35, -15.57, 52.04, -15.77, 44.33, -5.71, -25.52, -5.73, -31.75, -16.62, -121.07, -15.34, -126.81, -5.28, -134.7, -6.81, -136.54, -16.61, -144.22, -27.41, -154.63, -36.57, -172.47, -50.31, -183.41, -50.26, -194.09, -35.4, -206.56, -10.32, -207.4, 6.72, -212.65, 29.84, -212.64, 38.88, -183.37, 38.05, -149.38, 22.86, -226.43, 85.25, -216.33, 68.71, -213.76, 44.64, -179.34, 12.42, -171.29, 1.81, -158.6, -20.36, -169.42, 17.11, -148.52, 13.49, 166.82, 13.56, 196.76, 27.89, 218.14, 45.6, -211.08, 81.54, -195.15, 74.59, -178.93, 64.17, -168.84, 54.16, -156.19, 37.31, -178.5, -28.13, -190.55, -4.9, -198.07, 4.33, -245.79, 68.54, -244.14, 44.18, -245, 26.12, -186.36, 32.17, -168.92, 23.12, -160.69, 15.6 ], + "hull": 45, + "edges": [ 0, 2, 8, 10, 10, 12, 12, 14, 18, 20, 20, 22, 26, 28, 28, 30, 30, 32, 32, 34, 34, 36, 36, 38, 38, 40, 40, 42, 42, 44, 44, 46, 46, 48, 48, 50, 50, 52, 52, 54, 54, 56, 56, 58, 58, 60, 60, 62, 62, 64, 64, 66, 66, 68, 68, 70, 70, 72, 72, 74, 80, 82, 82, 84, 84, 86, 86, 88, 0, 88, 2, 90, 90, 92, 92, 94, 94, 96, 96, 32, 18, 98, 98, 100, 100, 102, 2, 4, 102, 4, 92, 102, 0, 104, 104, 106, 106, 108, 78, 80, 108, 78, 74, 76, 76, 78, 62, 56, 64, 70, 0, 110, 112, 114, 114, 116, 116, 118, 118, 42, 50, 116, 114, 34, 98, 120, 120, 122, 22, 24, 24, 26, 120, 24, 122, 124, 124, 126, 126, 128, 128, 96, 80, 130, 130, 132, 132, 134, 134, 88, 14, 16, 16, 18, 136, 16, 136, 138, 138, 140, 4, 6, 6, 8, 140, 6, 96, 112, 92, 142, 142, 144, 110, 146, 146, 112, 144, 146 ], + "width": 492, + "height": 152 + } + }, + "hoverboard-thruster-front": { + "hoverboard-thruster": { "x": 0.02, "y": -7.08, "rotation": 0.17, "width": 60, "height": 64 } + }, + "hoverboard-thruster-rear": { + "hoverboard-thruster": { "x": 1.1, "y": -6.29, "rotation": 0.17, "width": 60, "height": 64 } + }, + "hoverglow-front": { + "hoverglow-small": { + "x": 2.13, + "y": -2, + "scaleX": 0.303, + "scaleY": 0.495, + "rotation": 0.15, + "width": 274, + "height": 75 + } + }, + "hoverglow-rear": { + "hoverglow-small": { + "x": 1.39, + "y": -2.09, + "scaleX": 0.303, + "scaleY": 0.495, + "rotation": 0.61, + "width": 274, + "height": 75 + } + }, + "mouth": { + "mouth-grind": { + "type": "mesh", + "uvs": [ 1, 1, 0, 1, 0, 0, 1, 0 ], + "triangles": [ 1, 3, 0, 1, 2, 3 ], + "vertices": [ 2, 66, -98.93, -85.88, 0.22, 46, 11.28, -85.88, 0.78, 2, 66, -129.77, 1.84, 0.6, 46, -19.56, 1.84, 0.4, 2, 66, -74.12, 21.41, 0.6, 46, 36.09, 21.41, 0.4, 2, 66, -43.28, -66.32, 0.4, 46, 66.93, -66.32, 0.6 ], + "hull": 4, + "edges": [ 0, 2, 2, 4, 4, 6, 0, 6 ], + "width": 93, + "height": 59 + }, + "mouth-oooo": { + "type": "mesh", + "uvs": [ 1, 1, 0, 1, 0, 0, 1, 0 ], + "triangles": [ 1, 3, 0, 1, 2, 3 ], + "vertices": [ 2, 46, 11.28, -85.89, 0.22, 66, -98.93, -85.89, 0.78, 2, 46, -19.56, 1.85, 0.6, 66, -129.78, 1.85, 0.4, 2, 46, 36.1, 21.42, 0.6, 66, -74.12, 21.42, 0.4, 2, 46, 66.94, -66.32, 0.4, 66, -43.27, -66.32, 0.6 ], + "hull": 4, + "edges": [ 0, 2, 2, 4, 4, 6, 0, 6 ], + "width": 93, + "height": 59 + }, + "mouth-smile": { + "type": "mesh", + "uvs": [ 1, 1, 0, 1, 0, 0, 1, 0 ], + "triangles": [ 1, 3, 0, 1, 2, 3 ], + "vertices": [ 2, 66, -98.93, -85.89, 0.21075, 46, 11.28, -85.89, 0.78925, 2, 66, -129.77, 1.85, 0.6, 46, -19.56, 1.85, 0.4, 2, 66, -74.11, 21.42, 0.6, 46, 36.1, 21.42, 0.4, 2, 66, -43.27, -66.32, 0.40772, 46, 66.94, -66.32, 0.59228 ], + "hull": 4, + "edges": [ 0, 2, 2, 4, 4, 6, 0, 6 ], + "width": 93, + "height": 59 + } + }, + "muzzle": { + "muzzle01": { + "x": 151.97, + "y": 5.81, + "scaleX": 3.7361, + "scaleY": 3.7361, + "rotation": 0.15, + "width": 133, + "height": 79 + }, + "muzzle02": { + "x": 187.25, + "y": 5.9, + "scaleX": 4.0623, + "scaleY": 4.0623, + "rotation": 0.15, + "width": 135, + "height": 84 + }, + "muzzle03": { + "x": 231.96, + "y": 6.02, + "scaleX": 4.1325, + "scaleY": 4.1325, + "rotation": 0.15, + "width": 166, + "height": 106 + }, + "muzzle04": { + "x": 231.96, + "y": 6.02, + "scaleX": 4.0046, + "scaleY": 4.0046, + "rotation": 0.15, + "width": 149, + "height": 90 + }, + "muzzle05": { + "x": 293.8, + "y": 6.19, + "scaleX": 4.4673, + "scaleY": 4.4673, + "rotation": 0.15, + "width": 135, + "height": 75 + } + }, + "muzzle-glow": { + "muzzle-glow": { "width": 50, "height": 50 } + }, + "muzzle-ring": { + "muzzle-ring": { "x": -1.3, "y": 0.32, "scaleX": 0.3147, "scaleY": 0.3147, "width": 49, "height": 209 } + }, + "muzzle-ring2": { + "muzzle-ring": { "x": -1.3, "y": 0.32, "scaleX": 0.3147, "scaleY": 0.3147, "width": 49, "height": 209 } + }, + "muzzle-ring3": { + "muzzle-ring": { "x": -1.3, "y": 0.32, "scaleX": 0.3147, "scaleY": 0.3147, "width": 49, "height": 209 } + }, + "muzzle-ring4": { + "muzzle-ring": { "x": -1.3, "y": 0.32, "scaleX": 0.3147, "scaleY": 0.3147, "width": 49, "height": 209 } + }, + "neck": { + "neck": { "x": 9.77, "y": -3.01, "rotation": -55.22, "width": 36, "height": 41 } + }, + "portal-bg": { + "portal-bg": { "x": -3.1, "y": 7.25, "scaleX": 1.0492, "scaleY": 1.0492, "width": 266, "height": 266 } + }, + "portal-flare1": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare2": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare3": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare4": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare5": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare6": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare7": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare8": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare9": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-flare10": { + "portal-flare1": { "width": 111, "height": 60 }, + "portal-flare2": { "width": 114, "height": 61 }, + "portal-flare3": { "width": 115, "height": 59 } + }, + "portal-shade": { + "portal-shade": { "width": 266, "height": 266 } + }, + "portal-streaks1": { + "portal-streaks1": { "scaleX": 0.9774, "scaleY": 0.9774, "width": 252, "height": 256 } + }, + "portal-streaks2": { + "portal-streaks2": { "x": -1.64, "y": 2.79, "width": 250, "height": 249 } + }, + "rear-bracer": { + "rear-bracer": { "x": 11.15, "y": -2.2, "rotation": 66.17, "width": 56, "height": 72 } + }, + "rear-foot": { + "rear-foot": { + "type": "mesh", + "uvs": [ 0.48368, 0.1387, 0.51991, 0.21424, 0.551, 0.27907, 0.58838, 0.29816, 0.63489, 0.32191, 0.77342, 0.39267, 1, 0.73347, 1, 1, 0.54831, 0.99883, 0.31161, 1, 0, 1, 0, 0.41397, 0.13631, 0, 0.41717, 0 ], + "triangles": [ 8, 3, 4, 8, 4, 5, 8, 5, 6, 8, 6, 7, 11, 1, 10, 3, 9, 2, 2, 10, 1, 12, 13, 0, 0, 11, 12, 1, 11, 0, 2, 9, 10, 3, 8, 9 ], + "vertices": [ 2, 8, 10.45, 29.41, 0.90802, 9, -6.74, 49.62, 0.09198, 2, 8, 16.56, 29.27, 0.84259, 9, -2.65, 45.09, 0.15741, 2, 8, 21.8, 29.15, 0.69807, 9, 0.85, 41.2, 0.30193, 2, 8, 25.53, 31.43, 0.52955, 9, 5.08, 40.05, 0.47045, 2, 8, 30.18, 34.27, 0.39303, 9, 10.33, 38.62, 0.60697, 2, 8, 44.02, 42.73, 0.27525, 9, 25.98, 34.36, 0.72475, 2, 8, 76.47, 47.28, 0.21597, 9, 51.56, 13.9, 0.78403, 2, 8, 88.09, 36.29, 0.28719, 9, 51.55, -2.09, 0.71281, 2, 8, 52.94, -0.73, 0.47576, 9, 0.52, -1.98, 0.52424, 2, 8, 34.63, -20.23, 0.68757, 9, -26.23, -2.03, 0.31243, 2, 8, 10.44, -45.81, 0.84141, 9, -61.43, -2, 0.15859, 2, 8, -15.11, -21.64, 0.93283, 9, -61.4, 33.15, 0.06717, 1, 8, -22.57, 6.61, 1, 1, 8, -0.76, 29.67, 1 ], + "hull": 14, + "edges": [ 14, 12, 10, 12, 14, 16, 16, 18, 18, 20, 4, 18, 20, 22, 24, 26, 22, 24, 4, 2, 2, 20, 4, 6, 6, 16, 6, 8, 8, 10, 2, 0, 0, 26 ], + "width": 113, + "height": 60 + } + }, + "rear-shin": { + "rear-shin": { "x": 58.29, "y": -2.75, "rotation": 92.37, "width": 75, "height": 178 } + }, + "rear-thigh": { + "rear-thigh": { "x": 33.11, "y": -4.11, "rotation": 72.54, "width": 55, "height": 94 } + }, + "rear-upper-arm": { + "rear-upper-arm": { "x": 21.13, "y": 4.09, "rotation": 89.33, "width": 40, "height": 87 } + }, + "side-glow1": { + "hoverglow-small": { "x": 2.09, "scaleX": 0.2353, "scaleY": 0.4132, "width": 274, "height": 75 } + }, + "side-glow2": { + "hoverglow-small": { "x": 2.09, "scaleX": 0.2353, "scaleY": 0.4132, "width": 274, "height": 75 } + }, + "side-glow3": { + "hoverglow-small": { "x": 2.09, "scaleX": 0.3586, "scaleY": 0.6297, "width": 274, "height": 75 } + }, + "torso": { + "torso": { + "type": "mesh", + "uvs": [ 0.6251, 0.12672, 1, 0.26361, 1, 0.28871, 1, 0.66021, 1, 0.68245, 0.92324, 0.69259, 0.95116, 0.84965, 0.77124, 1, 0.49655, 1, 0.27181, 1, 0.13842, 0.77196, 0.09886, 0.6817, 0.05635, 0.58471, 0, 0.45614, 0, 0.33778, 0, 0.19436, 0.14463, 0, 0.27802, 0, 0.72525, 0.27835, 0.76091, 0.46216, 0.84888, 0.67963, 0.68257, 0.63249, 0.53986, 0.3847, 0.25443, 0.3217, 0.30063, 0.55174, 0.39553, 0.79507, 0.26389, 0.17007, 0.5241, 0.18674, 0.71492, 0.76655, 0.82151, 0.72956, 0.27626, 0.4304, 0.62327, 0.52952, 0.3455, 0.66679, 0.53243, 0.2914 ], + "triangles": [ 18, 1, 2, 19, 2, 3, 18, 0, 1, 23, 15, 26, 27, 26, 16, 14, 15, 23, 15, 16, 26, 17, 27, 16, 13, 14, 23, 0, 27, 17, 13, 23, 30, 11, 12, 24, 21, 31, 19, 12, 13, 30, 24, 22, 31, 31, 22, 19, 12, 30, 24, 32, 24, 31, 24, 30, 22, 3, 20, 19, 32, 31, 21, 11, 24, 32, 4, 5, 3, 8, 28, 7, 7, 29, 6, 7, 28, 29, 9, 25, 8, 8, 25, 28, 9, 10, 25, 29, 5, 6, 10, 32, 25, 25, 21, 28, 25, 32, 21, 10, 11, 32, 28, 21, 29, 29, 20, 5, 29, 21, 20, 5, 20, 3, 20, 21, 19, 33, 26, 27, 22, 18, 19, 19, 18, 2, 33, 27, 18, 30, 23, 22, 22, 33, 18, 23, 33, 22, 33, 23, 26, 27, 0, 18 ], + "vertices": [ 2, 29, 44.59, -10.39, 0.88, 40, -17.65, 33.99, 0.12, 3, 28, 59.65, -45.08, 0.12189, 29, 17.13, -45.08, 0.26811, 40, 22.68, 15.82, 0.61, 3, 28, 55.15, -44.72, 0.1345, 29, 12.63, -44.72, 0.2555, 40, 23.43, 11.37, 0.61, 3, 27, 31.01, -39.45, 0.51133, 28, -11.51, -39.45, 0.30867, 40, 34.58, -54.57, 0.18, 3, 27, 27.01, -39.14, 0.53492, 28, -15.5, -39.14, 0.28508, 40, 35.25, -58.52, 0.18, 2, 27, 25.79, -31.5, 0.75532, 28, -16.73, -31.5, 0.24468, 1, 27, -2.61, -32, 1, 1, 27, -28.2, -12.29, 1, 1, 27, -26.08, 14.55, 1, 1, 27, -24.35, 36.5, 1, 2, 27, 17.6, 46.3, 0.8332, 28, -24.92, 46.3, 0.1668, 2, 27, 34.1, 48.89, 0.59943, 28, -8.42, 48.89, 0.40058, 3, 27, 51.83, 51.67, 0.29262, 28, 9.32, 51.67, 0.63181, 29, -33.2, 51.67, 0.07557, 3, 27, 75.34, 55.35, 0.06656, 28, 32.82, 55.35, 0.62298, 29, -9.7, 55.35, 0.31046, 2, 28, 54.06, 53.67, 0.37296, 29, 11.54, 53.67, 0.62704, 2, 28, 79.79, 51.64, 0.10373, 29, 37.27, 51.64, 0.89627, 1, 29, 71.04, 34.76, 1, 1, 29, 70.01, 21.72, 1, 1, 30, 36.74, 7.06, 1, 3, 30, 45.7, -24.98, 0.67, 28, 25.87, -18.9, 0.3012, 29, -16.65, -18.9, 0.0288, 2, 27, 28.69, -24.42, 0.77602, 28, -13.83, -24.42, 0.22398, 3, 30, 43.24, -56.49, 0.064, 27, 38.43, -8.84, 0.67897, 28, -4.09, -8.84, 0.25703, 3, 30, 22.02, -14.85, 0.29, 28, 41.48, 1.59, 0.53368, 29, -1.04, 1.59, 0.17632, 3, 30, -7.45, -8.33, 0.76, 28, 54.98, 28.59, 0.06693, 29, 12.46, 28.59, 0.17307, 3, 30, 3.91, -48.4, 0.25, 27, 55.87, 27.33, 0.15843, 28, 13.35, 27.33, 0.59157, 1, 27, 11.47, 21.51, 1, 2, 30, -11.09, 18.74, 0.416, 29, 39.6, 25.51, 0.584, 2, 30, 14.56, 20.03, 0.53, 29, 34.6, 0.33, 0.47, 1, 27, 14.12, -10.1, 1, 2, 27, 19.94, -21.03, 0.92029, 28, -22.58, -21.03, 0.07971, 3, 30, -2.08, -27.26, 0.29, 28, 35.31, 27.99, 0.49582, 29, -7.21, 27.99, 0.21418, 2, 30, 34.42, -39.19, 0.25, 28, 14.84, -4.5, 0.75, 2, 27, 34.87, 24.58, 0.67349, 28, -7.64, 24.58, 0.32651, 2, 30, 18.5, 1.59, 0.76, 29, 15.76, 1, 0.24 ], + "hull": 18, + "edges": [ 14, 12, 12, 10, 10, 8, 18, 20, 32, 34, 30, 32, 2, 4, 36, 4, 36, 38, 38, 40, 4, 6, 6, 8, 40, 6, 40, 42, 14, 16, 16, 18, 50, 16, 46, 52, 54, 36, 2, 0, 0, 34, 54, 0, 54, 32, 20, 50, 14, 56, 56, 42, 50, 56, 56, 58, 58, 40, 58, 10, 46, 60, 60, 48, 26, 60, 60, 44, 24, 26, 24, 48, 42, 62, 62, 44, 48, 62, 48, 64, 64, 50, 42, 64, 20, 22, 22, 24, 64, 22, 26, 28, 28, 30, 28, 46, 44, 66, 66, 54, 46, 66, 66, 36, 62, 38 ], + "width": 98, + "height": 180 + } + } + } + } + ], + "events": { + "footstep": {} + }, + "animations": { + "aim": { + "slots": { + "crosshair": { + "attachment": [ + { "name": "crosshair" } + ] + } + }, + "bones": { + "front-fist": { + "rotate": [ + { "value": 36.08 } + ] + }, + "rear-bracer": { + "rotate": [ + { "value": -26.55 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { "value": 62.31 } + ] + }, + "front-bracer": { + "rotate": [ + { "value": 9.11 } + ] + }, + "gun": { + "rotate": [ + { "value": -0.31 } + ] + } + }, + "ik": { + "aim-ik": [ + { "mix": 0.995 } + ] + }, + "transform": { + "aim-front-arm-transform": [ + { "mixRotate": 0.784, "mixX": 0, "mixScaleX": 0, "mixShearY": 0 } + ], + "aim-head-transform": [ + { "mixRotate": 0.659, "mixX": 0, "mixScaleX": 0, "mixShearY": 0 } + ], + "aim-torso-transform": [ + { "mixRotate": 0.423, "mixX": 0, "mixScaleX": 0, "mixShearY": 0 } + ] + } + }, + "death": { + "slots": { + "eye": { + "attachment": [ + { "name": "eye-surprised" }, + { "time": 0.5333, "name": "eye-indifferent" }, + { "time": 2.2, "name": "eye-surprised" }, + { "time": 4.6, "name": "eye-indifferent" } + ] + }, + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + }, + "mouth": { + "attachment": [ + { "name": "mouth-oooo" }, + { "time": 0.5333, "name": "mouth-grind" }, + { "time": 1.4, "name": "mouth-oooo" }, + { "time": 2.1667, "name": "mouth-grind" }, + { "time": 4.5333, "name": "mouth-oooo" } + ] + } + }, + "bones": { + "head": { + "rotate": [ + { + "value": -2.83, + "curve": [ 0.015, -2.83, 0.036, 12.72 ] + }, + { + "time": 0.0667, + "value": 12.19, + "curve": [ 0.096, 11.68, 0.119, -1.14 ] + }, + { + "time": 0.1333, + "value": -6.86, + "curve": [ 0.149, -13.27, 0.21, -37.28 ] + }, + { + "time": 0.3, + "value": -36.86, + "curve": [ 0.354, -36.61, 0.412, -32.35 ] + }, + { + "time": 0.4667, + "value": -23.49, + "curve": [ 0.49, -19.87, 0.512, -3.29 ] + }, + { + "time": 0.5333, + "value": -3.24, + "curve": [ 0.56, -3.39, 0.614, -67.25 ] + }, + { + "time": 0.6333, + "value": -74.4, + "curve": [ 0.652, -81.58, 0.702, -88.94 ] + }, + { + "time": 0.7333, + "value": -88.93, + "curve": [ 0.805, -88.91, 0.838, -80.87 ] + }, + { + "time": 0.8667, + "value": -81.03, + "curve": [ 0.922, -81.32, 0.976, -85.29 ] + }, + { "time": 1, "value": -85.29, "curve": "stepped" }, + { + "time": 2.2333, + "value": -85.29, + "curve": [ 2.314, -85.29, 2.382, -68.06 ] + }, + { + "time": 2.4667, + "value": -63.48, + "curve": [ 2.57, -57.87, 2.916, -55.24 ] + }, + { + "time": 3.2, + "value": -55.1, + "curve": [ 3.447, -54.98, 4.135, -56.61 ] + }, + { + "time": 4.2667, + "value": -58.23, + "curve": [ 4.672, -63.24, 4.646, -82.69 ] + }, + { "time": 4.9333, "value": -85.29 } + ], + "scale": [ + { + "time": 0.4667, + "curve": [ 0.469, 1.005, 0.492, 1.065, 0.475, 1.018, 0.492, 0.94 ] + }, + { + "time": 0.5, + "x": 1.065, + "y": 0.94, + "curve": [ 0.517, 1.065, 0.541, 0.991, 0.517, 0.94, 0.542, 1.026 ] + }, + { + "time": 0.5667, + "x": 0.99, + "y": 1.025, + "curve": [ 0.593, 0.988, 0.609, 1.002, 0.595, 1.024, 0.607, 1.001 ] + }, + { "time": 0.6333 } + ] + }, + "neck": { + "rotate": [ + { + "value": -2.83, + "curve": [ 0.114, 1.33, 0.195, 4.13 ] + }, + { + "time": 0.2667, + "value": 4.13, + "curve": [ 0.351, 4.14, 0.444, -24.5 ] + }, + { + "time": 0.5, + "value": -24.69, + "curve": [ 0.571, -23.89, 0.55, 34.22 ] + }, + { + "time": 0.6667, + "value": 35.13, + "curve": [ 0.713, 34.81, 0.756, 22.76 ] + }, + { + "time": 0.8333, + "value": 22.82, + "curve": [ 0.868, 22.84, 0.916, 47.95 ] + }, + { "time": 0.9667, "value": 47.95, "curve": "stepped" }, + { + "time": 2.2333, + "value": 47.95, + "curve": [ 2.3, 47.95, 2.617, 18.72 ] + }, + { + "time": 2.6667, + "value": 18.51, + "curve": [ 3.172, 16.58, 4.06, 16.79 ] + }, + { + "time": 4.5333, + "value": 18.51, + "curve": [ 4.707, 19.13, 4.776, 41.11 ] + }, + { "time": 4.8, "value": 47.95 } + ] + }, + "torso": { + "rotate": [ + { + "value": -8.62, + "curve": [ 0.01, -16.71, 0.032, -33.6 ] + }, + { + "time": 0.0667, + "value": -33.37, + "curve": [ 0.182, -32.61, 0.298, 123.07 ] + }, + { + "time": 0.4667, + "value": 122.77, + "curve": [ 0.511, 122.69, 0.52, 100.2 ] + }, + { + "time": 0.5667, + "value": 88.96, + "curve": [ 0.588, 83.89, 0.667, 75.34 ] + }, + { + "time": 0.7, + "value": 75.34, + "curve": [ 0.767, 75.34, 0.9, 76.03 ] + }, + { "time": 0.9667, "value": 76.03 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -38.86, + "curve": [ 0.022, -40.38, 0.096, -41.92 ] + }, + { + "time": 0.1333, + "value": -41.92, + "curve": [ 0.176, -41.92, 0.216, -16.92 ] + }, + { + "time": 0.2333, + "value": -4.35, + "curve": [ 0.258, 13.69, 0.308, 60.35 ] + }, + { + "time": 0.4, + "value": 60.17, + "curve": [ 0.496, 59.98, 0.539, 33.63 ] + }, + { + "time": 0.5667, + "value": 23.06, + "curve": [ 0.595, 32.71, 0.675, 53.71 ] + }, + { + "time": 0.7333, + "value": 53.61, + "curve": [ 0.797, 53.51, 0.926, 30.98 ] + }, + { "time": 0.9333, "value": 19.57, "curve": "stepped" }, + { + "time": 1.9667, + "value": 19.57, + "curve": [ 2.245, 19.57, 2.702, 77.03 ] + }, + { + "time": 3.0667, + "value": 77.06, + "curve": [ 3.209, 77.33, 3.291, 67.99 ] + }, + { + "time": 3.4333, + "value": 67.96, + "curve": [ 3.608, 68.34, 3.729, 73.88 ] + }, + { + "time": 3.8333, + "value": 73.42, + "curve": [ 4.152, 73.91, 4.46, 71.98 ] + }, + { + "time": 4.6333, + "value": 64.77, + "curve": [ 4.688, 62.5, 4.847, 26.42 ] + }, + { "time": 4.8667, "value": 10.94 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": -44.7, + "curve": [ 0.033, -44.7, 0.12, 54.89 ] + }, + { + "time": 0.1333, + "value": 64.62, + "curve": [ 0.154, 79.18, 0.214, 79.42 ] + }, + { + "time": 0.2667, + "value": 63.4, + "curve": [ 0.293, 55.19, 0.332, 30.13 ] + }, + { + "time": 0.3667, + "value": 30.13, + "curve": [ 0.4, 30.13, 0.441, 39.87 ] + }, + { + "time": 0.4667, + "value": 55.13, + "curve": [ 0.488, 68.18, 0.52, 100.72 ] + }, + { + "time": 0.5333, + "value": 111.96, + "curve": [ 0.551, 126.88, 0.627, 185.97 ] + }, + { + "time": 0.6667, + "value": 185.97, + "curve": [ 0.692, 185.97, 0.736, 162.43 ] + }, + { + "time": 0.8, + "value": 158.01, + "curve": [ 0.9, 151.12, 1.017, 144.01 ] + }, + { "time": 1.1, "value": 144.01, "curve": "stepped" }, + { + "time": 2.3667, + "value": 144.01, + "curve": [ 2.492, 144.01, 2.742, 138.63 ] + }, + { + "time": 2.8667, + "value": 138.63, + "curve": [ 3.067, 138.63, 3.467, 138.63 ] + }, + { + "time": 3.6667, + "value": 138.63, + "curve": [ 3.883, 138.63, 4.317, 135.18 ] + }, + { + "time": 4.5333, + "value": 135.18, + "curve": [ 4.575, 135.18, 4.692, 131.59 ] + }, + { + "time": 4.7333, + "value": 131.59, + "curve": [ 4.758, 131.59, 4.517, 144.01 ] + }, + { "time": 4.8333, "value": 144.01 } + ], + "translate": [ + { + "time": 0.4667, + "curve": [ 0.517, 0, 0.617, -34.96, 0.517, 0, 0.617, -16.59 ] + }, + { "time": 0.6667, "x": -35.02, "y": -16.62 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 21.88, + "curve": [ 0.033, 21.88, 0.099, 20.44 ] + }, + { + "time": 0.1333, + "value": 9.43, + "curve": [ 0.164, -0.29, 0.162, -38.26 ] + }, + { + "time": 0.2, + "value": -38.05, + "curve": [ 0.24, -37.96, 0.228, -17.82 ] + }, + { + "time": 0.3333, + "value": -9.73, + "curve": [ 0.372, -6.76, 0.431, -0.74 ] + }, + { + "time": 0.4667, + "value": 6.47, + "curve": [ 0.489, 11.05, 0.503, 19.09 ] + }, + { + "time": 0.5333, + "value": 19.09, + "curve": [ 0.571, 19.09, 0.554, -42.67 ] + }, + { + "time": 0.6, + "value": -42.67, + "curve": [ 0.653, -42.67, 0.691, -13.8 ] + }, + { + "time": 0.7, + "value": -3.54, + "curve": [ 0.707, 3.8, 0.719, 24.94 ] + }, + { + "time": 0.8, + "value": 25.31, + "curve": [ 0.902, 24.75, 0.992, -0.34 ] + }, + { "time": 1, "value": -32.16, "curve": "stepped" }, + { + "time": 2.2333, + "value": -32.16, + "curve": [ 2.6, -32.16, 2.638, -5.3 ] + }, + { + "time": 2.7, + "value": -1.96, + "curve": [ 2.707, -1.56, 2.775, 1.67 ] + }, + { + "time": 2.8, + "value": 1.67, + "curve": [ 2.825, 1.67, 2.875, -0.39 ] + }, + { + "time": 2.9, + "value": -0.39, + "curve": [ 2.925, -0.39, 2.975, 0.26 ] + }, + { + "time": 3, + "value": 0.26, + "curve": [ 3.025, 0.26, 3.075, -1.81 ] + }, + { + "time": 3.1, + "value": -1.81, + "curve": [ 3.125, -1.81, 3.175, -0.52 ] + }, + { + "time": 3.2, + "value": -0.52, + "curve": [ 3.225, -0.52, 3.275, -2.41 ] + }, + { + "time": 3.3, + "value": -2.41, + "curve": [ 3.333, -2.41, 3.4, -0.38 ] + }, + { + "time": 3.4333, + "value": -0.38, + "curve": [ 3.467, -0.38, 3.533, -2.25 ] + }, + { + "time": 3.5667, + "value": -2.25, + "curve": [ 3.592, -2.25, 3.642, -0.33 ] + }, + { + "time": 3.6667, + "value": -0.33, + "curve": [ 3.7, -0.33, 3.767, -1.34 ] + }, + { + "time": 3.8, + "value": -1.34, + "curve": [ 3.825, -1.34, 3.862, -0.77 ] + }, + { + "time": 3.9, + "value": -0.77, + "curve": [ 3.942, -0.77, 3.991, -1.48 ] + }, + { + "time": 4, + "value": -1.87, + "curve": [ 4.167, -1.87, 4.5, -1.96 ] + }, + { + "time": 4.6667, + "value": -1.96, + "curve": [ 4.709, 18.05, 4.767, 34.55 ] + }, + { + "time": 4.8, + "value": 34.55, + "curve": [ 4.84, 34.24, 4.902, 12.03 ] + }, + { "time": 4.9333, "value": -18.75 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": -2.33, + "curve": [ 0.019, 4.43, 0.069, 10.82 ] + }, + { + "time": 0.1, + "value": 10.6, + "curve": [ 0.148, 10.6, 0.123, -15.24 ] + }, + { + "time": 0.2, + "value": -15.35, + "curve": [ 0.266, -15.44, 0.316, -6.48 ] + }, + { + "time": 0.3333, + "value": -3.9, + "curve": [ 0.362, 0.43, 0.479, 22.36 ] + }, + { + "time": 0.5667, + "value": 22.01, + "curve": [ 0.61, 21.84, 0.627, 12.85 ] + }, + { + "time": 0.6333, + "value": 9.05, + "curve": [ 0.643, 2.77, 0.622, -39.43 ] + }, + { + "time": 0.7, + "value": -39.5, + "curve": [ 0.773, -39.57, 0.814, 14.77 ] + }, + { + "time": 0.8667, + "value": 14.81, + "curve": [ 0.965, 14.88, 1.1, 5.64 ] + }, + { "time": 1.1, "value": -6.08, "curve": "stepped" }, + { + "time": 2.2333, + "value": -6.08, + "curve": [ 2.307, -6.08, 2.427, -25.89 ] + }, + { + "time": 2.5333, + "value": -22.42, + "curve": [ 2.598, -20.38, 2.657, 5.73 ] + }, + { + "time": 2.7, + "value": 5.73, + "curve": [ 2.77, 5.73, 2.851, -5.38 ] + }, + { + "time": 2.9333, + "value": -5.38, + "curve": [ 3.008, -5.38, 3.087, -4.54 ] + }, + { + "time": 3.1667, + "value": -4.17, + "curve": [ 3.223, -3.91, 4.486, 5.73 ] + }, + { + "time": 4.6667, + "value": 5.73, + "curve": [ 4.733, 5.73, 4.886, -2.47 ] + }, + { "time": 4.9333, "value": -6.52 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 10.36, + "curve": [ 0.033, 10.36, 0.1, -32.89 ] + }, + { + "time": 0.1333, + "value": -32.89, + "curve": [ 0.183, -32.89, 0.283, -4.45 ] + }, + { + "time": 0.3333, + "value": -4.45, + "curve": [ 0.367, -4.45, 0.438, -6.86 ] + }, + { + "time": 0.4667, + "value": -8.99, + "curve": [ 0.529, -13.62, 0.605, -20.58 ] + }, + { + "time": 0.6333, + "value": -23.2, + "curve": [ 0.708, -30.18, 0.758, -35.56 ] + }, + { + "time": 0.8, + "value": -35.56, + "curve": [ 0.875, -35.56, 1.025, -23.2 ] + }, + { "time": 1.1, "value": -23.2 } + ] + }, + "gun": { + "rotate": [ + { + "value": -2.79, + "curve": [ 0.033, -2.79, 0.12, -7.22 ] + }, + { + "time": 0.1333, + "value": -8.52, + "curve": [ 0.168, -11.87, 0.29, -23.71 ] + }, + { + "time": 0.3333, + "value": -26.24, + "curve": [ 0.369, -28.31, 0.436, -29.75 ] + }, + { + "time": 0.5, + "value": -29.66, + "curve": [ 0.552, -29.58, 0.611, -25.47 ] + }, + { + "time": 0.6333, + "value": -22.68, + "curve": [ 0.656, -19.76, 0.68, -10.02 ] + }, + { + "time": 0.7, + "value": -6.49, + "curve": [ 0.722, -2.6, 0.75, -1.22 ] + }, + { + "time": 0.7667, + "value": -1.35, + "curve": [ 0.792, -1.55, 0.842, -19.74 ] + }, + { "time": 0.8667, "value": -19.8 } + ] + }, + "hip": { + "translate": [ + { + "curve": [ 0.098, -42.62, 0.166, -79.85, 0.029, 84.97, 0.109, 155.93 ] + }, + { + "time": 0.2667, + "x": -133.79, + "y": 152.44, + "curve": [ 0.361, -184.63, 0.392, -203.69, 0.42, 149.12, 0.467, -15.7 ] + }, + { + "time": 0.4667, + "x": -230.02, + "y": -113.87, + "curve": [ 0.523, -249.86, 0.565, -261.7, 0.473, -133.1, 0.583, -203.43 ] + }, + { + "time": 0.6, + "x": -268.57, + "y": -203.43, + "curve": [ 0.663, -280.98, 0.816, -290.05, 0.708, -203.43, 0.892, -203.5 ] + }, + { "time": 1, "x": -290.42, "y": -203.5 } + ] + }, + "front-thigh": { + "rotate": [ + { + "curve": [ 0.06, 1.02, 0.151, 45.23 ] + }, + { + "time": 0.1667, + "value": 54.01, + "curve": [ 0.19, 66.85, 0.358, 169.85 ] + }, + { + "time": 0.5, + "value": 169.51, + "curve": [ 0.628, 169.85, 0.692, 108.85 ] + }, + { + "time": 0.7, + "value": 97.74, + "curve": [ 0.723, 102.6, 0.805, 111.6 ] + }, + { + "time": 0.8667, + "value": 111.69, + "curve": [ 0.899, 111.83, 1.015, 109.15 ] + }, + { "time": 1.0667, "value": 95.8 } + ] + }, + "front-shin": { + "rotate": [ + { + "curve": [ 0.086, -0.02, 0.191, -24.25 ] + }, + { + "time": 0.2, + "value": -26.5, + "curve": [ 0.214, -29.92, 0.249, -40.51 ] + }, + { + "time": 0.3333, + "value": -40.57, + "curve": [ 0.431, -40.7, 0.459, -11.34 ] + }, + { + "time": 0.4667, + "value": -8.71, + "curve": [ 0.477, -5.16, 0.524, 17.13 ] + }, + { + "time": 0.6, + "value": 16.98, + "curve": [ 0.632, 17.09, 0.625, 2.76 ] + }, + { + "time": 0.6333, + "value": 2.76, + "curve": [ 0.648, 2.76, 0.653, 2.75 ] + }, + { + "time": 0.6667, + "value": 2.59, + "curve": [ 0.678, 2.39, 0.733, 2.53 ] + }, + { + "time": 0.7333, + "value": -9.43, + "curve": [ 0.745, -2.48, 0.782, 3.12 ] + }, + { + "time": 0.8, + "value": 4.28, + "curve": [ 0.832, 6.32, 0.895, 8.46 ] + }, + { + "time": 0.9333, + "value": 8.49, + "curve": [ 0.986, 8.53, 1.051, 6.38 ] + }, + { + "time": 1.0667, + "value": 2.28, + "curve": [ 1.078, 4.17, 1.103, 5.86 ] + }, + { + "time": 1.1333, + "value": 5.88, + "curve": [ 1.191, 5.93, 1.209, 4.56 ] + }, + { "time": 1.2333, "value": 2.52 } + ] + }, + "rear-thigh": { + "rotate": [ + { + "curve": [ 0.033, 0, 0.12, 50.26 ] + }, + { + "time": 0.1333, + "value": 57.3, + "curve": [ 0.164, 73.34, 0.274, 147.18 ] + }, + { + "time": 0.3333, + "value": 147.1, + "curve": [ 0.475, 146.45, 0.583, 95.72 ] + }, + { + "time": 0.6, + "value": 79.66, + "curve": [ 0.62, 94.74, 0.732, 103.15 ] + }, + { + "time": 0.7667, + "value": 103.02, + "curve": [ 0.812, 102.85, 0.897, 95.75 ] + }, + { "time": 0.9333, "value": 83.01 } + ] + }, + "rear-shin": { + "rotate": [ + { + "curve": [ 0.021, -16.65, 0.091, -54.82 ] + }, + { + "time": 0.1667, + "value": -55.29, + "curve": [ 0.187, -55.42, 0.213, -52.52 ] + }, + { + "time": 0.2333, + "value": -45.98, + "curve": [ 0.242, -43.1, 0.311, -12.73 ] + }, + { + "time": 0.3333, + "value": -6.32, + "curve": [ 0.356, 0.13, 0.467, 24.5 ] + }, + { + "time": 0.5, + "value": 24.5, + "curve": [ 0.543, 24.5, 0.56, 3.78 ] + }, + { + "time": 0.5667, + "value": -3.53, + "curve": [ 0.585, 3.86, 0.659, 16.63 ] + }, + { + "time": 0.7, + "value": 16.56, + "curve": [ 0.782, 16.43, 0.896, 8.44 ] + }, + { + "time": 0.9333, + "value": 4.04, + "curve": [ 0.956, 6.84, 1.008, 8.41 ] + }, + { + "time": 1.0333, + "value": 8.41, + "curve": [ 1.067, 8.41, 1.122, 8.14 ] + }, + { "time": 1.1667, "value": 5.8 } + ] + }, + "rear-foot": { + "rotate": [ + { + "value": -0.28, + "curve": [ 0.033, -0.28, 0.256, -66.71 ] + }, + { + "time": 0.3667, + "value": -66.84, + "curve": [ 0.418, -66.91, 0.499, -21.79 ] + }, + { + "time": 0.6, + "value": -21.52, + "curve": [ 0.652, -21.38, 0.665, -53.96 ] + }, + { + "time": 0.7, + "value": -54.26, + "curve": [ 0.757, -53.96, 0.843, -2.07 ] + }, + { + "time": 0.9333, + "value": -1.47, + "curve": [ 0.968, -2.07, 0.975, -19.96 ] + }, + { + "time": 1, + "value": -19.96, + "curve": [ 1.025, -19.96, 1.075, -12.42 ] + }, + { + "time": 1.1, + "value": -12.42, + "curve": [ 1.133, -12.42, 1.2, -18.34 ] + }, + { "time": 1.2333, "value": -18.34 } + ] + }, + "front-foot": { + "rotate": [ + { + "curve": [ 0.008, -11.33, 0.108, -57.71 ] + }, + { + "time": 0.1333, + "value": -57.71, + "curve": [ 0.175, -57.71, 0.229, 19.73 ] + }, + { + "time": 0.3, + "value": 19.34, + "curve": [ 0.354, 19.34, 0.4, -57.76 ] + }, + { + "time": 0.4333, + "value": -57.76, + "curve": [ 0.458, -57.76, 0.511, -3.56 ] + }, + { + "time": 0.5333, + "value": 3.7, + "curve": [ 0.563, 13.29, 0.633, 15.79 ] + }, + { + "time": 0.6667, + "value": 15.79, + "curve": [ 0.7, 15.79, 0.767, -48.75 ] + }, + { + "time": 0.8, + "value": -48.75, + "curve": [ 0.842, -48.75, 0.925, 4.7 ] + }, + { + "time": 0.9667, + "value": 4.7, + "curve": [ 1, 4.7, 1.067, -22.9 ] + }, + { + "time": 1.1, + "value": -22.9, + "curve": [ 1.142, -22.9, 1.225, -13.28 ] + }, + { "time": 1.2667, "value": -13.28 } + ] + }, + "rear-foot-target": { + "rotate": [ + { "value": -0.28 } + ] + }, + "front-foot-tip": { + "rotate": [ + { + "value": -0.28, + "curve": [ 0.008, -0.28, 0.003, -66.62 ] + }, + { + "time": 0.0667, + "value": -65.75, + "curve": [ 0.166, -64.42, 0.234, 14.35 ] + }, + { + "time": 0.2667, + "value": 38.25, + "curve": [ 0.294, 57.91, 0.392, 89.79 ] + }, + { + "time": 0.4667, + "value": 90.73, + "curve": [ 0.483, 90.73, 0.55, 177.66 ] + }, + { + "time": 0.5667, + "value": 177.66, + "curve": [ 0.733, 176.24, 0.75, 11.35 ] + }, + { + "time": 0.8, + "value": 11.35, + "curve": [ 0.886, 12.29, 0.911, 47.88 ] + }, + { + "time": 0.9333, + "value": 56.77, + "curve": [ 0.967, 70.59, 1.05, 86.46 ] + }, + { + "time": 1.1, + "value": 86.46, + "curve": [ 1.187, 86.46, 1.214, 66.44 ] + }, + { "time": 1.3333, "value": 64.55 } + ] + }, + "back-foot-tip": { + "rotate": [ + { + "value": -0.28, + "curve": [ 0, -7.97, 0.027, -18.69 ] + }, + { + "time": 0.0667, + "value": -19, + "curve": [ 0.166, -19.3, 0.208, 15.58 ] + }, + { + "time": 0.2667, + "value": 45.95, + "curve": [ 0.306, 66.24, 0.378, 99.08 ] + }, + { + "time": 0.4333, + "value": 99.08, + "curve": [ 0.497, 98.62, 0.488, -1.2 ] + }, + { + "time": 0.5667, + "value": -1.32, + "curve": [ 0.637, -0.84, 0.687, 94.41 ] + }, + { + "time": 0.7333, + "value": 94.33, + "curve": [ 0.832, 94.16, 0.895, 29.6 ] + }, + { + "time": 0.9667, + "value": 28.67, + "curve": [ 1.026, 28.67, 1.045, 53.14 ] + }, + { "time": 1.1, "value": 53.38 } + ] + }, + "hair4": { + "rotate": [ + { + "curve": [ 0.011, 4.5, 0.05, 11.42 ] + }, + { + "time": 0.0667, + "value": 11.42, + "curve": [ 0.1, 11.42, 0.136, -5.92 ] + }, + { + "time": 0.1667, + "value": -10.54, + "curve": [ 0.206, -16.51, 0.327, -22 ] + }, + { + "time": 0.3667, + "value": -24.47, + "curve": [ 0.413, -27.37, 0.467, -43.99 ] + }, + { + "time": 0.5, + "value": -43.99, + "curve": [ 0.533, -43.99, 0.552, 12.12 ] + }, + { + "time": 0.6333, + "value": 11.85, + "curve": [ 0.714, 11.59, 0.758, -34.13 ] + }, + { + "time": 0.8, + "value": -34.13, + "curve": [ 0.858, -34.13, 1.015, -12.47 ] + }, + { + "time": 1.0667, + "value": -8.85, + "curve": [ 1.121, -5.07, 1.219, -0.02 ] + }, + { + "time": 1.3333, + "value": 1.29, + "curve": [ 1.509, 3.3, 1.763, 2.75 ] + }, + { + "time": 1.8667, + "value": 2.78, + "curve": [ 1.974, 2.81, 2.108, 2.81 ] + }, + { + "time": 2.2, + "value": 2.78, + "curve": [ 2.315, 2.74, 2.374, 1.22 ] + }, + { + "time": 2.4667, + "value": 1.18, + "curve": [ 2.525, 1.18, 2.608, 10.79 ] + }, + { + "time": 2.6667, + "value": 10.79, + "curve": [ 2.725, 10.79, 2.893, 4.72 ] + }, + { + "time": 3.0333, + "value": 4.72, + "curve": [ 3.117, 4.72, 3.283, 7.93 ] + }, + { + "time": 3.3667, + "value": 7.93, + "curve": [ 3.492, 7.93, 3.775, 6.93 ] + }, + { + "time": 3.9, + "value": 6.93, + "curve": [ 3.981, 6.93, 4.094, 6.9 ] + }, + { + "time": 4.2, + "value": 8.44, + "curve": [ 4.267, 9.42, 4.401, 16.61 ] + }, + { + "time": 4.5, + "value": 16.33, + "curve": [ 4.582, 16.12, 4.709, 9.94 ] + }, + { + "time": 4.7333, + "value": 6.51, + "curve": [ 4.747, 4.57, 4.779, -1.76 ] + }, + { + "time": 4.8, + "value": -1.75, + "curve": [ 4.823, -1.73, 4.82, 4.47 ] + }, + { + "time": 4.8667, + "value": 6.04, + "curve": [ 4.899, 7.14, 4.913, 6.93 ] + }, + { "time": 4.9333, "value": 6.93 } + ] + }, + "hair2": { + "rotate": [ + { + "value": 10.61, + "curve": [ 0.075, 10.61, 0.05, 12.67 ] + }, + { + "time": 0.0667, + "value": 12.67, + "curve": [ 0.123, 12.67, 0.194, -16.51 ] + }, + { + "time": 0.2, + "value": -19.87, + "curve": [ 0.207, -23.48, 0.236, -31.68 ] + }, + { + "time": 0.3, + "value": -31.8, + "curve": [ 0.356, -31.9, 0.437, -25.61 ] + }, + { + "time": 0.4667, + "value": -19.29, + "curve": [ 0.485, -15.33, 0.529, 6.48 ] + }, + { + "time": 0.5667, + "value": 6.67, + "curve": [ 0.628, 6.97, 0.65, -46.39 ] + }, + { + "time": 0.7333, + "value": -46.3, + "curve": [ 0.843, -46.17, 0.941, -33.37 ] + }, + { + "time": 0.9667, + "value": -23.17, + "curve": [ 0.972, -20.98, 1.047, 15.21 ] + }, + { + "time": 1.1, + "value": 15.21, + "curve": [ 1.142, 15.21, 1.183, 10.73 ] + }, + { + "time": 1.2667, + "value": 10.61, + "curve": [ 1.45, 10.34, 1.817, 10.61 ] + }, + { + "time": 2, + "value": 10.61, + "curve": [ 2.075, 10.61, 2.225, 16.9 ] + }, + { + "time": 2.3, + "value": 16.9, + "curve": [ 2.327, 16.9, 2.347, 6.81 ] + }, + { + "time": 2.4, + "value": 6.83, + "curve": [ 2.492, 6.87, 2.602, 17.39 ] + }, + { + "time": 2.6667, + "value": 17.39, + "curve": [ 2.742, 17.39, 2.892, 10.67 ] + }, + { + "time": 2.9667, + "value": 10.64, + "curve": [ 3.187, 10.57, 3.344, 10.73 ] + }, + { + "time": 3.6, + "value": 11.4, + "curve": [ 3.766, 11.83, 3.874, 14.87 ] + }, + { + "time": 3.9333, + "value": 14.83, + "curve": [ 4.022, 14.76, 4.208, 9.49 ] + }, + { + "time": 4.3, + "value": 9.54, + "curve": [ 4.391, 9.58, 4.441, 14.82 ] + }, + { + "time": 4.5333, + "value": 14.84, + "curve": [ 4.642, 14.88, 4.692, 1.17 ] + }, + { + "time": 4.7667, + "value": 1.24, + "curve": [ 4.823, 1.3, 4.818, 18.35 ] + }, + { + "time": 4.8667, + "value": 18.38, + "curve": [ 4.905, 18.41, 4.901, 10.61 ] + }, + { "time": 4.9333, "value": 10.61 } + ] + }, + "torso2": { + "rotate": [ + { + "curve": [ 0.048, 0, 0.129, -12.73 ] + }, + { + "time": 0.1667, + "value": -15.95, + "curve": [ 0.221, -20.66, 0.254, -21.62 ] + }, + { + "time": 0.3, + "value": -21.59, + "curve": [ 0.458, -21.46, 0.46, -1.67 ] + }, + { + "time": 0.6333, + "value": -1.71, + "curve": [ 0.71, -1.73, 0.715, -4 ] + }, + { + "time": 0.7667, + "value": -3.97, + "curve": [ 0.866, -3.92, 0.84, 0.02 ] + }, + { "time": 1, "curve": "stepped" }, + { + "time": 2, + "curve": [ 2.275, 0, 2.867, -5.8 ] + }, + { + "time": 3.1, + "value": -6.44, + "curve": [ 3.327, -7.06, 3.71, -6.23 ] + }, + { + "time": 3.9333, + "value": -5.41, + "curve": [ 4.168, -4.53, 4.488, -2.83 ] + }, + { "time": 4.8 } + ] + }, + "torso3": { + "rotate": [ + { + "curve": [ 0.025, 0, 0.09, -3.66 ] + }, + { + "time": 0.1, + "value": -4.55, + "curve": [ 0.143, -8.4, 0.223, -17.07 ] + }, + { + "time": 0.2333, + "value": -18.31, + "curve": [ 0.282, -24.44, 0.35, -29 ] + }, + { + "time": 0.3667, + "value": -30.07, + "curve": [ 0.405, -32.58, 0.442, -33.03 ] + }, + { + "time": 0.4667, + "value": -32.99, + "curve": [ 0.491, -33.04, 0.505, -23.56 ] + }, + { + "time": 0.5333, + "value": -23.55, + "curve": [ 0.571, -23.67, 0.599, -27.21 ] + }, + { + "time": 0.6333, + "value": -27.21, + "curve": [ 0.669, -27.2, 0.742, -10.43 ] + }, + { + "time": 0.7667, + "value": -7.79, + "curve": [ 0.788, -5.53, 0.796, -4.42 ] + }, + { + "time": 0.8333, + "value": -2.9, + "curve": [ 0.875, -1.21, 0.933, 0 ] + }, + { "time": 0.9667, "curve": "stepped" }, + { + "time": 2.4333, + "curve": [ 2.517, 0, 2.683, 4.63 ] + }, + { + "time": 2.7667, + "value": 4.66, + "curve": [ 3.084, 4.76, 3.248, 4.37 ] + }, + { + "time": 3.4, + "value": 3.74, + "curve": [ 3.596, 2.92, 3.755, 2.18 ] + }, + { + "time": 3.8667, + "value": 1.72, + "curve": [ 4.136, 0.59, 4.471, 0 ] + }, + { "time": 4.8 } + ] + }, + "hair3": { + "rotate": [ + { + "curve": [ 0, 0, 0.041, 10.74 ] + }, + { + "time": 0.0667, + "value": 14.16, + "curve": [ 0.075, 15.22, 0.148, 18.04 ] + }, + { + "time": 0.2, + "value": 18.13, + "curve": [ 0.251, 18.23, 0.307, -4.75 ] + }, + { + "time": 0.3667, + "value": -5.06, + "curve": [ 0.412, -5.3, 0.47, -0.96 ] + }, + { + "time": 0.5, + "value": 2.21, + "curve": [ 0.512, 3.48, 0.595, 20.31 ] + }, + { + "time": 0.6333, + "value": 24.87, + "curve": [ 0.647, 26.53, 0.719, 29.33 ] + }, + { + "time": 0.8, + "value": 29.22, + "curve": [ 0.859, 29.14, 0.9, 28.48 ] + }, + { + "time": 0.9333, + "value": 26.11, + "curve": [ 0.981, 22.72, 0.998, 2.06 ] + }, + { "time": 1.1, "value": 2.21 } + ] + }, + "hair1": { + "rotate": [ + { + "curve": [ 0.047, -0.21, 0.048, 7.86 ] + }, + { + "time": 0.0667, + "value": 13.27, + "curve": [ 0.083, 18.05, 0.135, 24.44 ] + }, + { + "time": 0.2, + "value": 24.02, + "curve": [ 0.225, 24.02, 0.28, 6.32 ] + }, + { + "time": 0.3, + "value": 3.1, + "curve": [ 0.323, -0.58, 0.382, -7.12 ] + }, + { + "time": 0.4667, + "value": -7.45, + "curve": [ 0.512, -7.66, 0.538, 12.13 ] + }, + { + "time": 0.5667, + "value": 16.46, + "curve": [ 0.609, 22.72, 0.672, 27.4 ] + }, + { + "time": 0.7333, + "value": 27.55, + "curve": [ 0.827, 27.4, 0.933, 23.23 ] + }, + { + "time": 0.9667, + "value": 19.11, + "curve": [ 0.998, 15.27, 1.092, -2.53 ] + }, + { + "time": 1.1333, + "value": -2.53, + "curve": [ 1.158, -2.53, 1.208, 0 ] + }, + { "time": 1.2333, "curve": "stepped" }, + { + "time": 2, + "curve": [ 2.075, 0, 2.248, 0.35 ] + }, + { + "time": 2.3333, + "value": 0.78, + "curve": [ 2.585, 2.06, 2.805, 3.46 ] + }, + { + "time": 3.2, + "value": 3.5, + "curve": [ 3.593, 3.54, 3.979, 2.36 ] + }, + { + "time": 4.1667, + "value": 1.55, + "curve": [ 4.391, 0.59, 4.447, 0.04 ] + }, + { + "time": 4.6, + "value": 0.04, + "curve": [ 4.642, 0.04, 4.742, 0 ] + }, + { "time": 4.9333 } + ] + }, + "head-control": { + "translate": [ + { + "curve": [ 0.025, 0, 0.09, 1.43, 0.025, 0, 0.075, -34.76 ] + }, + { + "time": 0.1, + "x": 1.59, + "y": -34.76, + "curve": [ 0.214, 3.33, 0.375, 5.34, 0.192, -34.76, 0.441, -21.17 ] + }, + { + "time": 0.4667, + "x": 5.34, + "y": -12.57, + "curve": [ 0.492, 5.34, 0.55, 5.24, 0.482, -7.36, 0.504, 4.03 ] + }, + { + "time": 0.5667, + "x": 5.11, + "y": 4.01, + "curve": [ 0.658, 4.45, 0.679, 3.19, 0.649, 3.98, 0.642, -16.84 ] + }, + { + "time": 0.7, + "x": 2.8, + "y": -16.74, + "curve": [ 0.787, 1.15, 0.881, -1.29, 0.772, -16.62, 0.82, 8.95 ] + }, + { + "time": 0.9, + "x": -1.72, + "y": 8.91, + "curve": [ 0.961, -3.06, 1.025, -3.58, 0.975, 8.87, 0.951, -1.37 ] + }, + { + "time": 1.1, + "x": -3.58, + "y": -1.45, + "curve": [ 1.292, -3.58, 2.002, -2.4, 1.292, -1.56, 1.975, -1.45 ] + }, + { + "time": 2.1667, + "x": -1.39, + "y": -1.45, + "curve": [ 2.25, -0.88, 2.503, 1.38, 2.283, -1.45, 2.603, -12.44 ] + }, + { + "time": 2.6667, + "x": 2.13, + "y": -14.45, + "curve": [ 2.766, 2.59, 2.999, 2.81, 2.835, -19.73, 3.003, -25.2 ] + }, + { + "time": 3.1333, + "x": 2.91, + "y": -26.08, + "curve": [ 3.392, 3.1, 4.199, 4.05, 3.483, -28.44, 4.129, -27.23 ] + }, + { + "time": 4.3667, + "x": 4.81, + "y": -19.59, + "curve": [ 4.429, 5.1, 4.594, 8.54, 4.538, -14.08, 4.583, -7.88 ] + }, + { + "time": 4.6667, + "x": 8.65, + "y": -4.56, + "curve": [ 4.794, 8.86, 4.806, 5.93, 4.691, -3.59, 4.8, -1.61 ] + }, + { "time": 4.9333, "x": 5.8, "y": -1.99 } + ] + } + }, + "ik": { + "front-foot-ik": [ + { "mix": 0 } + ], + "front-leg-ik": [ + { "mix": 0, "bendPositive": false } + ], + "rear-foot-ik": [ + { "mix": 0.005 } + ], + "rear-leg-ik": [ + { "mix": 0.005, "bendPositive": false } + ] + } + }, + "hoverboard": { + "slots": { + "exhaust1": { + "attachment": [ + { "name": "hoverglow-small" } + ] + }, + "exhaust2": { + "attachment": [ + { "name": "hoverglow-small" } + ] + }, + "exhaust3": { + "attachment": [ + { "name": "hoverglow-small" } + ] + }, + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + }, + "hoverboard-board": { + "attachment": [ + { "name": "hoverboard-board" } + ] + }, + "hoverboard-thruster-front": { + "attachment": [ + { "name": "hoverboard-thruster" } + ] + }, + "hoverboard-thruster-rear": { + "attachment": [ + { "name": "hoverboard-thruster" } + ] + }, + "hoverglow-front": { + "attachment": [ + { "name": "hoverglow-small" } + ] + }, + "hoverglow-rear": { + "attachment": [ + { "name": "hoverglow-small" } + ] + }, + "side-glow1": { + "attachment": [ + { "name": "hoverglow-small" }, + { "time": 0.9667 } + ] + }, + "side-glow2": { + "attachment": [ + { "time": 0.0667, "name": "hoverglow-small" }, + { "time": 1 } + ] + }, + "side-glow3": { + "attachment": [ + { "name": "hoverglow-small" }, + { "time": 0.9667 } + ] + } + }, + "bones": { + "hoverboard-controller": { + "translate": [ + { + "x": 319.55, + "y": -1.59, + "curve": [ 0.064, 319.55, 0.2, 347.85, 0.058, -1.2, 0.2, 23.11 ] + }, + { + "time": 0.2667, + "x": 347.66, + "y": 39.62, + "curve": [ 0.35, 347.41, 0.476, 341.47, 0.323, 53.58, 0.44, 85.82 ] + }, + { + "time": 0.5333, + "x": 338.47, + "y": 85.72, + "curve": [ 0.603, 334.83, 0.913, 319.65, 0.621, 85.62, 0.88, -1.53 ] + }, + { "time": 1, "x": 319.55, "y": -1.59 } + ] + }, + "hip": { + "translate": [ + { + "x": -53.49, + "y": 32.14, + "curve": [ 0.061, -53.77, 0.093, -51.81, 0.044, 16.34, 0.063, 9.67 ] + }, + { + "time": 0.1333, + "x": -49.31, + "y": 7.01, + "curve": [ 0.3, -35.27, 0.461, -20.06, 0.314, 9.52, 0.408, 121.09 ] + }, + { + "time": 0.5667, + "x": -20.06, + "y": 122.72, + "curve": [ 0.716, -20.09, 0.912, -53.29, 0.753, 121.8, 0.946, 51.85 ] + }, + { "time": 1, "x": -53.49, "y": 32.14 } + ] + }, + "exhaust1": { + "scale": [ + { + "x": 1.593, + "y": 0.964, + "curve": [ 0.033, 1.593, 0.1, 1, 0.033, 0.964, 0.1, 0.713 ] + }, + { + "time": 0.1333, + "y": 0.713, + "curve": [ 0.15, 1, 0.183, 1.774, 0.15, 0.713, 0.183, 0.883 ] + }, + { + "time": 0.2, + "x": 1.774, + "y": 0.883, + "curve": [ 0.242, 1.774, 0.325, 1.181, 0.242, 0.883, 0.325, 0.649 ] + }, + { + "time": 0.3667, + "x": 1.181, + "y": 0.649, + "curve": [ 0.408, 1.181, 0.492, 1.893, 0.408, 0.649, 0.492, 0.819 ] + }, + { + "time": 0.5333, + "x": 1.893, + "y": 0.819, + "curve": [ 0.558, 1.893, 0.608, 1.18, 0.558, 0.819, 0.608, 0.686 ] + }, + { + "time": 0.6333, + "x": 1.18, + "y": 0.686, + "curve": [ 0.658, 1.18, 0.708, 1.903, 0.658, 0.686, 0.708, 0.855 ] + }, + { + "time": 0.7333, + "x": 1.903, + "y": 0.855, + "curve": [ 0.767, 1.903, 0.833, 1.311, 0.767, 0.855, 0.833, 0.622 ] + }, + { + "time": 0.8667, + "x": 1.311, + "y": 0.622, + "curve": [ 0.9, 1.311, 0.967, 1.593, 0.9, 0.622, 0.967, 0.964 ] + }, + { "time": 1, "x": 1.593, "y": 0.964 } + ] + }, + "exhaust2": { + "scale": [ + { + "x": 1.88, + "y": 0.832, + "curve": [ 0.025, 1.88, 0.075, 1.311, 0.025, 0.832, 0.075, 0.686 ] + }, + { + "time": 0.1, + "x": 1.311, + "y": 0.686, + "curve": [ 0.133, 1.311, 0.2, 2.01, 0.133, 0.686, 0.208, 0.736 ] + }, + { + "time": 0.2333, + "x": 2.01, + "y": 0.769, + "curve": [ 0.267, 2.01, 0.333, 1, 0.282, 0.831, 0.333, 0.91 ] + }, + { + "time": 0.3667, + "y": 0.91, + "curve": [ 0.4, 1, 0.467, 1.699, 0.4, 0.91, 0.474, 0.891 ] + }, + { + "time": 0.5, + "x": 1.699, + "y": 0.86, + "curve": [ 0.517, 1.699, 0.55, 1.181, 0.54, 0.813, 0.55, 0.713 ] + }, + { + "time": 0.5667, + "x": 1.181, + "y": 0.713, + "curve": [ 0.617, 1.181, 0.717, 1.881, 0.617, 0.713, 0.717, 0.796 ] + }, + { + "time": 0.7667, + "x": 1.881, + "y": 0.796, + "curve": [ 0.8, 1.881, 0.867, 1.3, 0.8, 0.796, 0.867, 0.649 ] + }, + { + "time": 0.9, + "x": 1.3, + "y": 0.649, + "curve": [ 0.925, 1.3, 0.975, 1.88, 0.925, 0.649, 0.975, 0.832 ] + }, + { "time": 1, "x": 1.88, "y": 0.832 } + ] + }, + "hoverboard-thruster-front": { + "rotate": [ + { + "curve": [ 0.125, 0, 0.375, 24.06 ] + }, + { + "time": 0.5, + "value": 24.06, + "curve": [ 0.625, 24.06, 0.875, 0 ] + }, + { "time": 1 } + ] + }, + "hoverglow-front": { + "scale": [ + { + "x": 0.849, + "y": 1.764, + "curve": [ 0.017, 0.849, 0.05, 0.835, 0.017, 1.764, 0.05, 2.033 ] + }, + { + "time": 0.0667, + "x": 0.835, + "y": 2.033, + "curve": [ 0.092, 0.835, 0.142, 0.752, 0.092, 2.033, 0.142, 1.584 ] + }, + { + "time": 0.1667, + "x": 0.752, + "y": 1.584, + "curve": [ 0.183, 0.752, 0.217, 0.809, 0.183, 1.584, 0.217, 1.71 ] + }, + { + "time": 0.2333, + "x": 0.809, + "y": 1.71, + "curve": [ 0.25, 0.809, 0.283, 0.717, 0.25, 1.71, 0.283, 1.45 ] + }, + { + "time": 0.3, + "x": 0.717, + "y": 1.45, + "curve": [ 0.317, 0.717, 0.35, 0.777, 0.317, 1.45, 0.35, 1.698 ] + }, + { + "time": 0.3667, + "x": 0.777, + "y": 1.698, + "curve": [ 0.4, 0.781, 0.45, 0.685, 0.375, 1.698, 0.45, 1.173 ] + }, + { + "time": 0.4667, + "x": 0.685, + "y": 1.173, + "curve": [ 0.492, 0.685, 0.542, 0.825, 0.492, 1.173, 0.542, 1.572 ] + }, + { + "time": 0.5667, + "x": 0.825, + "y": 1.572, + "curve": [ 0.611, 0.816, 0.63, 0.727, 0.611, 1.577, 0.606, 1.255 ] + }, + { + "time": 0.6667, + "x": 0.725, + "y": 1.241, + "curve": [ 0.692, 0.725, 0.742, 0.895, 0.692, 1.241, 0.749, 1.799 ] + }, + { + "time": 0.7667, + "x": 0.895, + "y": 1.857, + "curve": [ 0.783, 0.895, 0.796, 0.892, 0.796, 1.955, 0.817, 1.962 ] + }, + { + "time": 0.8333, + "x": 0.845, + "y": 1.962, + "curve": [ 0.845, 0.831, 0.883, 0.802, 0.85, 1.962, 0.872, 1.704 ] + }, + { + "time": 0.9, + "x": 0.802, + "y": 1.491, + "curve": [ 0.917, 0.802, 0.95, 0.845, 0.907, 1.441, 0.936, 1.508 ] + }, + { + "time": 0.9667, + "x": 0.845, + "y": 1.627, + "curve": [ 0.975, 0.845, 0.992, 0.849, 0.973, 1.652, 0.992, 1.764 ] + }, + { "time": 1, "x": 0.849, "y": 1.764 } + ] + }, + "hoverboard-thruster-rear": { + "rotate": [ + { + "curve": [ 0.125, 0, 0.375, 24.06 ] + }, + { + "time": 0.5, + "value": 24.06, + "curve": [ 0.625, 24.06, 0.875, 0 ] + }, + { "time": 1 } + ] + }, + "hoverglow-rear": { + "scale": [ + { + "x": 0.845, + "y": 1.31, + "curve": [ 0.017, 0.845, 0.117, 0.899, 0.017, 1.31, 0.117, 2.033 ] + }, + { + "time": 0.1333, + "x": 0.899, + "y": 2.033, + "curve": [ 0.15, 0.899, 0.183, 0.752, 0.15, 2.033, 0.183, 1.574 ] + }, + { + "time": 0.2, + "x": 0.752, + "y": 1.574, + "curve": [ 0.225, 0.752, 0.275, 0.809, 0.225, 1.574, 0.275, 1.71 ] + }, + { + "time": 0.3, + "x": 0.809, + "y": 1.71, + "curve": [ 0.317, 0.809, 0.35, 0.717, 0.317, 1.71, 0.35, 1.397 ] + }, + { + "time": 0.3667, + "x": 0.717, + "y": 1.397, + "curve": [ 0.383, 0.717, 0.417, 0.777, 0.383, 1.397, 0.417, 1.45 ] + }, + { + "time": 0.4333, + "x": 0.777, + "y": 1.45, + "curve": [ 0.45, 0.777, 0.496, 0.689, 0.45, 1.45, 0.481, 1.168 ] + }, + { + "time": 0.5333, + "x": 0.685, + "y": 1.173, + "curve": [ 0.565, 0.682, 0.617, 0.758, 0.575, 1.177, 0.617, 1.297 ] + }, + { + "time": 0.6333, + "x": 0.758, + "y": 1.297, + "curve": [ 0.658, 0.758, 0.708, 0.725, 0.658, 1.297, 0.708, 1.241 ] + }, + { + "time": 0.7333, + "x": 0.725, + "y": 1.241, + "curve": [ 0.772, 0.732, 0.796, 0.893, 0.782, 1.238, 0.778, 1.854 ] + }, + { + "time": 0.8333, + "x": 0.895, + "y": 1.857, + "curve": [ 0.878, 0.9, 0.992, 0.845, 0.88, 1.86, 0.992, 1.31 ] + }, + { "time": 1, "x": 0.845, "y": 1.31 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -85.92, + "curve": [ 0.08, -85.59, 0.284, -62.7 ] + }, + { + "time": 0.3667, + "value": -55.14, + "curve": [ 0.438, -48.65, 0.551, -43.21 ] + }, + { + "time": 0.6333, + "value": -43.21, + "curve": [ 0.716, -43.22, 0.908, -85.92 ] + }, + { "time": 1, "value": -85.92 } + ], + "translate": [ + { + "x": -0.59, + "y": -2.94, + "curve": [ 0.1, -1.21, 0.275, -1.74, 0.092, -2.94, 0.275, -6.39 ] + }, + { + "time": 0.3667, + "x": -1.74, + "y": -6.39, + "curve": [ 0.433, -1.74, 0.567, 0.72, 0.433, -6.39, 0.587, -4.48 ] + }, + { + "time": 0.6333, + "x": 0.72, + "y": -4.21, + "curve": [ 0.725, 0.72, 0.908, -0.08, 0.743, -3.57, 0.908, -2.94 ] + }, + { "time": 1, "x": -0.59, "y": -2.94 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": 7.61, + "curve": [ 0.143, 7.62, 0.247, -23.17 ] + }, + { + "time": 0.2667, + "value": -26.56, + "curve": [ 0.281, -29.08, 0.351, -37.36 ] + }, + { + "time": 0.4333, + "value": -37.2, + "curve": [ 0.513, -37.05, 0.562, -29.88 ] + }, + { + "time": 0.6, + "value": -25.18, + "curve": [ 0.621, -22.58, 0.694, -3.98 ] + }, + { + "time": 0.8, + "value": 3.63, + "curve": [ 0.861, 8.03, 0.946, 7.57 ] + }, + { "time": 1, "value": 7.61 } + ], + "translate": [ + { + "curve": [ 0.117, 0, 0.35, 0.52, 0.117, 0, 0.35, -3.27 ] + }, + { + "time": 0.4667, + "x": 0.52, + "y": -3.27, + "curve": [ 0.6, 0.52, 0.867, 0, 0.6, -3.27, 0.867, 0 ] + }, + { "time": 1 } + ], + "shear": [ + { + "y": 19.83, + "curve": [ 0.117, 0, 0.35, 15.28, 0.117, 19.83, 0.35, 28.31 ] + }, + { + "time": 0.4667, + "x": 15.28, + "y": 28.31, + "curve": [ 0.6, 15.28, 0.867, 0, 0.6, 28.31, 0.867, 19.83 ] + }, + { "time": 1, "y": 19.83 } + ] + }, + "board-ik": { + "translate": [ + { + "x": 393.62, + "curve": [ 0.083, 393.62, 0.25, 393.48, 0.083, 0, 0.25, 117.69 ] + }, + { + "time": 0.3333, + "x": 393.48, + "y": 117.69, + "curve": [ 0.375, 393.48, 0.458, 393.62, 0.375, 117.69, 0.458, 83.82 ] + }, + { "time": 0.5, "x": 393.62, "y": 83.82 }, + { "time": 0.6667, "x": 393.62, "y": 30.15 }, + { "time": 1, "x": 393.62 } + ] + }, + "front-thigh": { + "translate": [ + { "x": -7.49, "y": 8.51 } + ] + }, + "front-leg-target": { + "translate": [ + { + "time": 0.3667, + "curve": [ 0.428, 10.83, 0.567, 12.78, 0.414, 7.29, 0.567, 8.79 ] + }, + { + "time": 0.6, + "x": 12.78, + "y": 8.79, + "curve": [ 0.692, 12.78, 0.772, 11.27, 0.692, 8.79, 0.766, 8.62 ] + }, + { "time": 0.8667 } + ] + }, + "rear-leg-target": { + "translate": [ + { + "time": 0.4667, + "curve": [ 0.492, 0, 0.534, 4.47, 0.492, 0, 0.542, 1.63 ] + }, + { + "time": 0.5667, + "x": 4.53, + "y": 1.77, + "curve": [ 0.622, 4.64, 0.717, 3.31, 0.615, 2.06, 0.71, 2.1 ] + }, + { "time": 0.8 } + ] + }, + "exhaust3": { + "scale": [ + { + "x": 1.882, + "y": 0.81, + "curve": [ 0.017, 1.882, 0.167, 1.3, 0.017, 0.81, 0.167, 0.649 ] + }, + { + "time": 0.2, + "x": 1.3, + "y": 0.649, + "curve": [ 0.225, 1.3, 0.275, 2.051, 0.225, 0.649, 0.275, 0.984 ] + }, + { + "time": 0.3, + "x": 2.051, + "y": 0.984, + "curve": [ 0.325, 2.051, 0.375, 1.311, 0.325, 0.984, 0.384, 0.715 ] + }, + { + "time": 0.4, + "x": 1.311, + "y": 0.686, + "curve": [ 0.433, 1.311, 0.5, 1.86, 0.426, 0.638, 0.5, 0.537 ] + }, + { + "time": 0.5333, + "x": 1.86, + "y": 0.537, + "curve": [ 0.567, 1.86, 0.633, 1.187, 0.567, 0.537, 0.604, 0.854 ] + }, + { + "time": 0.6667, + "x": 1.187, + "y": 0.854, + "curve": [ 0.7, 1.187, 0.767, 1.549, 0.707, 0.854, 0.774, 0.775 ] + }, + { + "time": 0.8, + "x": 1.549, + "y": 0.746, + "curve": [ 0.817, 1.549, 0.85, 1.181, 0.815, 0.729, 0.85, 0.713 ] + }, + { + "time": 0.8667, + "x": 1.181, + "y": 0.713, + "curve": [ 0.9, 1.181, 0.967, 1.882, 0.9, 0.713, 0.967, 0.81 ] + }, + { "time": 1, "x": 1.882, "y": 0.81 } + ] + }, + "side-glow1": { + "rotate": [ + { "value": 51.12, "curve": "stepped" }, + { "time": 0.0667, "value": 43.82, "curve": "stepped" }, + { "time": 0.1, "value": 40.95, "curve": "stepped" }, + { "time": 0.1667, "value": 27.78, "curve": "stepped" }, + { "time": 0.2, "value": 10.24, "curve": "stepped" }, + { "time": 0.2667, "curve": "stepped" }, + { "time": 0.8, "value": -25.81 } + ], + "translate": [ + { "x": 338.28, "y": 40.22, "curve": "stepped" }, + { "time": 0.0667, "x": 331.2, "y": 30.39, "curve": "stepped" }, + { "time": 0.1, "x": 318.63, "y": 20.59, "curve": "stepped" }, + { "time": 0.1667, "x": 302.45, "y": 9.64, "curve": "stepped" }, + { "time": 0.2, "x": 276.87, "y": 1.13, "curve": "stepped" }, + { "time": 0.2667, "x": 248.16, "curve": "stepped" }, + { "time": 0.3, "x": 221.36, "curve": "stepped" }, + { "time": 0.3667, "x": 195.69, "curve": "stepped" }, + { "time": 0.4, "x": 171.08, "curve": "stepped" }, + { "time": 0.4667, "x": 144.84, "curve": "stepped" }, + { "time": 0.5, "x": 121.22, "curve": "stepped" }, + { "time": 0.5667, "x": 91.98, "curve": "stepped" }, + { "time": 0.6, "x": 62.63, "curve": "stepped" }, + { "time": 0.6667, "x": 30.78, "curve": "stepped" }, + { "time": 0.7, "curve": "stepped" }, + { "time": 0.7667, "x": -28.45, "curve": "stepped" }, + { "time": 0.8, "x": -67.49, "y": 16.82, "curve": "stepped" }, + { "time": 0.8667, "x": -83.07, "y": 24.36, "curve": "stepped" }, + { "time": 0.9, "x": -93.81, "y": 29.55 } + ], + "scale": [ + { "x": 0.535, "curve": "stepped" }, + { "time": 0.0667, "x": 0.594, "curve": "stepped" }, + { "time": 0.1, "x": 0.844, "curve": "stepped" }, + { "time": 0.1667, "curve": "stepped" }, + { "time": 0.8, "x": 0.534, "curve": "stepped" }, + { "time": 0.8667, "x": 0.428, "y": 0.801, "curve": "stepped" }, + { "time": 0.9, "x": 0.349, "y": 0.654 } + ] + }, + "side-glow2": { + "rotate": [ + { "time": 0.0667, "value": 51.12, "curve": "stepped" }, + { "time": 0.1, "value": 43.82, "curve": "stepped" }, + { "time": 0.1667, "value": 40.95, "curve": "stepped" }, + { "time": 0.2, "value": 27.78, "curve": "stepped" }, + { "time": 0.2667, "value": 10.24, "curve": "stepped" }, + { "time": 0.3, "curve": "stepped" }, + { "time": 0.8667, "value": -25.81 } + ], + "translate": [ + { "time": 0.0667, "x": 338.28, "y": 40.22, "curve": "stepped" }, + { "time": 0.1, "x": 331.2, "y": 30.39, "curve": "stepped" }, + { "time": 0.1667, "x": 318.63, "y": 20.59, "curve": "stepped" }, + { "time": 0.2, "x": 302.45, "y": 9.64, "curve": "stepped" }, + { "time": 0.2667, "x": 276.87, "y": 1.13, "curve": "stepped" }, + { "time": 0.3, "x": 248.16, "curve": "stepped" }, + { "time": 0.3667, "x": 221.36, "curve": "stepped" }, + { "time": 0.4, "x": 195.69, "curve": "stepped" }, + { "time": 0.4667, "x": 171.08, "curve": "stepped" }, + { "time": 0.5, "x": 144.84, "curve": "stepped" }, + { "time": 0.5667, "x": 121.22, "curve": "stepped" }, + { "time": 0.6, "x": 91.98, "curve": "stepped" }, + { "time": 0.6667, "x": 62.63, "curve": "stepped" }, + { "time": 0.7, "x": 30.78, "curve": "stepped" }, + { "time": 0.7667, "curve": "stepped" }, + { "time": 0.8, "x": -28.45, "curve": "stepped" }, + { "time": 0.8667, "x": -67.49, "y": 16.82, "curve": "stepped" }, + { "time": 0.9, "x": -83.07, "y": 24.36, "curve": "stepped" }, + { "time": 0.9667, "x": -93.81, "y": 29.55 } + ], + "scale": [ + { "time": 0.0667, "x": 0.535, "curve": "stepped" }, + { "time": 0.1, "x": 0.594, "curve": "stepped" }, + { "time": 0.1667, "x": 0.844, "curve": "stepped" }, + { "time": 0.2, "curve": "stepped" }, + { "time": 0.8667, "x": 0.534, "curve": "stepped" }, + { "time": 0.9, "x": 0.428, "y": 0.801, "curve": "stepped" }, + { "time": 0.9667, "x": 0.349, "y": 0.654 } + ] + }, + "torso": { + "rotate": [ + { + "value": -34.73, + "curve": [ 0.034, -36.31, 0.162, -39.33 ] + }, + { + "time": 0.2667, + "value": -39.37, + "curve": [ 0.384, -39.37, 0.491, -29.52 ] + }, + { + "time": 0.5, + "value": -28.86, + "curve": [ 0.525, -26.95, 0.571, -21.01 ] + }, + { + "time": 0.6333, + "value": -21.01, + "curve": [ 0.725, -21.01, 0.969, -33.35 ] + }, + { "time": 1, "value": -34.73 } + ] + }, + "neck": { + "rotate": [ + { + "value": 10.2, + "curve": [ 0.07, 12.09, 0.189, 16.03 ] + }, + { + "time": 0.2667, + "value": 16.14, + "curve": [ 0.333, 16.14, 0.449, 8.03 ] + }, + { + "time": 0.5, + "value": 5.83, + "curve": [ 0.542, 4.02, 0.6, 2.68 ] + }, + { + "time": 0.6333, + "value": 2.68, + "curve": [ 0.725, 2.68, 0.943, 8.57 ] + }, + { "time": 1, "value": 10.2 } + ] + }, + "head": { + "rotate": [ + { + "value": 10.2, + "curve": [ 0.044, 11.52, 0.2, 16.12 ] + }, + { + "time": 0.2667, + "value": 16.14, + "curve": [ 0.375, 16.17, 0.492, 2.65 ] + }, + { + "time": 0.6333, + "value": 2.68, + "curve": [ 0.725, 2.7, 0.963, 9.26 ] + }, + { "time": 1, "value": 10.2 } + ], + "translate": [ + { + "curve": [ 0.03, -0.24, 0.2, -4.22, 0.051, -1.06, 0.2, -3.62 ] + }, + { + "time": 0.2667, + "x": -4.22, + "y": -3.62, + "curve": [ 0.358, -4.22, 0.542, 0.84, 0.358, -3.62, 0.542, 6.01 ] + }, + { + "time": 0.6333, + "x": 0.84, + "y": 6.01, + "curve": [ 0.725, 0.84, 0.939, 0.32, 0.725, 6.01, 0.945, 1.14 ] + }, + { "time": 1 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": -11.18, + "curve": [ 0.064, -14.82, 0.25, -20.01 ] + }, + { + "time": 0.3333, + "value": -20.01, + "curve": [ 0.429, -20.12, 0.58, 5.12 ] + }, + { + "time": 0.6, + "value": 8.67, + "curve": [ 0.617, 11.72, 0.687, 20.52 ] + }, + { + "time": 0.7667, + "value": 20.55, + "curve": [ 0.848, 20.7, 0.963, -9.43 ] + }, + { "time": 1, "value": -11.18 } + ] + }, + "hair3": { + "rotate": [ + { + "value": 9.61, + "curve": [ 0.014, 8.51, 0.075, 2.63 ] + }, + { + "time": 0.1, + "value": 2.63, + "curve": [ 0.15, 2.63, 0.25, 13.52 ] + }, + { + "time": 0.3, + "value": 13.52, + "curve": [ 0.35, 13.52, 0.45, 11.28 ] + }, + { + "time": 0.5, + "value": 11.28, + "curve": [ 0.575, 11.28, 0.725, 18.13 ] + }, + { + "time": 0.8, + "value": 18.13, + "curve": [ 0.85, 18.13, 0.978, 11.07 ] + }, + { "time": 1, "value": 9.61 } + ] + }, + "hair4": { + "rotate": [ + { + "value": -17.7, + "curve": [ 0.008, -17.7, 0.025, -23.73 ] + }, + { + "time": 0.0333, + "value": -23.73, + "curve": [ 0.067, -23.73, 0.154, -4.4 ] + }, + { + "time": 0.1667, + "value": -1.92, + "curve": [ 0.197, 4.09, 0.236, 12.91 ] + }, + { + "time": 0.2667, + "value": 17.56, + "curve": [ 0.301, 22.68, 0.342, 27.97 ] + }, + { + "time": 0.3667, + "value": 27.97, + "curve": [ 0.4, 27.97, 0.467, -1.45 ] + }, + { + "time": 0.5, + "value": -1.45, + "curve": [ 0.517, -1.45, 0.55, 3.16 ] + }, + { + "time": 0.5667, + "value": 3.16, + "curve": [ 0.583, 3.16, 0.617, -8.9 ] + }, + { + "time": 0.6333, + "value": -8.9, + "curve": [ 0.642, -8.9, 0.658, -5.4 ] + }, + { + "time": 0.6667, + "value": -5.4, + "curve": [ 0.683, -5.4, 0.717, -15.32 ] + }, + { + "time": 0.7333, + "value": -15.32, + "curve": [ 0.75, -15.32, 0.783, -9.19 ] + }, + { + "time": 0.8, + "value": -9.19, + "curve": [ 0.817, -9.19, 0.85, -23.6 ] + }, + { + "time": 0.8667, + "value": -23.6, + "curve": [ 0.883, -23.6, 0.917, -17.38 ] + }, + { + "time": 0.9333, + "value": -17.38, + "curve": [ 0.942, -17.38, 0.958, -20.46 ] + }, + { + "time": 0.9667, + "value": -20.46, + "curve": [ 0.975, -20.46, 0.992, -17.7 ] + }, + { "time": 1, "value": -17.7 } + ] + }, + "hair1": { + "rotate": [ + { + "value": 9.61, + "curve": [ 0.06, 9.04, 0.25, 8.9 ] + }, + { + "time": 0.3333, + "value": 8.9, + "curve": [ 0.392, 8.9, 0.508, 14.58 ] + }, + { + "time": 0.5667, + "value": 14.58, + "curve": [ 0.675, 14.58, 0.956, 10.28 ] + }, + { "time": 1, "value": 9.61 } + ] + }, + "hair2": { + "rotate": [ + { + "value": -3.82, + "curve": [ 0.017, -3.82, 0.064, -9.16 ] + }, + { + "time": 0.1333, + "value": -9.09, + "curve": [ 0.178, -9.04, 0.234, 1.29 ] + }, + { + "time": 0.2667, + "value": 5.98, + "curve": [ 0.276, 7.27, 0.336, 17.1 ] + }, + { + "time": 0.3667, + "value": 17.1, + "curve": [ 0.413, 17.1, 0.467, 1.59 ] + }, + { + "time": 0.5, + "value": 1.59, + "curve": [ 0.533, 1.59, 0.567, 13.63 ] + }, + { + "time": 0.6, + "value": 13.63, + "curve": [ 0.617, 13.63, 0.683, 0.78 ] + }, + { + "time": 0.7, + "value": 0.78, + "curve": [ 0.717, 0.78, 0.75, 12.01 ] + }, + { + "time": 0.7667, + "value": 11.9, + "curve": [ 0.792, 11.73, 0.817, -0.85 ] + }, + { + "time": 0.8333, + "value": -0.85, + "curve": [ 0.85, -0.85, 0.88, 1.99 ] + }, + { + "time": 0.9, + "value": 1.82, + "curve": [ 0.916, 1.68, 0.95, -6.9 ] + }, + { + "time": 0.9667, + "value": -6.9, + "curve": [ 0.975, -6.9, 0.992, -3.82 ] + }, + { "time": 1, "value": -3.82 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 31.65, + "curve": [ 0.108, 31.65, 0.325, 13.01 ] + }, + { + "time": 0.4333, + "value": 13.01, + "curve": [ 0.71, 13.01, 0.917, 31.65 ] + }, + { "time": 1, "value": 31.65 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 31, + "curve": [ 0.108, 31, 0.325, 12.76 ] + }, + { + "time": 0.4333, + "value": 12.79, + "curve": [ 0.587, 12.82, 0.917, 31 ] + }, + { "time": 1, "value": 31 } + ] + }, + "gun": { + "rotate": [ + { + "value": 1.95, + "curve": [ 0.083, 1.95, 0.245, 36.73 ] + }, + { + "time": 0.3333, + "value": 36.71, + "curve": [ 0.439, 36.69, 0.589, 10.68 ] + }, + { + "time": 0.6333, + "value": 8.75, + "curve": [ 0.701, 5.81, 0.917, 1.95 ] + }, + { "time": 1, "value": 1.95 } + ] + }, + "torso2": { + "rotate": [ + { + "curve": [ 0.033, 0, 0.1, 2.35 ] + }, + { + "time": 0.1333, + "value": 2.35, + "curve": [ 0.225, 2.35, 0.408, -2.4 ] + }, + { + "time": 0.5, + "value": -2.4, + "curve": [ 0.567, -2.4, 0.7, 1.44 ] + }, + { + "time": 0.7667, + "value": 1.44, + "curve": [ 0.825, 1.44, 0.942, 0 ] + }, + { "time": 1 } + ] + }, + "torso3": { + "rotate": [ + { + "curve": [ 0.063, 0.77, 0.106, 1.42 ] + }, + { + "time": 0.1667, + "value": 1.42, + "curve": [ 0.259, 1.42, 0.344, -1.25 ] + }, + { + "time": 0.4667, + "value": -1.26, + "curve": [ 0.656, -1.26, 0.917, -0.78 ] + }, + { "time": 1 } + ] + }, + "head-control": { + "translate": [ + { + "x": 0.37, + "y": -11.17, + "curve": [ 0.133, 0.37, 0.335, -10.23, 0.133, -11.17, 0.335, 3.15 ] + }, + { + "time": 0.5333, + "x": -10.23, + "y": 3.15, + "curve": [ 0.71, -10.23, 0.883, 0.37, 0.71, 3.15, 0.883, -11.17 ] + }, + { "time": 1, "x": 0.37, "y": -11.17 } + ] + }, + "front-shoulder": { + "translate": [ + { + "x": 1.46, + "y": 10.15, + "curve": [ 0.103, 1.46, 0.249, 1.36, 0.103, 10.15, 0.249, -4.39 ] + }, + { + "time": 0.4, + "x": 1.36, + "y": -4.39, + "curve": [ 0.621, 1.36, 0.85, 1.46, 0.621, -4.39, 0.85, 10.15 ] + }, + { "time": 1, "x": 1.46, "y": 10.15 } + ] + }, + "back-shoulder": { + "translate": [ + { + "x": 1.4, + "y": 0.44, + "curve": [ 0.088, 1.4, 0.208, -2.47, 0.088, 0.44, 0.208, 8.61 ] + }, + { + "time": 0.3333, + "x": -2.47, + "y": 8.61, + "curve": [ 0.572, -2.47, 0.833, 1.4, 0.572, 8.61, 0.833, 0.44 ] + }, + { "time": 1, "x": 1.4, "y": 0.44 } + ] + } + }, + "transform": { + "front-foot-board-transform": [ + { "mixRotate": 0.997 } + ], + "rear-foot-board-transform": [ + {} + ], + "toes-board": [ + { "mixX": 0, "mixScaleX": 0, "mixShearY": 0 } + ] + }, + "attachments": { + "default": { + "front-foot": { + "front-foot": { + "deform": [ + { + "offset": 26, + "vertices": [ -0.02832, -5.37024, -0.02832, -5.37024, 3.8188, -3.7757, -0.02832, -5.37024, -3.82159, 3.77847 ] + } + ] + } + }, + "front-shin": { + "front-shin": { + "deform": [ + { + "offset": 14, + "vertices": [ 0.5298, -1.12677, -0.85507, -4.20587, -11.35158, -10.19225, -10.79865, -8.43765, -6.06447, -6.89757, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.54892, -3.06021, 1.48463, -2.29663, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -4.80437, -7.01817 ] + }, + { + "time": 0.3667, + "offset": 14, + "vertices": [ 0.5298, -1.12677, -11.66571, -9.07211, -25.65866, -17.53735, -25.53217, -16.50978, -11.78232, -11.26097, 0, 0, 0.60487, -1.63589, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.60487, -1.63589, 0, 0, -2.64522, -7.35739, 1.48463, -2.29663, 0, 0, 0, 0, 0, 0, 0.60487, -1.63589, 0.60487, -1.63589, 0.60487, -1.63589, 0.60487, -1.63589, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.60487, -1.63589, 0, 0, -10.06873, -12.0999 ] + }, + { + "time": 0.5333, + "offset": 14, + "vertices": [ 0.5298, -1.12677, -0.85507, -4.20587, -7.00775, -8.24771, -6.45482, -6.49312, -6.06447, -6.89757, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.54892, -3.06021, 1.48463, -2.29663, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -4.80437, -7.01817 ] + }, + { + "time": 1, + "offset": 14, + "vertices": [ 0.5298, -1.12677, -0.85507, -4.20587, -11.35158, -10.19225, -10.79865, -8.43765, -6.06447, -6.89757, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.54892, -3.06021, 1.48463, -2.29663, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -4.80437, -7.01817 ] + } + ] + } + }, + "hoverboard-board": { + "hoverboard-board": { + "deform": [ + { + "curve": [ 0.067, 0, 0.2, 1 ] + }, + { + "time": 0.2667, + "offset": 1, + "vertices": [ 2.45856, 0, 0, 0, 0, 0, 0, 0, 0, 3.55673, -3.0E-4, 3.55673, -3.0E-4, 0, 0, 0, 0, 0, 0, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, -7.6E-4, -9.84158, 0, 0, 0, 0, 0, 0, 0, 0, -4.90558, 0.11214, -9.40706, 6.2E-4, -6.34871, 4.3E-4, -6.34925, -6.57018, -6.34925, -6.57018, -6.34871, 4.3E-4, -2.3308, 1.7E-4, -2.33133, -6.57045, -2.33133, -6.57045, -2.3308, 1.7E-4, 0, 0, 1.2E-4, 2.45856, 1.2E-4, 2.45856, 1.2E-4, 2.45856, 1.2E-4, 2.45856, 3.3297, 4.44005, 3.3297, 4.44005, 3.3297, 4.44005, 1.2E-4, 2.45856, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2.46227, 1.7E-4, -2.46227, 1.7E-4, -2.52316, 1.1313, -2.52316, 1.1313, -2.52316, 1.1313, 1.2E-4, 2.45856, 1.2E-4, 2.45856, -9.40694, 2.45918, 1.88063, 0.44197, -2.9E-4, -3.54808, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2.52316, 1.1313, -2.52316, 1.1313, -2.52316, 1.1313, -2.46227, 1.7E-4, -2.46227, 1.7E-4, -2.46227, 1.7E-4, 0, 0, 0, 0, 1.2E-4, 2.45856 ], + "curve": [ 0.45, 0, 0.817, 1 ] + }, + { "time": 1 } + ] + } + }, + "rear-foot": { + "rear-foot": { + "deform": [ + { + "offset": 28, + "vertices": [ -1.93078, 1.34782, -0.31417, 2.33363, 3.05122, 0.33946, 2.31472, -2.01678, 2.17583, -2.05795, -0.04277, -2.99459, 1.15429, 0.26328, 0.97501, -0.67169 ] + } + ] + } + } + } + } + }, + "idle": { + "slots": { + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + } + }, + "bones": { + "front-foot-target": { + "translate": [ + { "x": -69.06 } + ] + }, + "hip": { + "rotate": [ + { + "curve": [ 0.073, 0.35, 0.303, 1.27 ] + }, + { + "time": 0.4, + "value": 1.28, + "curve": [ 0.615, 1.3, 0.847, -1.41 ] + }, + { + "time": 1.2, + "value": -1.38, + "curve": [ 1.344, -1.37, 1.602, -0.28 ] + }, + { "time": 1.6667 } + ], + "translate": [ + { + "x": -11.97, + "y": -23.15, + "curve": [ 0.059, -12.96, 0.258, -15.19, 0.142, -23.15, 0.341, -24.89 ] + }, + { + "time": 0.4667, + "x": -15.14, + "y": -26.74, + "curve": [ 0.62, -15.1, 0.788, -13.28, 0.597, -28.66, 0.75, -30.01 ] + }, + { + "time": 0.9, + "x": -12.02, + "y": -30.01, + "curve": [ 0.978, -11.13, 1.175, -9.05, 1.036, -29.94, 1.234, -28.08 ] + }, + { + "time": 1.3333, + "x": -9.06, + "y": -26.64, + "curve": [ 1.501, -9.06, 1.614, -10.95, 1.454, -24.89, 1.609, -23.15 ] + }, + { "time": 1.6667, "x": -11.97, "y": -23.15 } + ] + }, + "rear-foot-target": { + "translate": [ + { "x": 48.87 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -60.87, + "curve": [ 0.154, -60.85, 0.452, -68.65 ] + }, + { + "time": 0.8333, + "value": -68.65, + "curve": [ 1.221, -68.65, 1.542, -60.87 ] + }, + { "time": 1.6667, "value": -60.87 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 42.46, + "curve": [ 0.029, 42.97, 0.134, 45.28 ] + }, + { + "time": 0.3333, + "value": 45.27, + "curve": [ 0.578, 45.26, 0.798, 40.07 ] + }, + { + "time": 0.8333, + "value": 39.74, + "curve": [ 0.878, 39.32, 1.019, 38.23 ] + }, + { + "time": 1.2, + "value": 38.22, + "curve": [ 1.377, 38.22, 1.619, 41.68 ] + }, + { "time": 1.6667, "value": 42.46 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 39.2, + "curve": [ 0.185, 39.22, 0.5, 29.37 ] + }, + { + "time": 0.6667, + "value": 29.37, + "curve": [ 0.917, 29.37, 1.417, 39.2 ] + }, + { "time": 1.6667, "value": 39.2 } + ] + }, + "head": { + "rotate": [ + { + "value": -6.75, + "curve": [ 0.176, -7.88, 0.349, -8.95 ] + }, + { + "time": 0.4667, + "value": -8.95, + "curve": [ 0.55, -8.95, 0.697, -6.77 ] + }, + { + "time": 0.8333, + "value": -5.44, + "curve": [ 0.88, -4.98, 1.05, -4.12 ] + }, + { + "time": 1.1333, + "value": -4.12, + "curve": [ 1.266, -4.12, 1.469, -5.48 ] + }, + { "time": 1.6667, "value": -6.75 } + ] + }, + "front-fist": { + "rotate": [ + { + "curve": [ 0.086, 0, 0.233, 2.48 ] + }, + { + "time": 0.3333, + "value": 4.13, + "curve": [ 0.429, 5.7, 0.711, 10.06 ] + }, + { + "time": 0.8333, + "value": 10.06, + "curve": [ 0.926, 10.06, 1.092, 4.21 ] + }, + { + "time": 1.2, + "value": 2.78, + "curve": [ 1.349, 0.8, 1.551, 0 ] + }, + { "time": 1.6667 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "curve": [ 0.063, 0.54, 0.367, 3.39 ] + }, + { + "time": 0.5333, + "value": 3.39, + "curve": [ 0.696, 3.39, 0.939, -1.63 ] + }, + { + "time": 1.2, + "value": -1.61, + "curve": [ 1.42, -1.59, 1.574, -0.67 ] + }, + { "time": 1.6667 } + ] + }, + "gun": { + "rotate": [ + { + "curve": [ 0.099, 0.27, 0.367, 1.23 ] + }, + { + "time": 0.5333, + "value": 1.23, + "curve": [ 0.665, 1.23, 0.937, -0.56 ] + }, + { + "time": 1.1333, + "value": -0.55, + "curve": [ 1.316, -0.55, 1.582, -0.21 ] + }, + { "time": 1.6667 } + ] + }, + "torso": { + "rotate": [ + { + "value": -22.88, + "curve": [ 0.099, -23.45, 0.363, -24.74 ] + }, + { + "time": 0.5333, + "value": -24.74, + "curve": [ 0.706, -24.74, 0.961, -20.97 ] + }, + { + "time": 1.1333, + "value": -20.97, + "curve": [ 1.355, -20.97, 1.567, -22.28 ] + }, + { "time": 1.6667, "value": -22.88 } + ] + }, + "neck": { + "rotate": [ + { + "value": 3.78, + "curve": [ 0.167, 3.78, 0.5, 5.45 ] + }, + { + "time": 0.6667, + "value": 5.45, + "curve": [ 0.917, 5.45, 1.417, 3.78 ] + }, + { "time": 1.6667, "value": 3.78 } + ] + }, + "torso2": { + "rotate": [ + { + "curve": [ 0.067, 0.33, 0.341, 2.54 ] + }, + { + "time": 0.5333, + "value": 2.54, + "curve": [ 0.734, 2.55, 0.982, -0.94 ] + }, + { + "time": 1.1333, + "value": -0.93, + "curve": [ 1.365, -0.91, 1.549, -0.56 ] + }, + { "time": 1.6667 } + ] + }, + "torso3": { + "rotate": [ + { + "value": -2.15, + "curve": [ 0.052, -1.9, 0.384, -0.15 ] + }, + { + "time": 0.5333, + "value": -0.14, + "curve": [ 0.762, -0.13, 0.895, -3.1 ] + }, + { + "time": 1.1333, + "value": -3.1, + "curve": [ 1.348, -3.1, 1.592, -2.46 ] + }, + { "time": 1.6667, "value": -2.15 } + ] + }, + "hair1": { + "rotate": [ + { + "curve": [ 0.067, 0, 0.213, 2.86 ] + }, + { + "time": 0.2667, + "value": 3.65, + "curve": [ 0.358, 4.99, 0.535, 7.92 ] + }, + { + "time": 0.6667, + "value": 7.92, + "curve": [ 0.809, 7.92, 1.067, 5.49 ] + }, + { + "time": 1.1333, + "value": 4.7, + "curve": [ 1.245, 3.34, 1.525, 0 ] + }, + { "time": 1.6667 } + ] + }, + "hair2": { + "rotate": [ + { + "curve": [ 0.067, 0, 0.225, -7.97 ] + }, + { + "time": 0.2667, + "value": -9.75, + "curve": [ 0.316, -11.84, 0.519, -16.66 ] + }, + { + "time": 0.6667, + "value": -16.66, + "curve": [ 0.817, -16.66, 1.029, -11.43 ] + }, + { + "time": 1.1333, + "value": -9.14, + "curve": [ 1.25, -6.56, 1.525, 0 ] + }, + { "time": 1.6667 } + ] + }, + "hair3": { + "rotate": [ + { + "curve": [ 0.1, 0, 0.3, 1.32 ] + }, + { + "time": 0.4, + "value": 1.32, + "curve": [ 0.55, 1.32, 0.866, 0.93 ] + }, + { + "time": 1, + "value": 0.73, + "curve": [ 1.189, 0.46, 1.5, 0 ] + }, + { "time": 1.6667 } + ] + }, + "hair4": { + "rotate": [ + { + "curve": [ 0.118, -0.44, 0.3, -8.52 ] + }, + { + "time": 0.4, + "value": -8.52, + "curve": [ 0.55, -8.52, 0.85, 1.96 ] + }, + { + "time": 1, + "value": 1.96, + "curve": [ 1.167, 1.96, 1.577, 0.38 ] + }, + { "time": 1.6667 } + ] + }, + "head-control": { + "translate": [ + { + "curve": [ 0.098, 1.46, 0.3, 4.49, 0.17, 0.13, 0.316, -3.28 ] + }, + { + "time": 0.4, + "x": 4.55, + "y": -5.95, + "curve": [ 0.53, 4.64, 0.776, 2.59, 0.492, -8.89, 0.668, -14.21 ] + }, + { + "time": 0.8667, + "x": 1.42, + "y": -14.26, + "curve": [ 0.966, 0.15, 1.109, -2.91, 0.994, -14.26, 1.144, -10.58 ] + }, + { + "time": 1.2333, + "x": -3.02, + "y": -8.26, + "curve": [ 1.342, -3.02, 1.568, -1.48, 1.317, -6.1, 1.558, 0 ] + }, + { "time": 1.6667 } + ] + }, + "front-shoulder": { + "translate": [ + { + "curve": [ 0.21, 0, 0.525, -1.72, 0.21, 0, 0.525, 4.08 ] + }, + { + "time": 0.8333, + "x": -1.72, + "y": 4.08, + "curve": [ 1.15, -1.72, 1.46, 0, 1.15, 4.08, 1.46, 0 ] + }, + { "time": 1.6667 } + ] + } + } + }, + "idle-turn": { + "slots": { + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + } + }, + "bones": { + "front-upper-arm": { + "rotate": [ + { + "value": -302.77, + "curve": [ 0, -406.9, 0.125, -420.87 ] + }, + { "time": 0.2667, "value": -420.87 } + ], + "translate": [ + { + "x": 2.24, + "y": -4.98, + "curve": [ 0.067, 2.24, 0.111, 0, 0.067, -4.98, 0.111, 0 ] + }, + { "time": 0.2667 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 248.56, + "curve": [ 0, 371.28, 0.062, 399.2 ] + }, + { "time": 0.1333, "value": 399.2 } + ], + "translate": [ + { + "x": -2.84, + "y": 37.28, + "curve": [ 0.033, -2.84, 0.069, 0, 0.033, 37.28, 0.069, 0 ] + }, + { "time": 0.1333 } + ] + }, + "gun": { + "rotate": [ + { + "value": -3.95, + "curve": [ 0, -10.4, 0.019, -20.43 ] + }, + { + "time": 0.0333, + "value": -20.45, + "curve": [ 0.044, -20.47, 0.125, 0 ] + }, + { "time": 0.2 } + ] + }, + "neck": { + "rotate": [ + { + "value": 17.2, + "curve": [ 0, 6.27, 0.125, 3.78 ] + }, + { "time": 0.2667, "value": 3.78 } + ] + }, + "hip": { + "translate": [ + { + "x": -2.69, + "y": -6.79, + "curve": [ 0.067, -2.69, 0.2, -11.97, 0.067, -6.79, 0.2, -23.15 ] + }, + { "time": 0.2667, "x": -11.97, "y": -23.15 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": -15.54, + "curve": [ 0, -3.08, 0.034, 18.44 ] + }, + { + "time": 0.0667, + "value": 19.02, + "curve": [ 0.108, 19.75, 0.169, 0 ] + }, + { "time": 0.2667 } + ], + "scale": [ + { + "x": 0.94, + "curve": [ 0, 0.962, 0.024, 1.237, 0, 1, 0.026, 0.947 ] + }, + { + "time": 0.0667, + "x": 1.236, + "y": 0.947, + "curve": [ 0.117, 1.235, 0.189, 1, 0.117, 0.947, 0.189, 1 ] + }, + { "time": 0.2667 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 11.75, + "curve": [ 0, -7.97, 0.017, -33.4 ] + }, + { + "time": 0.0333, + "value": -33.39, + "curve": [ 0.049, -33.37, 0.131, 0 ] + }, + { "time": 0.2 } + ] + }, + "torso": { + "rotate": [ + { + "value": -18.25, + "curve": [ 0, -10.59, 0.125, -22.88 ] + }, + { "time": 0.2667, "value": -22.88 } + ], + "scale": [ + { + "y": 1.03, + "curve": [ 0.067, 1, 0.132, 1, 0.067, 1.03, 0.132, 1 ] + }, + { "time": 0.2667 } + ] + }, + "head": { + "rotate": [ + { + "value": 5.12, + "curve": [ 0, -6.34, 0.125, -6.75 ] + }, + { "time": 0.2667, "value": -6.75 } + ], + "scale": [ + { + "y": 1.03, + "curve": [ 0.067, 1, 0.107, 1, 0.067, 1.03, 0.107, 1 ] + }, + { "time": 0.2667 } + ] + }, + "rear-foot-target": { + "translate": [ + { + "x": -58.39, + "y": 30.48, + "curve": [ 0, -7.15, 0.047, 16.62, 0, 12.71, 0.039, 0.22 ] + }, + { + "time": 0.1, + "x": 34.14, + "y": -0.19, + "curve": [ 0.136, 45.79, 0.163, 48.87, 0.133, -0.41, 0.163, 0 ] + }, + { "time": 0.2, "x": 48.87 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 6.69, + "curve": [ 0, 19.76, 0.039, 56.53 ] + }, + { + "time": 0.0667, + "value": 56.63, + "curve": [ 0.114, 56.79, 0.189, 42.46 ] + }, + { "time": 0.2667, "value": 42.46 } + ] + }, + "front-foot-target": { + "rotate": [ + { + "value": -1.85, + "curve": [ 0.014, -8.91, 0.047, -28.4 ] + }, + { + "time": 0.1, + "value": -28.89, + "curve": [ 0.144, -29.29, 0.262, -21.77 ] + }, + { "time": 0.2667 } + ], + "translate": [ + { + "x": 9.97, + "y": 0.82, + "curve": [ 0, -54.41, 0.078, -69.06, 0, 0.15, 0.078, 0 ] + }, + { "time": 0.1667, "x": -69.06 } + ] + }, + "hair3": { + "rotate": [ + { + "value": -9.01, + "curve": [ 0.044, -9.01, 0.072, 7.41 ] + }, + { + "time": 0.1333, + "value": 10.08, + "curve": [ 0.166, 11.47, 0.208, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair4": { + "rotate": [ + { + "value": -16.49, + "curve": [ 0.044, -16.49, 0.101, -5.98 ] + }, + { + "time": 0.1333, + "value": -2.95, + "curve": [ 0.162, -0.34, 0.208, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair1": { + "rotate": [ + { + "value": -3.85, + "curve": [ 0.044, -3.85, 0.072, 6.91 ] + }, + { + "time": 0.1333, + "value": 8.05, + "curve": [ 0.166, 8.65, 0.208, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair2": { + "rotate": [ + { + "value": 1.25, + "curve": [ 0.044, 1.25, 0.072, 8.97 ] + }, + { + "time": 0.1333, + "value": 8.6, + "curve": [ 0.166, 8.4, 0.208, 0 ] + }, + { "time": 0.2667 } + ] + }, + "front-thigh": { + "translate": [ + { + "x": 12.21, + "y": 1.89, + "curve": [ 0.033, 12.21, 0.1, 0, 0.033, 1.89, 0.1, 0 ] + }, + { "time": 0.1333 } + ] + }, + "rear-thigh": { + "translate": [ + { + "x": -16.11, + "y": -1.38, + "curve": [ 0.033, -16.11, 0.1, 0, 0.033, -1.38, 0.1, 0 ] + }, + { "time": 0.1333 } + ] + }, + "torso3": { + "rotate": [ + { "time": 0.2667, "value": -2.15 } + ] + }, + "head-control": { + "translate": [ + { + "x": -13.72, + "y": -34.7, + "curve": [ 0.067, -13.72, 0.2, 0, 0.067, -34.7, 0.2, 0 ] + }, + { "time": 0.2667 } + ] + }, + "front-shoulder": { + "translate": [ + { + "x": 1.13, + "y": -14.31, + "curve": [ 0.067, 1.13, 0.2, 0, 0.067, -14.31, 0.2, 0 ] + }, + { "time": 0.2667 } + ] + } + } + }, + "jump": { + "slots": { + "front-fist": { + "attachment": [ + { "name": "front-fist-open" }, + { "time": 0.1, "name": "front-fist-closed" }, + { "time": 0.8333, "name": "front-fist-open" } + ] + } + }, + "bones": { + "front-thigh": { + "rotate": [ + { + "value": 55.08, + "curve": [ 0.007, 46.66, 0.043, 26.3 ] + }, + { + "time": 0.0667, + "value": 22.84, + "curve": [ 0.1, 17.99, 0.165, 15.78 ] + }, + { + "time": 0.2333, + "value": 15.71, + "curve": [ 0.309, 15.63, 0.408, 46.67 ] + }, + { + "time": 0.5, + "value": 63.6, + "curve": [ 0.56, 74.72, 0.762, 91.48 ] + }, + { + "time": 0.9667, + "value": 91.81, + "curve": [ 1.068, 92.01, 1.096, 22.05 ] + }, + { + "time": 1.1667, + "value": 22.25, + "curve": [ 1.18, 22.29, 1.176, 56.17 ] + }, + { + "time": 1.2, + "value": 56.16, + "curve": [ 1.246, 56.15, 1.263, 54.94 ] + }, + { "time": 1.3333, "value": 55.08 } + ], + "translate": [ + { "x": -5.13, "y": 11.55 } + ] + }, + "torso": { + "rotate": [ + { + "value": -45.57, + "curve": [ 0.022, -44.61, 0.03, -39.06 ] + }, + { + "time": 0.0667, + "value": -35.29, + "curve": [ 0.12, -29.77, 0.28, -19.95 ] + }, + { + "time": 0.4333, + "value": -19.95, + "curve": [ 0.673, -19.95, 0.871, -22.38 ] + }, + { + "time": 0.9667, + "value": -27.08, + "curve": [ 1.094, -33.33, 1.176, -44.93 ] + }, + { "time": 1.3333, "value": -45.57 } + ], + "translate": [ + { "x": -3.79, "y": -0.77 } + ] + }, + "rear-thigh": { + "rotate": [ + { + "value": 12.81, + "curve": [ 0.067, 12.81, 0.242, 67.88 ] + }, + { + "time": 0.2667, + "value": 74.11, + "curve": [ 0.314, 86.02, 0.454, 92.23 ] + }, + { + "time": 0.5667, + "value": 92.24, + "curve": [ 0.753, 92.26, 0.966, 67.94 ] + }, + { + "time": 1, + "value": 61.32, + "curve": [ 1.039, 53.75, 1.218, 12.68 ] + }, + { "time": 1.3333, "value": 12.81 } + ] + }, + "rear-shin": { + "rotate": [ + { + "value": -115.64, + "curve": [ 0.067, -117.17, 0.125, -117.15 ] + }, + { + "time": 0.1667, + "value": -117.15, + "curve": [ 0.225, -117.15, 0.332, -108.76 ] + }, + { + "time": 0.4, + "value": -107.15, + "curve": [ 0.48, -105.26, 0.685, -103.49 ] + }, + { + "time": 0.7667, + "value": -101.97, + "curve": [ 0.826, -100.87, 0.919, -92.3 ] + }, + { + "time": 1, + "value": -92.28, + "curve": [ 1.113, -92.26, 1.297, -114.22 ] + }, + { "time": 1.3333, "value": -115.64 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -40.21, + "curve": [ 0.054, -35.46, 0.15, -31.12 ] + }, + { + "time": 0.2, + "value": -31.12, + "curve": [ 0.308, -31.12, 0.547, -80.12 ] + }, + { + "time": 0.6333, + "value": -96.56, + "curve": [ 0.697, -108.56, 0.797, -112.54 ] + }, + { + "time": 0.8667, + "value": -112.6, + "curve": [ 1.137, -112.84, 1.274, -49.19 ] + }, + { "time": 1.3333, "value": -40.21 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 20.54, + "curve": [ 0.054, 32.23, 0.192, 55.84 ] + }, + { + "time": 0.2333, + "value": 62.58, + "curve": [ 0.29, 71.87, 0.375, 79.28 ] + }, + { + "time": 0.4333, + "value": 79.18, + "curve": [ 0.555, 78.98, 0.684, 27.54 ] + }, + { + "time": 0.7333, + "value": 13.28, + "curve": [ 0.786, -1.85, 0.874, -24.76 ] + }, + { + "time": 1, + "value": -25.45, + "curve": [ 1.165, -26.36, 1.303, 9.1 ] + }, + { "time": 1.3333, "value": 20.54 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": -36.16, + "curve": [ 0.114, -39.59, 0.3, -45.61 ] + }, + { + "time": 0.4, + "value": -45.61, + "curve": [ 0.442, -45.61, 0.537, -21.54 ] + }, + { + "time": 0.5667, + "value": -15.4, + "curve": [ 0.592, -10.23, 0.692, 11.89 ] + }, + { + "time": 0.7333, + "value": 11.73, + "curve": [ 0.783, 11.54, 0.831, 1.8 ] + }, + { + "time": 0.8667, + "value": -5.78, + "curve": [ 0.897, -12.22, 0.901, -14.22 ] + }, + { + "time": 0.9333, + "value": -14.51, + "curve": [ 0.974, -14.89, 0.976, 10.38 ] + }, + { + "time": 1, + "value": 10.55, + "curve": [ 1.027, 10.74, 1.023, -8.44 ] + }, + { + "time": 1.0333, + "value": -8.42, + "curve": [ 1.059, -8.36, 1.074, 10.12 ] + }, + { + "time": 1.1, + "value": 10.22, + "curve": [ 1.168, 10.48, 1.27, -36.07 ] + }, + { "time": 1.3333, "value": -36.16 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 40.5, + "curve": [ 0.048, 36.1, 0.168, 20.45 ] + }, + { + "time": 0.3, + "value": 20.45, + "curve": [ 0.476, 20.45, 0.571, 33.76 ] + }, + { + "time": 0.6, + "value": 38.67, + "curve": [ 0.642, 45.8, 0.681, 57.44 ] + }, + { + "time": 0.7333, + "value": 62.91, + "curve": [ 0.829, 72.8, 0.996, 77.61 ] + }, + { + "time": 1.0333, + "value": 80.37, + "curve": [ 1.082, 83.94, 1.148, 90.6 ] + }, + { + "time": 1.2, + "value": 90.6, + "curve": [ 1.248, 90.46, 1.317, 53.07 ] + }, + { "time": 1.3333, "value": 49.06 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 28.28, + "curve": [ 0.022, 25.12, 0.187, -0.89 ] + }, + { + "time": 0.2, + "value": -2.52, + "curve": [ 0.257, -9.92, 0.372, -17.38 ] + }, + { + "time": 0.4333, + "value": -17.41, + "curve": [ 0.54, -17.47, 0.659, -16.91 ] + }, + { + "time": 0.7667, + "value": -12.1, + "curve": [ 0.907, -5.79, 1.025, 14.58 ] + }, + { + "time": 1.1, + "value": 20.58, + "curve": [ 1.191, 27.85, 1.283, 29.67 ] + }, + { "time": 1.3333, "value": 29.67 } + ] + }, + "neck": { + "rotate": [ + { + "value": 11.88, + "curve": [ 0.104, 11.82, 0.179, 11.15 ] + }, + { + "time": 0.2, + "value": 10.08, + "curve": [ 0.255, 7.29, 0.405, -8.15 ] + }, + { + "time": 0.4333, + "value": -9.35, + "curve": [ 0.508, -12.48, 0.595, -13.14 ] + }, + { + "time": 0.6667, + "value": -12.61, + "curve": [ 0.714, -12.26, 0.815, -5.57 ] + }, + { + "time": 0.8333, + "value": -4.08, + "curve": [ 0.883, -0.07, 1.045, 12.77 ] + }, + { + "time": 1.1, + "value": 15.06, + "curve": [ 1.208, 19.6, 1.279, 20.64 ] + }, + { "time": 1.3333, "value": 20.73 } + ] + }, + "head": { + "rotate": [ + { + "value": 13.14, + "curve": [ 0.008, 12.19, 0.197, -23.53 ] + }, + { + "time": 0.3333, + "value": -23.95, + "curve": [ 0.509, -23.95, 0.667, -2.66 ] + }, + { + "time": 0.7333, + "value": -2.66, + "curve": [ 0.792, -2.66, 0.908, -13.32 ] + }, + { + "time": 0.9667, + "value": -13.32, + "curve": [ 1.158, -13.11, 1.241, -1.58 ] + }, + { "time": 1.3333, "value": -1.58 } + ], + "scale": [ + { + "curve": [ 0.041, 1, 0.052, 0.962, 0.041, 1, 0.052, 1.137 ] + }, + { + "time": 0.1, + "x": 0.954, + "y": 1.137, + "curve": [ 0.202, 0.962, 0.318, 1, 0.202, 1.137, 0.252, 1.002 ] + }, + { "time": 0.4667 }, + { + "time": 1.0667, + "x": 1.002, + "curve": [ 1.092, 1.002, 1.126, 1.143, 1.092, 1, 1.128, 0.975 ] + }, + { + "time": 1.1667, + "x": 1.144, + "y": 0.973, + "curve": [ 1.204, 1.145, 1.233, 0.959, 1.206, 0.972, 1.227, 1.062 ] + }, + { + "time": 1.2667, + "x": 0.958, + "y": 1.063, + "curve": [ 1.284, 0.958, 1.292, 1.001, 1.288, 1.063, 1.288, 1.001 ] + }, + { "time": 1.3333 } + ] + }, + "hip": { + "translate": [ + { + "y": -45.46, + "curve": [ 0.042, -0.09, 0.15, 15.22, 0.031, 44.98, 0.123, 289.73 ] + }, + { + "time": 0.2, + "x": 15.22, + "y": 415.85, + "curve": [ 0.332, 15.22, 0.539, -34.52, 0.271, 532.93, 0.483, 720.5 ] + }, + { + "time": 0.7667, + "x": -34.52, + "y": 721.6, + "curve": [ 0.888, -34.52, 1.057, -21.95, 1.049, 721.17, 1.098, 379.84 ] + }, + { + "time": 1.1333, + "x": -15.67, + "y": 266.77, + "curve": [ 1.144, -14.77, 1.188, -10.53, 1.15, 213.72, 1.172, -61.32 ] + }, + { + "time": 1.2333, + "x": -6.53, + "y": -61.34, + "curve": [ 1.272, -3.22, 1.311, 0.05, 1.291, -61.36, 1.296, -44.8 ] + }, + { "time": 1.3333, "y": -45.46 } + ] + }, + "front-shin": { + "rotate": [ + { + "value": -74.19, + "curve": [ 0, -51.14, 0.042, -12.54 ] + }, + { + "time": 0.1667, + "value": -12.28, + "curve": [ 0.285, -12.32, 0.37, -74.44 ] + }, + { + "time": 0.4333, + "value": -92.92, + "curve": [ 0.498, -111.86, 0.617, -140.28 ] + }, + { + "time": 0.9, + "value": -140.84, + "curve": [ 1.004, -141.04, 1.09, -47.87 ] + }, + { + "time": 1.1, + "value": -37.44, + "curve": [ 1.108, -29.83, 1.14, -21.18 ] + }, + { + "time": 1.1667, + "value": -21.08, + "curve": [ 1.18, -21.03, 1.191, -50.65 ] + }, + { + "time": 1.2, + "value": -53.17, + "curve": [ 1.22, -58.53, 1.271, -73.38 ] + }, + { "time": 1.3333, "value": -74.19 } + ] + }, + "front-foot": { + "rotate": [ + { + "value": 7.35, + "curve": [ 0, 4.8, 0.05, -26.64 ] + }, + { + "time": 0.0667, + "value": -26.64, + "curve": [ 0.192, -26.64, 0.442, -11.77 ] + }, + { + "time": 0.5667, + "value": -11.77, + "curve": [ 0.692, -11.77, 0.942, -19.36 ] + }, + { + "time": 1.0667, + "value": -19.36, + "curve": [ 1.133, -19.36, 1.32, 3.82 ] + }, + { "time": 1.3333, "value": 7.35 } + ] + }, + "rear-foot": { + "rotate": [ + { "value": -7.14 } + ] + }, + "gun": { + "rotate": [ + { + "value": 12.36, + "curve": [ 0.022, 16.28, 0.15, 30.81 ] + }, + { + "time": 0.2, + "value": 30.81, + "curve": [ 0.258, 30.81, 0.375, 13.26 ] + }, + { + "time": 0.4333, + "value": 13.26, + "curve": [ 0.508, 13.26, 0.658, 15.05 ] + }, + { + "time": 0.7333, + "value": 14.98, + "curve": [ 0.789, 14.94, 0.828, 13.62 ] + }, + { + "time": 0.8667, + "value": 12.72, + "curve": [ 0.887, 12.25, 0.984, 9.83 ] + }, + { + "time": 1.0333, + "value": 8.6, + "curve": [ 1.045, 8.31, 1.083, 7.55 ] + }, + { + "time": 1.1333, + "value": 7.13, + "curve": [ 1.175, 6.78, 1.283, 6.18 ] + }, + { "time": 1.3333, "value": 6.18 } + ] + }, + "front-leg-target": { + "translate": [ + { "x": -13.95, "y": -30.34 } + ] + }, + "rear-leg-target": { + "rotate": [ + { "value": -38.43 } + ], + "translate": [ + { "x": 85, "y": -33.59 } + ] + }, + "front-foot-target": { + "rotate": [ + { "value": -62.54 } + ], + "translate": [ + { "x": 16.34, "y": 0.18 } + ] + }, + "rear-foot-target": { + "rotate": [ + { "value": 18.55 } + ], + "translate": [ + { "x": -176.39, "y": 134.12 } + ] + }, + "back-foot-tip": { + "rotate": [ + { + "value": -143.73, + "curve": [ 0.083, -144.24, 0.167, -74.26 ] + }, + { + "time": 0.2667, + "value": -52.76, + "curve": [ 0.342, -36.57, 0.513, -36.57 ] + }, + { + "time": 0.6333, + "value": -30.97, + "curve": [ 0.724, -26.78, 0.848, -17.06 ] + }, + { + "time": 0.9667, + "value": -16.74, + "curve": [ 1.167, -16.2, 1.272, -144.17 ] + }, + { "time": 1.3333, "value": -143.73 } + ] + }, + "front-foot-tip": { + "rotate": [ + { + "value": -1.57, + "curve": [ 0, -24.71, 0.162, -60.88 ] + }, + { + "time": 0.2667, + "value": -60.83, + "curve": [ 0.342, -60.8, 0.582, -43.5 ] + }, + { + "time": 0.7, + "value": -39.45, + "curve": [ 0.773, -36.94, 0.832, -36.78 ] + }, + { + "time": 0.9667, + "value": -36.6, + "curve": [ 1.054, -36.49, 1.092, -37.37 ] + }, + { + "time": 1.1667, + "value": -33.26, + "curve": [ 1.237, -29.37, 1.147, -1.41 ] + }, + { "time": 1.2, "value": -1.57 } + ] + }, + "hair3": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0, 13.59, 0.117, 18.21 ] + }, + { + "time": 0.1333, + "value": 18.21, + "curve": [ 0.167, 18.21, 0.26, 12.95 ] + }, + { + "time": 0.3, + "value": 11.56, + "curve": [ 0.382, 8.7, 0.55, 9.43 ] + }, + { + "time": 0.6667, + "value": 9.32, + "curve": [ 0.843, 9.15, 0.918, -7.34 ] + }, + { "time": 1.3333, "value": -6.81 } + ], + "translate": [ + { + "time": 0.6667, + "curve": [ 0.781, 0, 0.972, 16.03, 0.781, 0, 0.972, 0.92 ] + }, + { + "time": 1.1333, + "x": 16.03, + "y": 0.92, + "curve": [ 1.211, 16.03, 1.281, 0, 1.211, 0.92, 1.281, 0 ] + }, + { "time": 1.3333 } + ] + }, + "hair4": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.001, -3.88, 0.063, 16.18 ] + }, + { + "time": 0.1667, + "value": 16.14, + "curve": [ 0.242, 16.1, 0.249, 16.07 ] + }, + { + "time": 0.3333, + "value": 13.46, + "curve": [ 0.442, 10.09, 0.573, -2.2 ] + }, + { + "time": 0.6, + "value": -6.04, + "curve": [ 0.614, -8.05, 0.717, -33.44 ] + }, + { + "time": 0.7667, + "value": -33.44, + "curve": [ 0.809, -33.44, 0.835, -31.32 ] + }, + { + "time": 0.8667, + "value": -27.36, + "curve": [ 0.874, -26.47, 0.903, -14.28 ] + }, + { + "time": 0.9333, + "value": -14.47, + "curve": [ 0.956, -14.62, 0.944, -25.91 ] + }, + { + "time": 1, + "value": -25.96, + "curve": [ 1.062, -26.02, 1.051, -1.87 ] + }, + { + "time": 1.0667, + "value": -1.87, + "curve": [ 1.096, -1.87, 1.096, -16.09 ] + }, + { + "time": 1.1333, + "value": -16.08, + "curve": [ 1.169, -16.08, 1.153, -3.38 ] + }, + { + "time": 1.2, + "value": -3.38, + "curve": [ 1.234, -3.38, 1.271, -6.07 ] + }, + { "time": 1.3333, "value": -6.07 } + ] + }, + "hair2": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0, -3.17, 0.042, 16.33 ] + }, + { + "time": 0.0667, + "value": 16.33, + "curve": [ 0.21, 15.74, 0.208, -12.06 ] + }, + { + "time": 0.3333, + "value": -12.21, + "curve": [ 0.417, -12.3, 0.552, -3.98 ] + }, + { + "time": 0.6667, + "value": 1.52, + "curve": [ 0.726, 4.35, 0.817, 4.99 ] + }, + { + "time": 0.8667, + "value": 4.99, + "curve": [ 0.901, 4.99, 0.912, -29.05 ] + }, + { + "time": 0.9667, + "value": -27.45, + "curve": [ 0.987, -26.83, 1.018, -5.42 ] + }, + { + "time": 1.0667, + "value": -5.46, + "curve": [ 1.107, -5.22, 1.095, -33.51 ] + }, + { + "time": 1.1333, + "value": -33.28, + "curve": [ 1.162, -33.57, 1.192, 8.04 ] + }, + { + "time": 1.2667, + "value": 7.86, + "curve": [ 1.302, 7.77, 1.313, 2.7 ] + }, + { "time": 1.3333, "value": 2.7 } + ] + }, + "hair1": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.001, -3.12, 0.074, 14.66 ] + }, + { + "time": 0.1333, + "value": 14.66, + "curve": [ 0.188, 14.8, 0.293, 9.56 ] + }, + { + "time": 0.3333, + "value": 5.99, + "curve": [ 0.381, 1.72, 0.55, -11.11 ] + }, + { + "time": 0.6667, + "value": -11.11, + "curve": [ 0.833, -11.11, 0.933, 22.54 ] + }, + { + "time": 1.1, + "value": 22.54, + "curve": [ 1.158, 22.54, 1.275, -6.81 ] + }, + { "time": 1.3333, "value": -6.81 } + ] + }, + "torso2": { + "rotate": [ + { + "value": 4.52, + "curve": [ 0.013, 2.33, 0.092, -9.75 ] + }, + { + "time": 0.1333, + "value": -9.75, + "curve": [ 0.175, -9.75, 0.291, -1.26 ] + }, + { + "time": 0.3333, + "value": 0.96, + "curve": [ 0.359, 2.3, 0.543, 4.25 ] + }, + { + "time": 0.6, + "value": 4.68, + "curve": [ 0.683, 5.3, 0.771, 5.92 ] + }, + { + "time": 0.8333, + "value": 6.48, + "curve": [ 0.871, 6.82, 1.083, 11.37 ] + }, + { + "time": 1.1667, + "value": 11.37, + "curve": [ 1.208, 11.37, 1.317, 6.18 ] + }, + { "time": 1.3333, "value": 4.52 } + ], + "translate": [ + { + "curve": [ 0, 0, 0.082, -2.24, 0, 0, 0.082, -0.42 ] + }, + { + "time": 0.1667, + "x": -2.98, + "y": -0.56, + "curve": [ 0.232, -2.24, 0.298, 0, 0.232, -0.42, 0.298, 0 ] + }, + { "time": 0.3333, "curve": "stepped" }, + { + "time": 0.8667, + "curve": [ 0.889, 0, 0.912, 0.26, 0.889, 0, 0.912, 0.06 ] + }, + { + "time": 0.9333, + "x": 0.68, + "y": 0.23, + "curve": [ 1.016, 2.22, 1.095, 5.9, 1.023, 0.97, 1.095, 1.99 ] + }, + { + "time": 1.1667, + "x": 6.47, + "y": 2.18, + "curve": [ 1.23, 5.75, 1.286, 0, 1.23, 1.94, 1.286, 0 ] + }, + { "time": 1.3333 } + ] + }, + "torso3": { + "rotate": [ + { + "value": 4.52, + "curve": [ 0.025, 4.52, 0.075, -6.17 ] + }, + { + "time": 0.1, + "value": -6.17, + "curve": [ 0.175, -6.17, 0.381, -0.71 ] + }, + { + "time": 0.4, + "value": -0.25, + "curve": [ 0.447, 0.87, 0.775, 4.84 ] + }, + { + "time": 0.9, + "value": 4.84, + "curve": [ 1.008, 4.84, 1.225, 4.52 ] + }, + { "time": 1.3333, "value": 4.52 } + ] + }, + "head-control": { + "translate": [ + { + "curve": [ 0.138, -2.4, 0.227, -10.44, 0.123, 1.05, 0.227, 2.7 ] + }, + { + "time": 0.3667, + "x": -10.44, + "y": 2.7, + "curve": [ 0.484, -10.44, 0.585, -5.63, 0.484, 2.7, 0.629, -23.62 ] + }, + { + "time": 0.7333, + "x": -2.29, + "y": -26.61, + "curve": [ 0.818, -0.39, 0.962, 1.21, 0.858, -30.17, 0.972, -28.75 ] + }, + { + "time": 1.1, + "x": 1.25, + "y": -28.75, + "curve": [ 1.192, 1.28, 1.234, 0.98, 1.224, -28.75, 1.235, -2.15 ] + }, + { "time": 1.3333 } + ] + }, + "front-shoulder": { + "translate": [ + { + "curve": [ 0.031, -2.22, 0.065, -3.73, 0.02, -3.25, 0.065, -14.74 ] + }, + { + "time": 0.1, + "x": -3.73, + "y": -14.74, + "curve": [ 0.216, -3.73, 0.384, -0.17, 0.216, -14.74, 0.402, -12.51 ] + }, + { + "time": 0.5, + "x": 1.63, + "y": -9.51, + "curve": [ 0.632, 3.69, 0.935, 7.41, 0.585, -6.91, 0.909, 10.86 ] + }, + { + "time": 1.1, + "x": 7.45, + "y": 10.99, + "curve": [ 1.18, 7.46, 1.265, 2.86, 1.193, 11.05, 1.294, 3.38 ] + }, + { "time": 1.3333 } + ] + } + }, + "ik": { + "front-foot-ik": [ + { + "mix": 0, + "curve": [ 0.3, 0, 0.9, 1, 0.3, 0, 0.9, 0 ] + }, + { "time": 1.2 } + ], + "front-leg-ik": [ + { + "mix": 0, + "bendPositive": false, + "curve": [ 0.3, 0, 0.9, 1, 0.3, 0, 0.9, 0 ] + }, + { "time": 1.2, "bendPositive": false } + ], + "rear-foot-ik": [ + { "mix": 0 } + ], + "rear-leg-ik": [ + { "mix": 0, "bendPositive": false } + ] + }, + "events": [ + { "time": 1.2, "name": "footstep" } + ] + }, + "portal": { + "slots": { + "clipping": { + "attachment": [ + { "name": "clipping" } + ] + }, + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + }, + "mouth": { + "attachment": [ + { "time": 0.9, "name": "mouth-grind" }, + { "time": 2.2667, "name": "mouth-smile" } + ] + }, + "portal-bg": { + "attachment": [ + { "name": "portal-bg" }, + { "time": 3 } + ] + }, + "portal-flare1": { + "attachment": [ + { "time": 1.1, "name": "portal-flare1" }, + { "time": 1.1333, "name": "portal-flare2" }, + { "time": 1.1667, "name": "portal-flare3" }, + { "time": 1.2, "name": "portal-flare1" }, + { "time": 1.2333, "name": "portal-flare2" }, + { "time": 1.2667, "name": "portal-flare1" }, + { "time": 1.3333 } + ] + }, + "portal-flare2": { + "attachment": [ + { "time": 1.1, "name": "portal-flare2" }, + { "time": 1.1333, "name": "portal-flare3" }, + { "time": 1.1667, "name": "portal-flare1" }, + { "time": 1.2, "name": "portal-flare2" }, + { "time": 1.2333, "name": "portal-flare3" }, + { "time": 1.2667 } + ] + }, + "portal-flare3": { + "attachment": [ + { "time": 1.2, "name": "portal-flare3" }, + { "time": 1.2333, "name": "portal-flare2" }, + { "time": 1.2667 } + ] + }, + "portal-flare4": { + "attachment": [ + { "time": 1.2, "name": "portal-flare2" }, + { "time": 1.2333, "name": "portal-flare1" }, + { "time": 1.2667, "name": "portal-flare2" }, + { "time": 1.3333 } + ] + }, + "portal-flare5": { + "attachment": [ + { "time": 1.2333, "name": "portal-flare3" }, + { "time": 1.2667, "name": "portal-flare1" }, + { "time": 1.3333 } + ] + }, + "portal-flare6": { + "attachment": [ + { "time": 1.2667, "name": "portal-flare3" }, + { "time": 1.3333 } + ] + }, + "portal-flare7": { + "attachment": [ + { "time": 1.1333, "name": "portal-flare2" }, + { "time": 1.1667 } + ] + }, + "portal-flare8": { + "attachment": [ + { "time": 1.2, "name": "portal-flare3" }, + { "time": 1.2333, "name": "portal-flare2" }, + { "time": 1.2667 } + ] + }, + "portal-flare9": { + "attachment": [ + { "time": 1.2, "name": "portal-flare2" }, + { "time": 1.2333, "name": "portal-flare3" }, + { "time": 1.2667, "name": "portal-flare1" }, + { "time": 1.3 } + ] + }, + "portal-flare10": { + "attachment": [ + { "time": 1.2, "name": "portal-flare2" }, + { "time": 1.2333, "name": "portal-flare1" }, + { "time": 1.2667, "name": "portal-flare3" }, + { "time": 1.3 } + ] + }, + "portal-shade": { + "attachment": [ + { "name": "portal-shade" }, + { "time": 3 } + ] + }, + "portal-streaks1": { + "attachment": [ + { "name": "portal-streaks1" }, + { "time": 3 } + ] + }, + "portal-streaks2": { + "attachment": [ + { "name": "portal-streaks2" }, + { "time": 3 } + ] + } + }, + "bones": { + "portal-root": { + "translate": [ + { + "x": -458.35, + "y": 105.19, + "curve": [ 0.333, -458.22, 0.669, -457.86, 0.934, 105.19, 0.671, 105.19 ] + }, + { + "time": 1, + "x": -456.02, + "y": 105.19, + "curve": [ 1.339, -454.14, 2.208, -447.28, 1.35, 105.19, 2.05, 105.19 ] + }, + { + "time": 2.4, + "x": -439.12, + "y": 105.19, + "curve": [ 2.463, -436.44, 2.502, -432.92, 2.487, 105.19, 2.512, 105.09 ] + }, + { + "time": 2.6, + "x": -432.58, + "y": 105.09, + "curve": [ 2.784, -431.94, 2.978, -446.6, 2.772, 105.09, 2.933, 105.19 ] + }, + { "time": 3.0333, "x": -457.42, "y": 105.19 } + ], + "scale": [ + { + "x": 0.003, + "y": 0.006, + "curve": [ 0.329, 0.044, 0.347, 0.117, 0.329, 0.097, 0.37, 0.249 ] + }, + { + "time": 0.4, + "x": 0.175, + "y": 0.387, + "curve": [ 0.63, 0.619, 0.663, 0.723, 0.609, 1.338, 0.645, 1.524 ] + }, + { + "time": 0.7333, + "x": 0.724, + "y": 1.52, + "curve": [ 0.798, 0.725, 0.907, 0.647, 0.797, 1.517, 0.895, 1.424 ] + }, + { + "time": 1, + "x": 0.645, + "y": 1.426, + "curve": [ 1.095, 0.643, 1.139, 0.688, 1.089, 1.428, 1.115, 1.513 ] + }, + { + "time": 1.2333, + "x": 0.685, + "y": 1.516, + "curve": [ 1.325, 0.683, 1.508, 0.636, 1.343, 1.518, 1.467, 1.4 ] + }, + { + "time": 1.6, + "x": 0.634, + "y": 1.401, + "curve": [ 1.728, 0.631, 1.946, 0.687, 1.722, 1.402, 1.924, 1.522 ] + }, + { + "time": 2.0667, + "x": 0.688, + "y": 1.522, + "curve": [ 2.189, 0.69, 2.289, 0.649, 2.142, 1.522, 2.265, 1.417 ] + }, + { + "time": 2.4, + "x": 0.65, + "y": 1.426, + "curve": [ 2.494, 0.651, 2.504, 0.766, 2.508, 1.434, 2.543, 1.566 ] + }, + { + "time": 2.6, + "x": 0.766, + "y": 1.568, + "curve": [ 2.73, 0.765, 3.006, 0.098, 2.767, 1.564, 2.997, 0.1 ] + }, + { "time": 3.0333, "x": 0.007, "y": 0.015 } + ] + }, + "portal-streaks1": { + "rotate": [ + {}, + { "time": 3.1667, "value": 1200 } + ], + "translate": [ + { + "x": 15.15, + "curve": [ 0.162, 15.15, 0.432, 12.6, 0.162, 0, 0.432, -3.86 ] + }, + { + "time": 0.6667, + "x": 10.9, + "y": -6.44, + "curve": [ 0.794, 9.93, 0.912, 9.21, 0.794, -7.71, 0.912, -8.66 ] + }, + { + "time": 1, + "x": 9.21, + "y": -8.66, + "curve": [ 1.083, 9.21, 1.25, 21.53, 1.083, -8.66, 1.265, -4.9 ] + }, + { + "time": 1.3333, + "x": 21.53, + "y": -3.19, + "curve": [ 1.5, 21.53, 1.939, 12.3, 1.446, -0.37, 1.9, 6.26 ] + }, + { + "time": 2.0667, + "x": 11.26, + "y": 6.26, + "curve": [ 2.239, 9.85, 2.389, 9.68, 2.208, 6.26, 2.523, 0.51 ] + }, + { + "time": 2.5667, + "x": 9.39, + "y": -0.8, + "curve": [ 2.657, 9.24, 2.842, 9.21, 2.646, -3.2, 2.842, -8.91 ] + }, + { "time": 2.9333, "x": 9.21, "y": -8.91 } + ], + "scale": [ + { + "curve": [ 0.167, 1, 0.5, 1.053, 0.167, 1, 0.5, 1.053 ] + }, + { + "time": 0.6667, + "x": 1.053, + "y": 1.053, + "curve": [ 0.833, 1.053, 1.167, 0.986, 0.833, 1.053, 1.167, 0.986 ] + }, + { + "time": 1.3333, + "x": 0.986, + "y": 0.986, + "curve": [ 1.5, 0.986, 1.833, 1.053, 1.5, 0.986, 1.833, 1.053 ] + }, + { "time": 2, "x": 1.053, "y": 1.053 } + ] + }, + "portal-streaks2": { + "rotate": [ + {}, + { "time": 3.1667, "value": 600 } + ], + "translate": [ + { "x": -2.11 }, + { "time": 1, "x": -2.11, "y": 6.63 }, + { "time": 1.9333, "x": -2.11 } + ], + "scale": [ + { + "x": 1.014, + "y": 1.014, + "curve": [ 0.229, 0.909, 0.501, 0.755, 0.242, 0.892, 0.502, 0.768 ] + }, + { + "time": 0.8667, + "x": 0.745, + "y": 0.745, + "curve": [ 1.282, 0.733, 2.021, 0.699, 1.27, 0.719, 2.071, 0.709 ] + }, + { + "time": 2.2, + "x": 0.7, + "y": 0.704, + "curve": [ 2.315, 0.7, 2.421, 0.794, 2.311, 0.701, 2.485, 0.797 ] + }, + { + "time": 2.5667, + "x": 0.794, + "y": 0.794, + "curve": [ 2.734, 0.794, 2.99, 0.323, 2.714, 0.789, 3.019, 0.341 ] + }, + { "time": 3.1667, "x": 0, "y": 0 } + ] + }, + "portal-shade": { + "translate": [ + { "x": -29.68 } + ], + "scale": [ + { "x": 0.714, "y": 0.714 } + ] + }, + "portal": { + "rotate": [ + {}, + { "time": 3.1667, "value": 600 } + ] + }, + "clipping": { + "translate": [ + { "x": -476.55, "y": 2.27 } + ], + "scale": [ + { "x": 0.983, "y": 1.197 } + ] + }, + "hip": { + "rotate": [ + { + "time": 1.0667, + "value": 22.74, + "curve": [ 1.163, 18.84, 1.77, 8.77 ] + }, + { + "time": 1.9, + "value": 7.82, + "curve": [ 2.271, 5.1, 2.89, 0 ] + }, + { "time": 3.1667 } + ], + "translate": [ + { "x": -899.41, "y": 4.47, "curve": "stepped" }, + { + "time": 1.0667, + "x": -694.16, + "y": 183.28, + "curve": [ 1.091, -602.08, 1.138, -427.59, 1.115, 185.6, 1.171, 133.18 ] + }, + { + "time": 1.2333, + "x": -316.97, + "y": 55.29, + "curve": [ 1.317, -220.27, 1.512, -123.21, 1.271, 8.68, 1.461, -83.18 ] + }, + { + "time": 1.6, + "x": -95.53, + "y": -112.23, + "curve": [ 1.718, -58.25, 2.037, -22.54, 1.858, -166.17, 2.109, -31.4 ] + }, + { + "time": 2.1667, + "x": -14.82, + "y": -31.12, + "curve": [ 2.294, -7.28, 2.442, -7.2, 2.274, -30.6, 2.393, -36.76 ] + }, + { + "time": 2.6, + "x": -7.2, + "y": -36.96, + "curve": [ 2.854, -7.2, 3.071, -11.87, 2.786, -36.27, 3.082, -22.98 ] + }, + { "time": 3.1667, "x": -11.97, "y": -23.15 } + ] + }, + "rear-foot-target": { + "rotate": [ + { "time": 1.0667, "value": 41.6, "curve": "stepped" }, + { + "time": 1.2333, + "value": 41.6, + "curve": [ 1.258, 41.6, 1.379, 35.46 ] + }, + { + "time": 1.4, + "value": 30.09, + "curve": [ 1.412, 27.04, 1.433, 10.65 ] + }, + { "time": 1.4333, "value": -0.28 }, + { "time": 1.6, "value": 2.44 } + ], + "translate": [ + { "x": -899.41, "y": 4.47, "curve": "stepped" }, + { + "time": 1.0667, + "x": -591.13, + "y": 438.46, + "curve": [ 1.076, -539.77, 1.206, -268.1, 1.117, 418.44, 1.21, 333.18 ] + }, + { + "time": 1.2333, + "x": -225.28, + "y": 304.53, + "curve": [ 1.265, -175.22, 1.393, -74.21, 1.296, 226.52, 1.401, 49.61 ] + }, + { + "time": 1.4333, + "x": -52.32, + "y": 0.2, + "curve": [ 1.454, -40.85, 1.616, 40.87, 1.466, 0.17, 1.614, 0.04 ] + }, + { "time": 1.6667, "x": 45.87, "y": 0.01 }, + { "time": 1.9333, "x": 48.87 } + ] + }, + "front-foot-target": { + "rotate": [ + { + "time": 1.0667, + "value": 32.08, + "curve": [ 1.108, 32.08, 1.192, 35.16 ] + }, + { + "time": 1.2333, + "value": 35.16, + "curve": [ 1.258, 35.16, 1.317, 2.23 ] + }, + { + "time": 1.3333, + "value": -4.74, + "curve": [ 1.351, -12.14, 1.429, -34.96 ] + }, + { + "time": 1.6, + "value": -34.77, + "curve": [ 1.765, -34.58, 1.897, -17.25 ] + }, + { "time": 1.9333 } + ], + "translate": [ + { "x": -899.41, "y": 4.47, "curve": "stepped" }, + { + "time": 1.0667, + "x": -533.93, + "y": 363.75, + "curve": [ 1.074, -480.85, 1.18, -261.31, 1.094, 362.3, 1.195, 267.77 ] + }, + { + "time": 1.2333, + "x": -201.23, + "y": 199.93, + "curve": [ 1.269, -161.38, 1.294, -140.32, 1.274, 126.67, 1.308, 77.12 ] + }, + { + "time": 1.3333, + "x": -124.08, + "y": 0.2, + "curve": [ 1.426, -85.6, 1.633, -69.06, 1.45, 0.48, 1.633, 0 ] + }, + { "time": 1.7333, "x": -69.06 } + ] + }, + "torso": { + "rotate": [ + { + "time": 1.0667, + "value": 27.02, + "curve": [ 1.187, 26.86, 1.291, 7.81 ] + }, + { + "time": 1.3333, + "value": -2.62, + "curve": [ 1.402, -19.72, 1.429, -48.64 ] + }, + { + "time": 1.4667, + "value": -56.31, + "curve": [ 1.509, -64.87, 1.62, -77.14 ] + }, + { + "time": 1.7333, + "value": -77.34, + "curve": [ 1.837, -76.89, 1.895, -71.32 ] + }, + { + "time": 2, + "value": -57.52, + "curve": [ 2.104, -43.83, 2.189, -28.59 ] + }, + { + "time": 2.3, + "value": -29.03, + "curve": [ 2.413, -29.48, 2.513, -36.79 ] + }, + { + "time": 2.6667, + "value": -36.79, + "curve": [ 2.814, -36.95, 2.947, -22.88 ] + }, + { "time": 3.1667, "value": -22.88 } + ] + }, + "neck": { + "rotate": [ + { + "time": 1.0667, + "value": -3.57, + "curve": [ 1.146, -3.66, 1.15, -13.5 ] + }, + { + "time": 1.2333, + "value": -13.5, + "curve": [ 1.428, -13.5, 1.443, 11.58 ] + }, + { + "time": 1.5667, + "value": 11.42, + "curve": [ 1.658, 11.3, 1.775, 3.78 ] + }, + { + "time": 1.8667, + "value": 3.78, + "curve": [ 1.92, 3.78, 2.036, 8.01 ] + }, + { + "time": 2.1, + "value": 7.93, + "curve": [ 2.266, 7.72, 2.42, 3.86 ] + }, + { + "time": 2.5333, + "value": 3.86, + "curve": [ 2.783, 3.86, 3.004, 3.78 ] + }, + { "time": 3.1667, "value": 3.78 } + ] + }, + "head": { + "rotate": [ + { + "time": 1.0667, + "value": 16.4, + "curve": [ 1.133, 9.9, 1.207, 1.87 ] + }, + { + "time": 1.3333, + "value": 1.67, + "curve": [ 1.46, 1.56, 1.547, 47.54 ] + }, + { + "time": 1.7333, + "value": 47.55, + "curve": [ 1.897, 47.56, 2.042, 5.68 ] + }, + { + "time": 2.0667, + "value": 0.86, + "curve": [ 2.074, -0.61, 2.086, -2.81 ] + }, + { + "time": 2.1, + "value": -5.31, + "curve": [ 2.145, -13.07, 2.216, -23.65 ] + }, + { + "time": 2.2667, + "value": -23.71, + "curve": [ 2.334, -23.79, 2.426, -13.43 ] + }, + { + "time": 2.4667, + "value": -9.18, + "curve": [ 2.498, -5.91, 2.604, 2.53 ] + }, + { + "time": 2.6667, + "value": 2.52, + "curve": [ 2.738, 2.24, 2.85, -8.76 ] + }, + { + "time": 2.9333, + "value": -8.67, + "curve": [ 3.036, -8.55, 3.09, -7.09 ] + }, + { "time": 3.1667, "value": -6.75 } + ], + "scale": [ + { + "time": 1.3333, + "curve": [ 1.392, 1, 1.526, 1, 1.392, 1, 1.508, 1.043 ] + }, + { + "time": 1.5667, + "x": 0.992, + "y": 1.043, + "curve": [ 1.598, 0.985, 1.676, 0.955, 1.584, 1.043, 1.672, 1.04 ] + }, + { + "time": 1.7333, + "x": 0.954, + "y": 1.029, + "curve": [ 1.843, 0.954, 1.933, 1, 1.825, 1.013, 1.933, 1 ] + }, + { "time": 2 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "time": 0.9, + "value": 39.24, + "curve": [ 0.968, 39.93, 1.267, 85.31 ] + }, + { + "time": 1.4667, + "value": 112.27, + "curve": [ 1.555, 124.24, 1.576, 126.44 ] + }, + { + "time": 1.6333, + "value": 126.44, + "curve": [ 1.782, 126.44, 1.992, 94.55 ] + }, + { + "time": 2.1, + "value": 79.96, + "curve": [ 2.216, 64.26, 2.407, 34.36 ] + }, + { + "time": 2.5667, + "value": 33.38, + "curve": [ 2.815, 31.87, 3.1, 39.2 ] + }, + { "time": 3.1667, "value": 39.2 } + ] + }, + "back-foot-tip": { + "rotate": [ + { + "time": 1.0667, + "value": 56.07, + "curve": [ 1.138, 59.21, 1.192, 59.65 ] + }, + { + "time": 1.2333, + "value": 59.46, + "curve": [ 1.295, 59.17, 1.45, 22.54 ] + }, + { "time": 1.4667, "value": -0.84 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "time": 1.0667, + "value": 118.03, + "curve": [ 1.075, 93.64, 1.358, -34.03 ] + }, + { + "time": 1.6667, + "value": -33.94, + "curve": [ 1.808, -33.89, 1.879, -25 ] + }, + { + "time": 1.9667, + "value": -25.19, + "curve": [ 2.09, -25.46, 2.312, -34.58 ] + }, + { + "time": 2.3667, + "value": -38.36, + "curve": [ 2.465, -45.18, 2.557, -60.1 ] + }, + { + "time": 2.8333, + "value": -61.1, + "curve": [ 2.843, -61.06, 3.16, -60.87 ] + }, + { "time": 3.1667, "value": -60.87 } + ] + }, + "front-bracer": { + "rotate": [ + { + "time": 1.0667, + "value": 0.66, + "curve": [ 1.108, 0.66, 1.221, 44.95 ] + }, + { + "time": 1.2333, + "value": 49.25, + "curve": [ 1.263, 59.42, 1.342, 68.06 ] + }, + { + "time": 1.3667, + "value": 68.34, + "curve": [ 1.409, 68.8, 1.476, 4.9 ] + }, + { + "time": 1.5, + "value": -2.05, + "curve": [ 1.529, -10.3, 1.695, -15.95 ] + }, + { + "time": 1.7333, + "value": -17.38, + "curve": [ 1.807, -20.1, 1.878, -21.19 ] + }, + { + "time": 1.9333, + "value": -21.08, + "curve": [ 2.073, -20.8, 2.146, -7.63 ] + }, + { + "time": 2.1667, + "value": -3.64, + "curve": [ 2.186, 0.12, 2.275, 15.28 ] + }, + { + "time": 2.3333, + "value": 21.78, + "curve": [ 2.392, 28.31, 2.575, 37.66 ] + }, + { + "time": 2.7, + "value": 39.43, + "curve": [ 2.947, 42.93, 3.02, 42.46 ] + }, + { "time": 3.1667, "value": 42.46 } + ] + }, + "front-thigh": { + "translate": [ + { "time": 1.1, "x": -6.41, "y": 18.23, "curve": "stepped" }, + { "time": 1.1333, "x": -6.41, "y": 18.23 }, + { "time": 1.2, "x": 1.61, "y": 3.66 }, + { "time": 1.2333, "x": 4.5, "y": -3.15 }, + { "time": 1.3667, "x": -3.79, "y": 2.94 }, + { "time": 1.4, "x": -8.37, "y": 8.72 }, + { "time": 1.4333, "x": -11.26, "y": 16.99 }, + { "time": 1.4667, "x": -9.89, "y": 24.73, "curve": "stepped" }, + { "time": 1.8667, "x": -9.89, "y": 24.73 }, + { "time": 2.1 } + ] + }, + "front-foot-tip": { + "rotate": [ + { "time": 1.0667, "value": 42.55, "curve": "stepped" }, + { "time": 1.1333, "value": 42.55 }, + { "time": 1.2333, "value": 17.71 }, + { "time": 1.3667, "value": 3.63 }, + { "time": 1.4333 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "time": 1.0667, + "value": 108.71, + "curve": [ 1.082, 108.29, 1.437, 50.73 ] + }, + { + "time": 1.5667, + "value": 24.87, + "curve": [ 1.62, 14.2, 1.66, -11.74 ] + }, + { + "time": 1.7333, + "value": -11.74, + "curve": [ 1.961, -11.73, 2.172, 1.66 ] + }, + { + "time": 2.2667, + "value": 7.88, + "curve": [ 2.331, 12.13, 2.439, 18.65 ] + }, + { + "time": 2.5333, + "value": 18.72, + "curve": [ 2.788, 18.91, 3.145, -0.3 ] + }, + { "time": 3.1667 } + ] + }, + "front-fist": { + "rotate": [ + { + "time": 1.1, + "value": 6.32, + "curve": [ 1.11, 3.31, 1.153, -5.07 ] + }, + { + "time": 1.2333, + "value": -5.13, + "curve": [ 1.311, -5.19, 1.364, 34.65 ] + }, + { + "time": 1.4667, + "value": 34.53, + "curve": [ 1.574, 34.41, 1.547, -55.78 ] + }, + { + "time": 1.8667, + "value": -54.7, + "curve": [ 1.947, -54.7, 2.03, -53.94 ] + }, + { + "time": 2.1333, + "value": -42.44, + "curve": [ 2.215, -33.42, 2.358, -4.43 ] + }, + { + "time": 2.4, + "value": 0.03, + "curve": [ 2.444, 4.66, 2.536, 8.2 ] + }, + { + "time": 2.6333, + "value": 8.2, + "curve": [ 2.733, 8.19, 2.804, -0.67 ] + }, + { + "time": 2.9, + "value": -0.82, + "curve": [ 3.127, -1.16, 3.093, 0 ] + }, + { "time": 3.1667 } + ] + }, + "gun": { + "rotate": [ + { + "time": 1.2667, + "curve": [ 1.35, 0, 1.549, 7.49 ] + }, + { + "time": 1.6, + "value": 9.5, + "curve": [ 1.663, 12.02, 1.846, 19.58 ] + }, + { + "time": 1.9333, + "value": 19.43, + "curve": [ 1.985, 19.4, 2.057, 2.98 ] + }, + { + "time": 2.2, + "value": 2.95, + "curve": [ 2.304, 3.55, 2.458, 10.8 ] + }, + { + "time": 2.5, + "value": 10.8, + "curve": [ 2.642, 10.8, 2.873, -2.54 ] + }, + { + "time": 2.9333, + "value": -2.55, + "curve": [ 3.09, -2.57, 3.08, 0 ] + }, + { "time": 3.1667 } + ] + }, + "hair2": { + "rotate": [ + { + "time": 1.0667, + "value": 26.19, + "curve": [ 1.158, 26.19, 1.368, 26 ] + }, + { + "time": 1.4333, + "value": 24.43, + "curve": [ 1.534, 22.03, 2, -29.14 ] + }, + { + "time": 2.2, + "value": -29.14, + "curve": [ 2.292, -29.14, 2.475, 6.71 ] + }, + { + "time": 2.5667, + "value": 6.71, + "curve": [ 2.675, 6.71, 2.814, -5.06 ] + }, + { + "time": 2.9, + "value": -5.06, + "curve": [ 2.973, -5.06, 3.123, 0 ] + }, + { "time": 3.1667 } + ] + }, + "hair4": { + "rotate": [ + { + "time": 1.0667, + "value": 5.21, + "curve": [ 1.108, 5.21, 1.192, 26.19 ] + }, + { + "time": 1.2333, + "value": 26.19, + "curve": [ 1.317, 26.19, 1.483, 10.63 ] + }, + { + "time": 1.5667, + "value": 10.63, + "curve": [ 1.627, 10.63, 1.642, 17.91 ] + }, + { + "time": 1.7, + "value": 17.94, + "curve": [ 1.761, 17.97, 1.774, 8.22 ] + }, + { + "time": 1.8, + "value": 3.33, + "curve": [ 1.839, -4.21, 1.95, -22.67 ] + }, + { + "time": 2, + "value": -22.67, + "curve": [ 2.025, -22.67, 2.123, -21.86 ] + }, + { + "time": 2.1667, + "value": -18.71, + "curve": [ 2.228, -14.31, 2.294, -0.3 ] + }, + { + "time": 2.3667, + "value": 6.36, + "curve": [ 2.433, 12.45, 2.494, 19.21 ] + }, + { + "time": 2.6, + "value": 19.21, + "curve": [ 2.729, 19.21, 2.854, 6.75 ] + }, + { + "time": 2.9333, + "value": 4.62, + "curve": [ 3.09, 0.45, 3.062, 0 ] + }, + { "time": 3.1667 } + ] + }, + "hair3": { + "rotate": [ + { + "time": 1.4333, + "curve": [ 1.45, 0, 1.452, 11.29 ] + }, + { + "time": 1.5, + "value": 11.21, + "curve": [ 1.596, 11.06, 1.573, -14.17 ] + }, + { + "time": 1.7333, + "value": -20.4, + "curve": [ 1.851, -24.98, 1.943, -28.45 ] + }, + { + "time": 2.2, + "value": -28.75, + "curve": [ 2.317, -28.75, 2.55, 7.04 ] + }, + { + "time": 2.6667, + "value": 7.04, + "curve": [ 2.792, 7.04, 2.885, -5.19 ] + }, + { + "time": 2.9667, + "value": -5.19, + "curve": [ 3.037, -5.19, 3.096, 0 ] + }, + { "time": 3.1667 } + ] + }, + "hair1": { + "rotate": [ + { + "time": 1.2333, + "curve": [ 1.283, 0, 1.349, 3.99 ] + }, + { + "time": 1.4333, + "value": 6.58, + "curve": [ 1.497, 8.54, 1.683, 9.35 ] + }, + { + "time": 1.7667, + "value": 9.35, + "curve": [ 1.825, 9.35, 1.945, -8.71 ] + }, + { + "time": 2, + "value": -11.15, + "curve": [ 2.058, -13.71, 2.2, -14.97 ] + }, + { + "time": 2.2667, + "value": -14.97, + "curve": [ 2.367, -14.97, 2.567, 18.77 ] + }, + { + "time": 2.6667, + "value": 18.77, + "curve": [ 2.733, 18.77, 2.817, 8.29 ] + }, + { + "time": 2.8667, + "value": 6.51, + "curve": [ 2.988, 2.17, 3.058, 0 ] + }, + { "time": 3.1667 } + ] + }, + "flare1": { + "rotate": [ + { "time": 1.1, "value": 8.2 } + ], + "translate": [ + { "time": 1.1, "x": -19.97, "y": 149.68 }, + { "time": 1.2, "x": 3.85, "y": 152.43 }, + { "time": 1.2333, "x": -15.42, "y": 152.29 } + ], + "scale": [ + { + "time": 1.1, + "x": 0.805, + "y": 0.805, + "curve": [ 1.119, 0.763, 1.16, 1.162, 1.117, 0.805, 1.15, 0.605 ] + }, + { + "time": 1.1667, + "x": 1.279, + "y": 0.605, + "curve": [ 1.177, 1.47, 1.192, 2.151, 1.175, 0.605, 1.192, 0.911 ] + }, + { + "time": 1.2, + "x": 2.151, + "y": 0.911, + "curve": [ 1.208, 2.151, 1.231, 1.668, 1.208, 0.911, 1.227, 0.844 ] + }, + { + "time": 1.2333, + "x": 1.608, + "y": 0.805, + "curve": [ 1.249, 1.205, 1.283, 0.547, 1.254, 0.685, 1.283, 0.416 ] + }, + { "time": 1.3, "x": 0.547, "y": 0.416 } + ], + "shear": [ + { "time": 1.1, "y": 4.63 }, + { "time": 1.2333, "x": -5.74, "y": 4.63 } + ] + }, + "flare2": { + "rotate": [ + { "time": 1.1, "value": 12.29 } + ], + "translate": [ + { "time": 1.1, "x": -8.63, "y": 132.96 }, + { "time": 1.2, "x": 4.35, "y": 132.93 } + ], + "scale": [ + { "time": 1.1, "x": 0.864, "y": 0.864 }, + { "time": 1.1667, "x": 0.945, "y": 0.945 }, + { "time": 1.2, "x": 1.511, "y": 1.081 } + ], + "shear": [ + { "time": 1.1, "y": 24.03 } + ] + }, + "flare3": { + "rotate": [ + { "time": 1.1667, "value": 2.88 } + ], + "translate": [ + { "time": 1.1667, "x": 3.24, "y": 114.81 } + ], + "scale": [ + { "time": 1.1667, "x": 0.668, "y": 0.668 } + ], + "shear": [ + { "time": 1.1667, "y": 38.59 } + ] + }, + "flare4": { + "rotate": [ + { "time": 1.1667, "value": -8.64 } + ], + "translate": [ + { "time": 1.1667, "x": -3.82, "y": 194.06 }, + { "time": 1.2667, "x": -1.82, "y": 198.47, "curve": "stepped" }, + { "time": 1.3, "x": -1.94, "y": 187.81 } + ], + "scale": [ + { "time": 1.1667, "x": 0.545, "y": 0.545 }, + { "time": 1.2667, "x": 0.757, "y": 0.757 } + ], + "shear": [ + { "time": 1.1667, "x": 7.42, "y": -22.04 } + ] + }, + "flare5": { + "translate": [ + { "time": 1.2, "x": -11.17, "y": 176.42 }, + { "time": 1.2333, "x": -8.56, "y": 179.04, "curve": "stepped" }, + { "time": 1.3, "x": -14.57, "y": 168.69 } + ], + "scale": [ + { "time": 1.2333, "x": 1.146 }, + { "time": 1.3, "x": 0.703, "y": 0.61 } + ], + "shear": [ + { "time": 1.2, "x": 6.9 } + ] + }, + "flare6": { + "rotate": [ + { "time": 1.2333, "value": -5.36 }, + { "time": 1.2667, "value": -0.54 } + ], + "translate": [ + { "time": 1.2333, "x": 14.52, "y": 204.67 }, + { "time": 1.2667, "x": 19.16, "y": 212.9, "curve": "stepped" }, + { "time": 1.3, "x": 9.23, "y": 202.85 } + ], + "scale": [ + { "time": 1.2333, "x": 0.777, "y": 0.49 }, + { "time": 1.2667, "x": 0.777, "y": 0.657 }, + { "time": 1.3, "x": 0.475, "y": 0.401 } + ] + }, + "flare7": { + "rotate": [ + { "time": 1.1, "value": 5.98 }, + { "time": 1.1333, "value": 32.82 } + ], + "translate": [ + { "time": 1.1, "x": -6.34, "y": 112.98 }, + { "time": 1.1333, "x": 2.66, "y": 111.6 } + ], + "scale": [ + { "time": 1.1, "x": 0.588, "y": 0.588 } + ], + "shear": [ + { "time": 1.1333, "x": -19.93 } + ] + }, + "flare8": { + "rotate": [ + { "time": 1.2333, "value": -6.85 } + ], + "translate": [ + { "time": 1.1667, "x": 66.67, "y": 125.52, "curve": "stepped" }, + { "time": 1.2, "x": 58.24, "y": 113.53, "curve": "stepped" }, + { "time": 1.2333, "x": 40.15, "y": 114.69 } + ], + "scale": [ + { "time": 1.1667, "x": 1.313, "y": 1.203 }, + { "time": 1.2333, "x": 1.038, "y": 0.95 } + ], + "shear": [ + { "time": 1.2, "y": -13.01 } + ] + }, + "flare9": { + "rotate": [ + { "time": 1.1667, "value": 2.9 } + ], + "translate": [ + { "time": 1.1667, "x": 28.45, "y": 151.35, "curve": "stepped" }, + { "time": 1.2, "x": 48.8, "y": 191.09, "curve": "stepped" }, + { "time": 1.2333, "x": 52, "y": 182.52, "curve": "stepped" }, + { "time": 1.2667, "x": 77.01, "y": 195.96 } + ], + "scale": [ + { "time": 1.1667, "x": 0.871, "y": 1.073 }, + { "time": 1.2, "x": 0.927, "y": 0.944 }, + { "time": 1.2333, "x": 1.165, "y": 1.336 } + ], + "shear": [ + { "time": 1.1667, "x": 7.95, "y": 25.48 } + ] + }, + "flare10": { + "rotate": [ + { "time": 1.1667, "value": 2.18 } + ], + "translate": [ + { "time": 1.1667, "x": 55.64, "y": 137.64, "curve": "stepped" }, + { "time": 1.2, "x": 90.49, "y": 151.07, "curve": "stepped" }, + { "time": 1.2333, "x": 114.06, "y": 153.05, "curve": "stepped" }, + { "time": 1.2667, "x": 90.44, "y": 164.61 } + ], + "scale": [ + { "time": 1.1667, "x": 2.657, "y": 0.891 }, + { "time": 1.2, "x": 3.314, "y": 1.425 }, + { "time": 1.2333, "x": 2.871, "y": 0.924 }, + { "time": 1.2667, "x": 2.317, "y": 0.775 } + ], + "shear": [ + { "time": 1.1667, "x": -1.35 } + ] + }, + "torso2": { + "rotate": [ + { + "time": 1, + "curve": [ 1.117, 0, 1.255, 24.94 ] + }, + { + "time": 1.4, + "value": 24.94, + "curve": [ 1.477, 24.94, 1.59, -17.62 ] + }, + { + "time": 1.6333, + "value": -19.48, + "curve": [ 1.717, -23.1, 1.784, -26.12 ] + }, + { + "time": 1.9333, + "value": -26.14, + "curve": [ 2.067, -26.15, 2.158, 4.3 ] + }, + { + "time": 2.3, + "value": 4.22, + "curve": [ 2.45, 4.13, 2.579, -1.76 ] + }, + { + "time": 2.7333, + "value": -1.8, + "curve": [ 2.816, -1.82, 2.857, -2.94 ] + }, + { + "time": 2.9333, + "value": -2.99, + "curve": [ 3.056, -3.08, 3.09, 0 ] + }, + { "time": 3.1667 } + ] + }, + "torso3": { + "rotate": [ + { + "time": 1.3, + "curve": [ 1.352, 0, 1.408, 6.47 ] + }, + { + "time": 1.4667, + "value": 6.43, + "curve": [ 1.55, 6.39, 1.723, -5.05 ] + }, + { + "time": 1.7333, + "value": -5.53, + "curve": [ 1.782, -7.72, 1.843, -16.94 ] + }, + { + "time": 1.9667, + "value": -16.86, + "curve": [ 2.111, -16.78, 2.259, -3.97 ] + }, + { + "time": 2.4, + "value": -2.43, + "curve": [ 2.525, -1.12, 2.639, -0.5 ] + }, + { + "time": 2.7333, + "value": -0.49, + "curve": [ 2.931, -0.47, 2.999, -2.15 ] + }, + { "time": 3.1667, "value": -2.15 } + ] + }, + "head-control": { + "translate": [ + { + "time": 1.2333, + "curve": [ 1.25, 0, 1.474, 6.89, 1.25, 0, 1.496, 0.98 ] + }, + { + "time": 1.6667, + "x": 11.99, + "y": -6.42, + "curve": [ 1.743, 14.01, 1.86, 14.33, 1.785, -11.55, 1.86, -27.1 ] + }, + { + "time": 1.9667, + "x": 13.91, + "y": -26.88, + "curve": [ 2.074, 13.49, 2.244, 8.13, 2.074, -26.65, 2.215, -21.78 ] + }, + { + "time": 2.3, + "x": 6.07, + "y": -16.64, + "curve": [ 2.416, 1.84, 2.497, -1.41, 2.417, -9.57, 2.526, -1.72 ] + }, + { + "time": 2.5667, + "x": -3.78, + "y": -1.71, + "curve": [ 2.661, -6.98, 2.76, -8.76, 2.692, -1.68, 2.821, -15.75 ] + }, + { + "time": 2.9, + "x": -8.32, + "y": -16.7, + "curve": [ 2.962, -8.12, 3.082, -0.04, 2.958, -17.39, 3.089, 0 ] + }, + { "time": 3.1667 } + ] + }, + "front-shoulder": { + "translate": [ + { + "time": 1.3333, + "curve": [ 1.488, 0, 1.717, 0.21, 1.488, 0, 1.688, -30.29 ] + }, + { + "time": 1.9, + "x": 0.83, + "y": -30.29, + "curve": [ 2.078, 1.43, 2.274, 2.88, 2.071, -30.29, 2.289, 4.48 ] + }, + { + "time": 2.4333, + "x": 2.89, + "y": 4.59, + "curve": [ 2.604, 2.89, 2.677, -0.68, 2.57, 4.7, 2.694, -2.43 ] + }, + { + "time": 2.7667, + "x": -0.67, + "y": -2.47, + "curve": [ 2.866, -0.67, 2.986, -0.07, 2.882, -2.47, 3.036, -0.06 ] + }, + { "time": 3.1667 } + ] + } + }, + "ik": { + "rear-leg-ik": [ + { "time": 3.1667, "softness": 10, "bendPositive": false } + ] + } + }, + "run": { + "slots": { + "mouth": { + "attachment": [ + { "name": "mouth-grind" } + ] + } + }, + "bones": { + "front-thigh": { + "translate": [ + { + "x": -5.14, + "y": 11.13, + "curve": [ 0.033, -7.77, 0.112, -9.03, 0.034, 11.13, 0.108, 9.74 ] + }, + { + "time": 0.1667, + "x": -9.03, + "y": 7.99, + "curve": [ 0.23, -9.05, 0.314, -1.34, 0.236, 5.93, 0.28, 3.22 ] + }, + { + "time": 0.3333, + "x": 0.41, + "y": 3.19, + "curve": [ 0.352, 2.09, 0.449, 11.16, 0.384, 3.16, 0.449, 4.98 ] + }, + { + "time": 0.5, + "x": 11.17, + "y": 6.76, + "curve": [ 0.571, 10.79, 0.621, -1.83, 0.542, 8.21, 0.625, 11.13 ] + }, + { "time": 0.6667, "x": -5.14, "y": 11.13 } + ] + }, + "torso": { + "rotate": [ + { + "value": -37.66, + "curve": [ 0.034, -37.14, 0.107, -36.21 ] + }, + { + "time": 0.1333, + "value": -36.21, + "curve": [ 0.158, -36.21, 0.209, -38.8 ] + }, + { + "time": 0.2333, + "value": -38.79, + "curve": [ 0.259, -38.78, 0.313, -38.03 ] + }, + { + "time": 0.3333, + "value": -37.66, + "curve": [ 0.357, -37.21, 0.4, -36.21 ] + }, + { + "time": 0.4333, + "value": -36.21, + "curve": [ 0.458, -36.21, 0.539, -38.8 ] + }, + { + "time": 0.5667, + "value": -38.8, + "curve": [ 0.592, -38.8, 0.645, -38 ] + }, + { "time": 0.6667, "value": -37.66 } + ] + }, + "rear-thigh": { + "translate": [ + { + "x": -16.41, + "y": 1.55, + "curve": [ 0.013, -15.67, 0.183, -8.55, 0.03, 2.39, 0.183, 6.17 ] + }, + { + "time": 0.2333, + "x": -8.55, + "y": 6.17, + "curve": [ 0.308, -8.55, 0.492, -19.75, 0.308, 6.17, 0.492, 0.61 ] + }, + { + "time": 0.5667, + "x": -19.75, + "y": 0.61, + "curve": [ 0.592, -19.75, 0.641, -18.06, 0.592, 0.61, 0.632, 0.78 ] + }, + { "time": 0.6667, "x": -16.41, "y": 1.55 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -39.03, + "curve": [ 0.051, -0.1, 0.145, 88.36 ] + }, + { + "time": 0.2333, + "value": 88.36, + "curve": [ 0.28, 88.76, 0.324, 59.52 ] + }, + { + "time": 0.3333, + "value": 51.13, + "curve": [ 0.358, 30.2, 0.445, -74.91 ] + }, + { + "time": 0.5667, + "value": -75.82, + "curve": [ 0.599, -76.06, 0.642, -55.72 ] + }, + { "time": 0.6667, "value": -39.03 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 20.54, + "curve": [ 0.052, 11.42, 0.089, 0.13 ] + }, + { + "time": 0.1333, + "value": 0.15, + "curve": [ 0.186, 0.17, 0.221, 26.29 ] + }, + { + "time": 0.2333, + "value": 32.37, + "curve": [ 0.247, 39.19, 0.286, 61.45 ] + }, + { + "time": 0.3333, + "value": 61.58, + "curve": [ 0.371, 61.69, 0.42, 55.79 ] + }, + { "time": 0.4667, "value": 49.68 }, + { "time": 0.6667, "value": 20.54 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": -36.16, + "curve": [ 0.014, -38.8, 0.036, -43.27 ] + }, + { + "time": 0.0667, + "value": -43.37, + "curve": [ 0.102, -43.49, 0.182, -28.46 ] + }, + { + "time": 0.2, + "value": -23.04, + "curve": [ 0.23, -13.87, 0.264, 3.86 ] + }, + { + "time": 0.3333, + "value": 3.7, + "curve": [ 0.38, 3.64, 0.535, -16.22 ] + }, + { "time": 0.5667, "value": -21.29 }, + { "time": 0.6667, "value": -36.16 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 40.5, + "curve": [ 0.028, 23.74, 0.128, -79.86 ] + }, + { + "time": 0.2333, + "value": -79.87, + "curve": [ 0.38, -79.88, 0.403, 63.25 ] + }, + { + "time": 0.5667, + "value": 64.13, + "curve": [ 0.607, 64.35, 0.644, 53.1 ] + }, + { "time": 0.6667, "value": 40.5 } + ], + "translate": [ + { + "x": -3.79, + "y": -0.77, + "curve": [ 0.044, -4.58, 0.169, -5.48, 0.044, 0.93, 0.169, 2.85 ] + }, + { + "time": 0.2333, + "x": -5.48, + "y": 2.85, + "curve": [ 0.346, -5.48, 0.475, -2.68, 0.346, 2.85, 0.475, -3.13 ] + }, + { + "time": 0.5667, + "x": -2.68, + "y": -3.13, + "curve": [ 0.611, -2.68, 0.642, -3.34, 0.611, -3.13, 0.642, -1.73 ] + }, + { "time": 0.6667, "x": -3.79, "y": -0.77 } + ] + }, + "rear-bracer": { + "rotate": [ + { "value": 28.28 }, + { + "time": 0.2333, + "value": -11.12, + "curve": [ 0.252, -14.12, 0.297, -19.37 ] + }, + { + "time": 0.3333, + "value": -19.38, + "curve": [ 0.435, -19.41, 0.522, 38.96 ] + }, + { + "time": 0.5667, + "value": 38.87, + "curve": [ 0.619, 38.76, 0.644, 32.01 ] + }, + { "time": 0.6667, "value": 28.28 } + ] + }, + "neck": { + "rotate": [ + { + "value": 11.88, + "curve": [ 0.024, 11.4, 0.075, 9.74 ] + }, + { + "time": 0.1, + "value": 9.74, + "curve": [ 0.125, 9.74, 0.208, 13.36 ] + }, + { + "time": 0.2333, + "value": 13.36, + "curve": [ 0.258, 13.36, 0.321, 12.2 ] + }, + { + "time": 0.3333, + "value": 11.88, + "curve": [ 0.365, 11.06, 0.408, 9.72 ] + }, + { + "time": 0.4333, + "value": 9.72, + "curve": [ 0.458, 9.72, 0.542, 13.36 ] + }, + { + "time": 0.5667, + "value": 13.36, + "curve": [ 0.592, 13.36, 0.636, 12.48 ] + }, + { "time": 0.6667, "value": 11.88 } + ] + }, + "head": { + "rotate": [ + { + "value": 13.14, + "curve": [ 0.02, 11.99, 0.039, 8.94 ] + }, + { + "time": 0.0667, + "value": 8.93, + "curve": [ 0.122, 8.9, 0.232, 15.8 ] + }, + { + "time": 0.2667, + "value": 15.81, + "curve": [ 0.325, 15.82, 0.357, 8.95 ] + }, + { + "time": 0.4, + "value": 8.93, + "curve": [ 0.444, 8.91, 0.568, 15.8 ] + }, + { + "time": 0.6, + "value": 15.77, + "curve": [ 0.632, 15.74, 0.649, 14.05 ] + }, + { "time": 0.6667, "value": 13.14 } + ], + "scale": [ + { + "curve": [ 0.014, 0.996, 0.068, 0.991, 0.027, 1.005, 0.083, 1.012 ] + }, + { + "time": 0.1, + "x": 0.991, + "y": 1.012, + "curve": [ 0.128, 0.991, 0.205, 1.018, 0.128, 1.012, 0.197, 0.988 ] + }, + { + "time": 0.2333, + "x": 1.018, + "y": 0.988, + "curve": [ 0.272, 1.018, 0.305, 1.008, 0.262, 0.988, 0.311, 0.995 ] + }, + { + "time": 0.3333, + "curve": [ 0.351, 0.995, 0.417, 0.987, 0.359, 1.006, 0.417, 1.013 ] + }, + { + "time": 0.4333, + "x": 0.987, + "y": 1.013, + "curve": [ 0.467, 0.987, 0.533, 1.02, 0.467, 1.013, 0.533, 0.989 ] + }, + { + "time": 0.5667, + "x": 1.02, + "y": 0.989, + "curve": [ 0.592, 1.02, 0.652, 1.004, 0.592, 0.989, 0.644, 0.996 ] + }, + { "time": 0.6667 } + ] + }, + "gun": { + "rotate": [ + { + "value": 12.36, + "curve": [ 0.022, 16.28, 0.087, 20.25 ] + }, + { + "time": 0.1333, + "value": 20.19, + "curve": [ 0.168, 20.32, 0.254, -8.82 ] + }, + { + "time": 0.2667, + "value": -11.88, + "curve": [ 0.291, -17.91, 0.344, -24.11 ] + }, + { + "time": 0.4, + "value": -23.88, + "curve": [ 0.448, -23.69, 0.533, -15.47 ] + }, + { "time": 0.5667, "value": -8.69 }, + { "time": 0.6667, "value": 12.36 } + ] + }, + "hip": { + "rotate": [ + { "value": -8.24 } + ], + "translate": [ + { + "x": -3.6, + "y": -34.1, + "curve": [ 0.042, -3.84, 0.118, 7.62, 0.042, -33.74, 0.112, 20.55 ] + }, + { + "time": 0.1667, + "x": 7.61, + "y": 20.36, + "curve": [ 0.194, 7.6, 0.21, 5.06, 0.204, 20.65, 0.217, -8.69 ] + }, + { + "time": 0.2333, + "x": 1.68, + "y": -18.48, + "curve": [ 0.279, -4.99, 0.297, -5.64, 0.254, -31.08, 0.292, -34.55 ] + }, + { + "time": 0.3333, + "x": -5.76, + "y": -35, + "curve": [ 0.379, -5.9, 0.451, 6.8, 0.384, -35.56, 0.428, 17.6 ] + }, + { + "time": 0.5, + "x": 6.61, + "y": 17.01, + "curve": [ 0.536, 6.47, 0.545, 3.56, 0.533, 16.75, 0.548, -8.71 ] + }, + { + "time": 0.5667, + "x": 0.35, + "y": -18.81, + "curve": [ 0.597, -4.07, 0.642, -3.45, 0.584, -28.58, 0.642, -34.32 ] + }, + { "time": 0.6667, "x": -3.6, "y": -34.1 } + ] + }, + "front-foot-target": { + "rotate": [ + { + "value": -62.54, + "curve": [ 0.015, -74.19, 0.056, -103.19 ] + }, + { + "time": 0.0667, + "value": -111.08, + "curve": [ 0.092, -129.44, 0.189, -146.55 ] + }, + { + "time": 0.2333, + "value": -146.32, + "curve": [ 0.285, -146.06, 0.32, -125.1 ] + }, + { "time": 0.3333, "value": -117.24 }, + { + "time": 0.5, + "value": -35.07, + "curve": [ 0.522, -28.64, 0.546, -24.84 ] + }, + { + "time": 0.5667, + "value": -24.9, + "curve": [ 0.595, -25, 0.623, -40.82 ] + }, + { "time": 0.6667, "value": -62.54 } + ], + "translate": [ + { "x": 16.34, "y": 0.18 }, + { + "time": 0.0667, + "x": -101.43, + "y": 8.04, + "curve": [ 0.085, -131.35, 0.129, -207.69, 0.08, 14.9, 0.124, 113.28 ] + }, + { + "time": 0.1667, + "x": -207.92, + "y": 145.81, + "curve": [ 0.196, -208.13, 0.21, -202.91, 0.186, 160.26, 0.206, 163.48 ] + }, + { + "time": 0.2333, + "x": -189.94, + "y": 163.85, + "curve": [ 0.27, -169.94, 0.31, -126.19, 0.269, 164.35, 0.316, 85.97 ] + }, + { + "time": 0.3333, + "x": -90.56, + "y": 78.57, + "curve": [ 0.355, -57.99, 0.376, -29.14, 0.35, 71.55, 0.376, 66.4 ] + }, + { + "time": 0.4, + "x": 2.87, + "y": 66.38, + "curve": [ 0.412, 19.24, 0.469, 90.73, 0.429, 66.37, 0.469, 70.66 ] + }, + { + "time": 0.5, + "x": 117.18, + "y": 70.46, + "curve": [ 0.522, 136.24, 0.542, 151.33, 0.539, 70.2, 0.555, 38.25 ] + }, + { + "time": 0.5667, + "x": 151.49, + "y": 25.29, + "curve": [ 0.578, 146.76, 0.586, 133.13, 0.572, 19.7, 0.582, 12.23 ] + }, + { "time": 0.6, "x": 115.02, "y": 0.1 }, + { "time": 0.6667, "x": 16.34, "y": 0.18 } + ] + }, + "front-leg-target": { + "translate": [ + { "x": -13.95, "y": -30.34 } + ] + }, + "rear-foot-target": { + "rotate": [ + { "value": 18.55 }, + { + "time": 0.2333, + "value": 167.84, + "curve": [ 0.246, 153.66, 0.256, 129.74 ] + }, + { + "time": 0.2667, + "value": 124.32, + "curve": [ 0.296, 124.43, 0.313, 129.93 ] + }, + { + "time": 0.3667, + "value": 129.87, + "curve": [ 0.421, 128.32, 0.519, 0.98 ] + }, + { + "time": 0.5667, + "curve": [ 0.6, 0.27, 0.642, 4.73 ] + }, + { "time": 0.6667, "value": 18.55 } + ], + "translate": [ + { + "x": -176.39, + "y": 134.12, + "curve": [ 0.018, -142.26, 0.054, -94.41, 0.01, 120.96, 0.044, 84.08 ] + }, + { + "time": 0.0667, + "x": -73.56, + "y": 76.68, + "curve": [ 0.086, -42.82, 0.194, 101.2, 0.098, 66.73, 0.198, 60.88 ] + }, + { "time": 0.2333, "x": 98.32, "y": 32.17 }, + { "time": 0.2667, "x": 49.13, "y": -0.63 }, + { + "time": 0.4, + "x": -147.9, + "y": 0.32, + "curve": [ 0.414, -168.78, 0.478, -284.76, 0.43, 30.09, 0.478, 129.14 ] + }, + { + "time": 0.5, + "x": -283.37, + "y": 167.12, + "curve": [ 0.526, -285.66, 0.548, -280.54, 0.516, 194.84, 0.55, 216.53 ] + }, + { + "time": 0.5667, + "x": -266.98, + "y": 216.12, + "curve": [ 0.581, -256.27, 0.643, -206.54, 0.61, 214.82, 0.65, 145.33 ] + }, + { "time": 0.6667, "x": -176.39, "y": 134.12 } + ] + }, + "rear-leg-target": { + "translate": [ + { "x": 85, "y": -33.59 } + ] + }, + "back-foot-tip": { + "rotate": [ + { + "value": -147.04, + "curve": [ 0.033, -113.4, 0.161, 44.34 ] + }, + { + "time": 0.2333, + "value": 43.48, + "curve": [ 0.24, 43.41, 0.282, 35.72 ] + }, + { + "time": 0.3, + "value": 0.29, + "curve": [ 0.347, 0.28, 0.396, 4.27 ] + }, + { + "time": 0.4, + "curve": [ 0.424, -23.8, 0.525, -181.39 ] + }, + { + "time": 0.5667, + "value": -181.39, + "curve": [ 0.592, -181.39, 0.642, -169.09 ] + }, + { "time": 0.6667, "value": -147.04 } + ] + }, + "front-foot-tip": { + "rotate": [ + { + "value": -0.25, + "curve": [ 0.008, -0.25, 0.056, 1.73 ] + }, + { + "time": 0.0667, + "value": -7.68, + "curve": [ 0.075, -43.13, 0.15, -130.44 ] + }, + { + "time": 0.2, + "value": -130.08, + "curve": [ 0.239, -129.79, 0.272, -126.8 ] + }, + { + "time": 0.3, + "value": -116.24, + "curve": [ 0.333, -103.91, 0.348, -86.1 ] + }, + { + "time": 0.3667, + "value": -71.08, + "curve": [ 0.386, -55.25, 0.415, -32.44 ] + }, + { + "time": 0.4333, + "value": -21.63, + "curve": [ 0.47, -0.01, 0.542, 33.42 ] + }, + { + "time": 0.5667, + "value": 33.2, + "curve": [ 0.622, 32.7, 0.569, 0.64 ] + }, + { "time": 0.6667, "value": -0.25 } + ] + }, + "hair1": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.087, -6.81, 0.143, -5.75 ] + }, + { + "time": 0.1667, + "value": -4.3, + "curve": [ 0.183, -3.28, 0.209, 2.79 ] + }, + { + "time": 0.2333, + "value": 2.78, + "curve": [ 0.262, 2.77, 0.305, -6.63 ] + }, + { + "time": 0.3333, + "value": -6.64, + "curve": [ 0.419, -6.68, 0.49, -4.84 ] + }, + { + "time": 0.5, + "value": -4.38, + "curve": [ 0.518, -3.56, 0.574, 2.32 ] + }, + { + "time": 0.6, + "value": 2.33, + "curve": [ 0.643, 2.35, 0.633, -6.81 ] + }, + { "time": 0.6667, "value": -6.81 } + ] + }, + "hair2": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.014, -3.17, 0.109, 43.93 ] + }, + { + "time": 0.1333, + "value": 43.95, + "curve": [ 0.177, 43.97, 0.192, -13.76 ] + }, + { + "time": 0.2667, + "value": -13.83, + "curve": [ 0.302, -13.72, 0.322, -8.86 ] + }, + { + "time": 0.3333, + "value": -6.6, + "curve": [ 0.349, -3.5, 0.436, 41.1 ] + }, + { + "time": 0.4667, + "value": 41.05, + "curve": [ 0.51, 40.99, 0.549, -14.06 ] + }, + { + "time": 0.6, + "value": -14.18, + "curve": [ 0.63, -14.26, 0.656, -9.04 ] + }, + { "time": 0.6667, "value": -6.81 } + ] + }, + "hair3": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.079, -6.83, 0.108, 0.3 ] + }, + { + "time": 0.1333, + "value": 1.96, + "curve": [ 0.177, 4.89, 0.208, 6.28 ] + }, + { + "time": 0.2333, + "value": 6.29, + "curve": [ 0.313, 6.31, 0.383, 3.49 ] + }, + { + "time": 0.4, + "value": 2.58, + "curve": [ 0.442, 0.28, 0.523, -6.81 ] + }, + { "time": 0.6, "value": -6.81 } + ] + }, + "hair4": { + "rotate": [ + { + "value": -6.81, + "curve": [ 0.011, -4.06, 0.108, 24.92 ] + }, + { + "time": 0.1333, + "value": 24.92, + "curve": [ 0.158, 24.92, 0.208, -10.62 ] + }, + { + "time": 0.2333, + "value": -10.62, + "curve": [ 0.254, -10.62, 0.312, -9.73 ] + }, + { + "time": 0.3333, + "value": -6.4, + "curve": [ 0.356, -2.95, 0.438, 24.93 ] + }, + { + "time": 0.4667, + "value": 24.93, + "curve": [ 0.492, 24.93, 0.575, -9.78 ] + }, + { + "time": 0.6, + "value": -9.78, + "curve": [ 0.617, -9.78, 0.655, -8.63 ] + }, + { "time": 0.6667, "value": -6.81 } + ] + }, + "torso2": { + "rotate": [ + { + "value": 3.5, + "curve": [ 0.07, 3.51, 0.075, 8.69 ] + }, + { + "time": 0.1, + "value": 8.69, + "curve": [ 0.139, 8.69, 0.214, 6.9 ] + }, + { + "time": 0.2333, + "value": 6.33, + "curve": [ 0.266, 5.34, 0.285, 3.48 ] + }, + { + "time": 0.3333, + "value": 3.48, + "curve": [ 0.398, 3.48, 0.408, 8.68 ] + }, + { + "time": 0.4333, + "value": 8.68, + "curve": [ 0.458, 8.68, 0.551, 6.8 ] + }, + { + "time": 0.5667, + "value": 6.26, + "curve": [ 0.598, 5.17, 0.642, 3.49 ] + }, + { "time": 0.6667, "value": 3.5 } + ] + }, + "torso3": { + "rotate": [ + { + "value": 4.52, + "curve": [ 0.067, 4.54, 0.075, -7.27 ] + }, + { + "time": 0.1, + "value": -7.27, + "curve": [ 0.125, -7.27, 0.227, 0.84 ] + }, + { + "time": 0.2333, + "value": 1.24, + "curve": [ 0.254, 2.5, 0.301, 4.51 ] + }, + { + "time": 0.3333, + "value": 4.52, + "curve": [ 0.386, 4.54, 0.408, -7.35 ] + }, + { + "time": 0.4333, + "value": -7.35, + "curve": [ 0.458, -7.35, 0.549, -0.14 ] + }, + { + "time": 0.5667, + "value": 0.95, + "curve": [ 0.586, 2.18, 0.632, 4.54 ] + }, + { "time": 0.6667, "value": 4.52 } + ] + }, + "aim-constraint-target": { + "rotate": [ + { "value": 30.57 } + ] + }, + "rear-foot": { + "rotate": [ + { "value": -6.5 } + ] + }, + "front-foot": { + "rotate": [ + { "value": 4.5 } + ] + }, + "head-control": { + "translate": [ + { + "y": -9.94, + "curve": [ 0.058, 0, 0.175, -15.32, 0.044, -4.19, 0.175, 5 ] + }, + { + "time": 0.2333, + "x": -15.32, + "y": 5, + "curve": [ 0.317, -15.32, 0.429, -9.74, 0.317, 5, 0.382, -31.71 ] + }, + { + "time": 0.4667, + "x": -7.81, + "y": -31.59, + "curve": [ 0.507, -5.76, 0.617, 0, 0.549, -31.47, 0.628, -13.33 ] + }, + { "time": 0.6667, "y": -9.94 } + ] + }, + "front-shoulder": { + "translate": [ + { + "x": -0.74, + "y": 11.22, + "curve": [ 0.061, -0.74, 0.144, 1.17, 0.061, 11.22, 0.143, -17.93 ] + }, + { + "time": 0.2333, + "x": 1.19, + "y": -17.9, + "curve": [ 0.54, 1.25, 0.558, -0.74, 0.545, -17.8, 0.558, 11.22 ] + }, + { "time": 0.6667, "x": -0.74, "y": 11.22 } + ] + }, + "back-shoulder": { + "translate": [ + { + "curve": [ 0.083, 0, 0.25, 0, 0.083, 0, 0.25, 8.93 ] + }, + { + "time": 0.3333, + "y": 8.93, + "curve": [ 0.417, 0, 0.583, 0, 0.417, 8.93, 0.583, 0 ] + }, + { "time": 0.6667 } + ] + } + }, + "ik": { + "front-leg-ik": [ + { "softness": 10, "bendPositive": false }, + { "time": 0.5667, "softness": 14.8, "bendPositive": false }, + { "time": 0.6, "softness": 48.2, "bendPositive": false }, + { "time": 0.6667, "softness": 10, "bendPositive": false } + ], + "rear-leg-ik": [ + { "bendPositive": false }, + { "time": 0.1667, "softness": 22.5, "bendPositive": false }, + { "time": 0.3, "softness": 61.4, "bendPositive": false }, + { "time": 0.6667, "bendPositive": false } + ] + }, + "events": [ + { "time": 0.2333, "name": "footstep" }, + { "time": 0.5667, "name": "footstep" } + ] + }, + "run-to-idle": { + "slots": { + "front-fist": { + "attachment": [ + { "name": "front-fist-open" } + ] + } + }, + "bones": { + "front-foot-target": { + "translate": [ + { + "x": -16.5, + "y": 3.41, + "curve": [ 0.033, -16.5, 0.1, -69.06, 0.033, 3.41, 0.1, 0 ] + }, + { "time": 0.1333, "x": -69.06 } + ] + }, + "hip": { + "translate": [ + { + "x": -28.78, + "y": -72.96, + "curve": [ 0.036, -28.63, 0.2, -10.85, 0.135, -62.35, 0.2, -23.15 ] + }, + { "time": 0.2667, "x": -11.97, "y": -23.15 } + ] + }, + "rear-foot-target": { + "translate": [ + { + "x": 33.15, + "y": 31.61, + "curve": [ 0.017, 33.15, 0.05, 24.41, 0.017, 31.61, 0.041, 20.73 ] + }, + { + "time": 0.0667, + "x": 24.41, + "y": 0.19, + "curve": [ 0.117, 24.41, 0.217, 48.87, 0.117, 0.19, 0.217, 0 ] + }, + { "time": 0.2667, "x": 48.87 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -80.61, + "curve": [ 0.067, -80.61, 0.2, -60.87 ] + }, + { "time": 0.2667, "value": -60.87 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 8.79, + "curve": [ 0.041, 8.79, 0.115, 6.3 ] + }, + { + "time": 0.1667, + "value": 6.41, + "curve": [ 0.201, 6.48, 0.241, 42.46 ] + }, + { "time": 0.2667, "value": 42.46 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 55.3, + "curve": [ 0.067, 55.3, 0.2, 39.2 ] + }, + { "time": 0.2667, "value": 39.2 } + ] + }, + "head": { + "rotate": [ + { + "curve": [ 0.05, 0, 0.083, 2.67 ] + }, + { + "time": 0.1333, + "value": 2.67, + "curve": [ 0.15, 2.67, 0.25, -6.75 ] + }, + { "time": 0.2667, "value": -6.75 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": 38.26, + "curve": [ 0.041, 38.26, 0.127, -2.19 ] + }, + { + "time": 0.1667, + "value": -3, + "curve": [ 0.209, -3.84, 0.241, 0 ] + }, + { "time": 0.2667 } + ], + "scale": [ + { + "x": 0.844, + "curve": [ 0.067, 0.844, 0.2, 1, 0.067, 1, 0.2, 1 ] + }, + { "time": 0.2667 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 57.24, + "curve": [ 0.067, 57.24, 0.2, 0 ] + }, + { "time": 0.2667 } + ] + }, + "gun": { + "rotate": [ + { + "value": 2.28, + "curve": [ 0.041, 2.28, 0.105, 15.34 ] + }, + { + "time": 0.1667, + "value": 15.32, + "curve": [ 0.205, 15.31, 0.241, 0 ] + }, + { "time": 0.2667 } + ] + }, + "torso": { + "rotate": [ + { + "value": -12.98, + "curve": [ 0.033, -12.98, 0.103, -14.81 ] + }, + { + "time": 0.1333, + "value": -16.63, + "curve": [ 0.168, -18.69, 0.233, -22.88 ] + }, + { "time": 0.2667, "value": -22.88 } + ], + "scale": [ + { + "x": 0.963, + "y": 1.074, + "curve": [ 0.067, 0.963, 0.132, 1, 0.067, 1.074, 0.132, 1 ] + }, + { "time": 0.2667 } + ] + }, + "neck": { + "rotate": [ + {}, + { "time": 0.2667, "value": 3.78 } + ] + }, + "hair3": { + "rotate": [ + { + "curve": [ 0.033, 0, 0.1, 0.88 ] + }, + { + "time": 0.1333, + "value": 0.88, + "curve": [ 0.167, 0.88, 0.233, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair4": { + "rotate": [ + { + "curve": [ 0.033, 0, 0.1, 15.97 ] + }, + { + "time": 0.1333, + "value": 15.97, + "curve": [ 0.167, 15.97, 0.233, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair1": { + "rotate": [ + { + "curve": [ 0.033, 0, 0.1, 10.76 ] + }, + { + "time": 0.1333, + "value": 10.76, + "curve": [ 0.167, 10.76, 0.233, 0 ] + }, + { "time": 0.2667 } + ] + }, + "hair2": { + "rotate": [ + { + "curve": [ 0.014, -2.28, 0.042, -7.84 ] + }, + { + "time": 0.0667, + "value": -7.82, + "curve": [ 0.108, -7.79, 0.166, 6.57 ] + }, + { + "time": 0.2, + "value": 6.67, + "curve": [ 0.222, 6.73, 0.255, 1.98 ] + }, + { "time": 0.2667 } + ] + }, + "torso2": { + "rotate": [ + { + "curve": [ 0.041, 0, 0.107, 3.03 ] + }, + { + "time": 0.1667, + "value": 3.03, + "curve": [ 0.205, 3.03, 0.241, 0 ] + }, + { "time": 0.2667 } + ] + }, + "torso3": { + "rotate": [ + { + "curve": [ 0.049, 0, 0.166, 0.66 ] + }, + { + "time": 0.2, + "value": 0.66, + "curve": [ 0.232, 0.65, 0.249, -2.15 ] + }, + { "time": 0.2667, "value": -2.15 } + ] + }, + "head-control": { + "translate": [ + { "x": -10.12, "y": 8.71 }, + { "time": 0.2667 } + ] + }, + "front-shoulder": { + "translate": [ + { "x": 4.91, "y": 11.54 }, + { "time": 0.2667 } + ] + } + } + }, + "shoot": { + "slots": { + "muzzle": { + "rgba": [ + { "time": 0.1333, "color": "ffffffff" }, + { "time": 0.2, "color": "ffffff62" } + ], + "attachment": [ + { "time": 0.0333, "name": "muzzle01" }, + { "time": 0.0667, "name": "muzzle02" }, + { "time": 0.1, "name": "muzzle03" }, + { "time": 0.1333, "name": "muzzle04" }, + { "time": 0.1667, "name": "muzzle05" }, + { "time": 0.2 } + ] + }, + "muzzle-glow": { + "rgba": [ + { "color": "ff0c0c00" }, + { + "time": 0.0333, + "color": "ffc9adff", + "curve": [ 0.255, 1, 0.273, 1, 0.255, 0.76, 0.273, 0.4, 0.255, 0.65, 0.273, 0.22, 0.255, 1, 0.273, 1 ] + }, + { "time": 0.3, "color": "ff400cff" }, + { "time": 0.6333, "color": "ff0c0c00" } + ], + "attachment": [ + { "name": "muzzle-glow" } + ] + }, + "muzzle-ring": { + "rgba": [ + { + "time": 0.0333, + "color": "d8baffff", + "curve": [ 0.202, 0.85, 0.214, 0.84, 0.202, 0.73, 0.214, 0.73, 0.202, 1, 0.214, 1, 0.202, 1, 0.214, 0.21 ] + }, + { "time": 0.2333, "color": "d7baff00" } + ], + "attachment": [ + { "time": 0.0333, "name": "muzzle-ring" }, + { "time": 0.2333 } + ] + }, + "muzzle-ring2": { + "rgba": [ + { + "time": 0.0333, + "color": "d8baffff", + "curve": [ 0.174, 0.85, 0.184, 0.84, 0.174, 0.73, 0.184, 0.73, 0.174, 1, 0.184, 1, 0.174, 1, 0.184, 0.21 ] + }, + { "time": 0.2, "color": "d7baff00" } + ], + "attachment": [ + { "time": 0.0333, "name": "muzzle-ring" }, + { "time": 0.2 } + ] + }, + "muzzle-ring3": { + "rgba": [ + { + "time": 0.0333, + "color": "d8baffff", + "curve": [ 0.174, 0.85, 0.184, 0.84, 0.174, 0.73, 0.184, 0.73, 0.174, 1, 0.184, 1, 0.174, 1, 0.184, 0.21 ] + }, + { "time": 0.2, "color": "d7baff00" } + ], + "attachment": [ + { "time": 0.0333, "name": "muzzle-ring" }, + { "time": 0.2 } + ] + }, + "muzzle-ring4": { + "rgba": [ + { + "time": 0.0333, + "color": "d8baffff", + "curve": [ 0.174, 0.85, 0.184, 0.84, 0.174, 0.73, 0.184, 0.73, 0.174, 1, 0.184, 1, 0.174, 1, 0.184, 0.21 ] + }, + { "time": 0.2, "color": "d7baff00" } + ], + "attachment": [ + { "time": 0.0333, "name": "muzzle-ring" }, + { "time": 0.2 } + ] + } + }, + "bones": { + "gun": { + "rotate": [ + { + "time": 0.0667, + "curve": [ 0.094, 25.89, 0.112, 45.27 ] + }, + { + "time": 0.1333, + "value": 45.35, + "curve": [ 0.192, 45.28, 0.18, -0.09 ] + }, + { "time": 0.6333 } + ] + }, + "muzzle": { + "translate": [ + { "x": -11.02, "y": 25.16 } + ] + }, + "rear-upper-arm": { + "translate": [ + { + "time": 0.0333, + "curve": [ 0.045, 0.91, 0.083, 3.46, 0.044, 0.86, 0.083, 3.32 ] + }, + { + "time": 0.1, + "x": 3.46, + "y": 3.32, + "curve": [ 0.133, 3.46, 0.176, -0.1, 0.133, 3.32, 0.169, 0 ] + }, + { "time": 0.2333 } + ] + }, + "rear-bracer": { + "translate": [ + { + "time": 0.0333, + "curve": [ 0.075, -3.78, 0.083, -4.36, 0.08, -2.7, 0.083, -2.88 ] + }, + { + "time": 0.1, + "x": -4.36, + "y": -2.88, + "curve": [ 0.133, -4.36, 0.168, 0.18, 0.133, -2.88, 0.167, 0 ] + }, + { "time": 0.2333 } + ] + }, + "gun-tip": { + "translate": [ + {}, + { "time": 0.3, "x": 3.15, "y": 0.39 } + ], + "scale": [ + { "x": 0.366, "y": 0.366 }, + { "time": 0.0333, "x": 1.453, "y": 1.453 }, + { "time": 0.3, "x": 0.366, "y": 0.366 } + ] + }, + "muzzle-ring": { + "translate": [ + { "time": 0.0333 }, + { "time": 0.2333, "x": 64.47 } + ], + "scale": [ + { "time": 0.0333 }, + { "time": 0.2333, "x": 5.951, "y": 5.951 } + ] + }, + "muzzle-ring2": { + "translate": [ + { "time": 0.0333 }, + { "time": 0.2, "x": 172.57 } + ], + "scale": [ + { "time": 0.0333 }, + { "time": 0.2, "x": 4, "y": 4 } + ] + }, + "muzzle-ring3": { + "translate": [ + { "time": 0.0333 }, + { "time": 0.2, "x": 277.17 } + ], + "scale": [ + { "time": 0.0333 }, + { "time": 0.2, "x": 2, "y": 2 } + ] + }, + "muzzle-ring4": { + "translate": [ + { "time": 0.0333 }, + { "time": 0.2, "x": 392.06 } + ] + } + } + }, + "walk": { + "bones": { + "rear-foot-target": { + "rotate": [ + { + "value": -32.82, + "curve": [ 0.035, -42.69, 0.057, -70.49 ] + }, + { + "time": 0.1, + "value": -70.59, + "curve": [ 0.236, -70.78, 0.335, -9.87 ] + }, + { + "time": 0.3667, + "value": -1.56, + "curve": [ 0.393, 5.5, 0.477, 13.96 ] + }, + { + "time": 0.5, + "value": 13.96, + "curve": [ 0.519, 13.96, 0.508, 0.13 ] + }, + { "time": 0.5667, "value": -0.28 }, + { + "time": 0.7333, + "value": -0.28, + "curve": [ 0.827, -0.06, 0.958, -21.07 ] + }, + { "time": 1, "value": -32.82 } + ], + "translate": [ + { + "x": -167.32, + "y": 0.58, + "curve": [ 0.022, -180.55, 0.075, -235.51, 0.045, 0.58, 0.075, 30.12 ] + }, + { + "time": 0.1, + "x": -235.51, + "y": 39.92, + "curve": [ 0.142, -235.51, 0.208, -201.73, 0.138, 54.94, 0.18, 60.78 ] + }, + { + "time": 0.2333, + "x": -176.33, + "y": 61.48, + "curve": [ 0.272, -136.61, 0.321, -45.18, 0.275, 62.02, 0.321, 56.6 ] + }, + { + "time": 0.3667, + "x": 8.44, + "y": 49.67, + "curve": [ 0.403, 51.03, 0.486, 66.86, 0.401, 44.37, 0.48, 23.11 ] + }, + { "time": 0.5, "x": 66.57, "y": 14.22 }, + { "time": 0.5333, "x": 52.58, "y": 0.6 }, + { "time": 1, "x": -167.32, "y": 0.58 } + ] + }, + "front-foot-target": { + "rotate": [ + { + "value": 18.19, + "curve": [ 0.01, 11.17, 0.043, 1.37 ] + }, + { "time": 0.1, "value": 0.47 }, + { + "time": 0.2333, + "value": 0.55, + "curve": [ 0.364, 0.3, 0.515, -80.48 ] + }, + { + "time": 0.7333, + "value": -80.78, + "curve": [ 0.788, -80.38, 0.921, 17.42 ] + }, + { "time": 1, "value": 18.19 } + ], + "translate": [ + { + "x": 139.21, + "y": 22.94, + "curve": [ 0.025, 139.21, 0.069, 111.46, 0.031, 3.25, 0.075, 0.06 ] + }, + { "time": 0.1, "x": 96.69, "y": 0.06 }, + { + "time": 0.5, + "x": -94.87, + "y": -0.03, + "curve": [ 0.518, -106.82, 0.575, -152.56, 0.534, 5.42, 0.557, 38.46 ] + }, + { + "time": 0.6, + "x": -152.56, + "y": 57.05, + "curve": [ 0.633, -152.56, 0.688, -128.05, 0.643, 75.61, 0.7, 84.14 ] + }, + { + "time": 0.7333, + "x": -109.42, + "y": 84.14, + "curve": [ 0.771, -93.91, 0.832, -30.64, 0.787, 84.14, 0.799, 89.65 ] + }, + { + "time": 0.8667, + "x": 17, + "y": 75.25, + "curve": [ 0.903, 66.18, 0.967, 139.21, 0.932, 61.53, 0.967, 44.02 ] + }, + { "time": 1, "x": 139.21, "y": 22.94 } + ] + }, + "hip": { + "rotate": [ + { "value": -4.35 } + ], + "translate": [ + { + "x": -2.86, + "y": -13.86, + "curve": [ 0.025, -2.84, 0.067, -2.82, 0.028, -19.14, 0.054, -24.02 ] + }, + { + "time": 0.1, + "x": -2.61, + "y": -24.19, + "curve": [ 0.143, -2.34, 0.202, -1.79, 0.152, -23.98, 0.213, -14.81 ] + }, + { + "time": 0.2667, + "x": -1.21, + "y": -7.12, + "curve": [ 0.308, -0.86, 0.345, -0.51, 0.306, -1.63, 0.341, 3.15 ] + }, + { + "time": 0.3667, + "x": -0.33, + "y": 3.15, + "curve": [ 0.41, 0.02, 0.458, 0.26, 0.427, 3.3, 0.481, -6.75 ] + }, + { + "time": 0.5, + "x": 0.26, + "y": -10.59, + "curve": [ 0.553, 0.26, 0.559, 0.2, 0.519, -14.41, 0.548, -23.88 ] + }, + { + "time": 0.6, + "x": -0.17, + "y": -23.71, + "curve": [ 0.663, -0.72, 0.798, -2.09, 0.702, -23.36, 0.802, 3.53 ] + }, + { + "time": 0.8667, + "x": -2.46, + "y": 3.48, + "curve": [ 0.901, -2.63, 0.967, -2.87, 0.913, 3.45, 0.967, -7.64 ] + }, + { "time": 1, "x": -2.86, "y": -13.86 } + ] + }, + "front-foot-tip": { + "rotate": [ + { + "value": 28.96, + "curve": [ 0.056, 28.74, 0.049, 19.6 ] + }, + { "time": 0.0667, "value": 1.68 }, + { + "time": 0.5, + "value": -10, + "curve": [ 0.525, -10, 0.592, -54.69 ] + }, + { + "time": 0.6, + "value": -59.66, + "curve": [ 0.623, -74.54, 0.674, -101.78 ] + }, + { + "time": 0.7333, + "value": -101.78, + "curve": [ 0.812, -101.78, 0.855, -84.67 ] + }, + { + "time": 0.8667, + "value": -63.53, + "curve": [ 0.869, -58.38, 0.975, 28.96 ] + }, + { "time": 1, "value": 28.96 } + ] + }, + "torso": { + "rotate": [ + { + "value": -20.72, + "curve": [ 0.025, -20.57, 0.071, -20.04 ] + }, + { + "time": 0.1333, + "value": -20.04, + "curve": [ 0.187, -20.04, 0.285, -21.16 ] + }, + { + "time": 0.3667, + "value": -21.16, + "curve": [ 0.405, -21.16, 0.47, -20.9 ] + }, + { + "time": 0.5, + "value": -20.71, + "curve": [ 0.518, -20.6, 0.582, -20.03 ] + }, + { + "time": 0.6333, + "value": -20.04, + "curve": [ 0.709, -20.05, 0.815, -21.18 ] + }, + { + "time": 0.8667, + "value": -21.18, + "curve": [ 0.908, -21.18, 0.971, -20.93 ] + }, + { "time": 1, "value": -20.72 } + ] + }, + "neck": { + "rotate": [ + { + "value": 17.78, + "curve": [ 0.025, 17.93, 0.071, 18.46 ] + }, + { + "time": 0.1333, + "value": 18.46, + "curve": [ 0.187, 18.46, 0.285, 17.34 ] + }, + { + "time": 0.3667, + "value": 17.34, + "curve": [ 0.405, 17.34, 0.47, 17.6 ] + }, + { + "time": 0.5, + "value": 17.79, + "curve": [ 0.518, 17.9, 0.582, 18.47 ] + }, + { + "time": 0.6333, + "value": 18.46, + "curve": [ 0.709, 18.45, 0.815, 17.32 ] + }, + { + "time": 0.8667, + "value": 17.32, + "curve": [ 0.908, 17.32, 0.971, 17.57 ] + }, + { "time": 1, "value": 17.78 } + ] + }, + "head": { + "rotate": [ + { + "value": -12.23, + "curve": [ 0.061, -12.23, 0.191, -7.45 ] + }, + { + "time": 0.2667, + "value": -7.43, + "curve": [ 0.341, -7.42, 0.421, -12.23 ] + }, + { + "time": 0.5, + "value": -12.23, + "curve": [ 0.567, -12.26, 0.694, -7.46 ] + }, + { + "time": 0.7667, + "value": -7.47, + "curve": [ 0.853, -7.49, 0.943, -12.23 ] + }, + { "time": 1, "value": -12.23 } + ], + "scale": [ + { + "curve": [ 0.039, 1, 0.084, 0.991, 0.039, 1, 0.084, 1.019 ] + }, + { + "time": 0.1333, + "x": 0.991, + "y": 1.019, + "curve": [ 0.205, 0.991, 0.318, 1.019, 0.205, 1.019, 0.337, 0.992 ] + }, + { + "time": 0.4, + "x": 1.019, + "y": 0.992, + "curve": [ 0.456, 1.019, 0.494, 1.001, 0.483, 0.991, 0.493, 0.999 ] + }, + { + "time": 0.5, + "curve": [ 0.508, 0.998, 0.584, 0.991, 0.51, 1.002, 0.584, 1.019 ] + }, + { + "time": 0.6333, + "x": 0.991, + "y": 1.019, + "curve": [ 0.705, 0.991, 0.818, 1.019, 0.705, 1.019, 0.837, 0.992 ] + }, + { + "time": 0.9, + "x": 1.019, + "y": 0.992, + "curve": [ 0.956, 1.019, 0.955, 1, 0.983, 0.991, 0.955, 1 ] + }, + { "time": 1 } + ] + }, + "back-foot-tip": { + "rotate": [ + { "value": 4.09 }, + { "time": 0.0333, "value": 3.05 }, + { + "time": 0.1, + "value": -59.01, + "curve": [ 0.124, -72.97, 0.169, -100.05 ] + }, + { + "time": 0.2333, + "value": -99.71, + "curve": [ 0.326, -99.21, 0.349, -37.4 ] + }, + { + "time": 0.3667, + "value": -17.85, + "curve": [ 0.388, 4.74, 0.451, 32.35 ] + }, + { + "time": 0.5, + "value": 32.4, + "curve": [ 0.537, 32.44, 0.566, 6.43 ] + }, + { "time": 0.5667, "value": 2 }, + { "time": 1, "value": 4.09 } + ] + }, + "front-thigh": { + "translate": [ + { + "x": 17.15, + "y": -0.09, + "curve": [ 0.178, 17.14, 0.295, -4.26, 0.009, -0.09, 0.475, 0.02 ] + }, + { + "time": 0.5, + "x": -4.26, + "y": 0.02, + "curve": [ 0.705, -4.27, 0.848, 17.15, 0.525, 0.02, 0.975, -0.09 ] + }, + { "time": 1, "x": 17.15, "y": -0.09 } + ] + }, + "rear-thigh": { + "translate": [ + { + "x": -17.71, + "y": -4.63, + "curve": [ 0.036, -19.81, 0.043, -20.86, 0.036, -4.63, 0.05, -7.03 ] + }, + { + "time": 0.1, + "x": -20.95, + "y": -7.06, + "curve": [ 0.162, -21.05, 0.4, 7.79, 0.2, -7.13, 0.4, -1.9 ] + }, + { + "time": 0.5, + "x": 7.79, + "y": -1.94, + "curve": [ 0.612, 7.69, 0.875, -10.49, 0.592, -1.97, 0.917, -3.25 ] + }, + { "time": 1, "x": -17.71, "y": -4.63 } + ] + }, + "torso2": { + "rotate": [ + { + "value": 1, + "curve": [ 0.006, 1.2, 0.084, 2.88 ] + }, + { + "time": 0.1333, + "value": 2.88, + "curve": [ 0.205, 2.88, 0.284, -1.17 ] + }, + { + "time": 0.3667, + "value": -1.17, + "curve": [ 0.411, -1.17, 0.481, 0.57 ] + }, + { + "time": 0.5, + "value": 1, + "curve": [ 0.515, 1.33, 0.59, 2.83 ] + }, + { + "time": 0.6333, + "value": 2.85, + "curve": [ 0.683, 2.86, 0.796, -1.2 ] + }, + { + "time": 0.8667, + "value": -1.2, + "curve": [ 0.916, -1.2, 0.984, 0.62 ] + }, + { "time": 1, "value": 1 } + ] + }, + "torso3": { + "rotate": [ + { "value": -1.81 } + ] + }, + "front-upper-arm": { + "rotate": [ + { + "value": -9.51, + "curve": [ 0.021, -13.32, 0.058, -19.4 ] + }, + { + "time": 0.1, + "value": -19.4, + "curve": [ 0.238, -19.69, 0.337, 7.78 ] + }, + { + "time": 0.3667, + "value": 16.2, + "curve": [ 0.399, 25.42, 0.497, 60.19 ] + }, + { + "time": 0.6, + "value": 60.26, + "curve": [ 0.719, 60.13, 0.845, 27.61 ] + }, + { + "time": 0.8667, + "value": 22.45, + "curve": [ 0.892, 16.38, 0.979, -3.27 ] + }, + { "time": 1, "value": -9.51 } + ] + }, + "front-bracer": { + "rotate": [ + { + "value": 13.57, + "curve": [ 0.022, 9.71, 0.147, -3.78 ] + }, + { + "time": 0.3667, + "value": -3.69, + "curve": [ 0.457, -3.66, 0.479, 0.83 ] + }, + { + "time": 0.5, + "value": 4.05, + "curve": [ 0.513, 6.08, 0.635, 30.8 ] + }, + { + "time": 0.8, + "value": 30.92, + "curve": [ 0.974, 31, 0.98, 18.35 ] + }, + { "time": 1, "value": 13.57 } + ] + }, + "front-fist": { + "rotate": [ + { + "value": -28.72, + "curve": [ 0.024, -31.74, 0.176, -43.4 ] + }, + { + "time": 0.3667, + "value": -43.6, + "curve": [ 0.403, -43.65, 0.47, -40.15 ] + }, + { + "time": 0.5, + "value": -35.63, + "curve": [ 0.547, -28.59, 0.624, -4.57 ] + }, + { + "time": 0.7333, + "value": -4.59, + "curve": [ 0.891, -4.62, 0.954, -24.28 ] + }, + { "time": 1, "value": -28.48 } + ] + }, + "rear-upper-arm": { + "rotate": [ + { + "value": 28.28, + "curve": [ 0.034, 30.94, 0.068, 32.05 ] + }, + { + "time": 0.1, + "value": 31.88, + "curve": [ 0.194, 31.01, 0.336, -0.11 ] + }, + { + "time": 0.3667, + "value": -7.11, + "curve": [ 0.421, -19.73, 0.53, -46.21 ] + }, + { + "time": 0.6, + "value": -45.75, + "curve": [ 0.708, -45.03, 0.844, -13.56 ] + }, + { + "time": 0.8667, + "value": -6.48, + "curve": [ 0.909, 6.59, 0.958, 24.21 ] + }, + { "time": 1, "value": 28.28 } + ] + }, + "hair2": { + "rotate": [ + { + "value": -2.79, + "curve": [ 0.074, -2.84, 0.121, 25.08 ] + }, + { + "time": 0.2333, + "value": 24.99, + "curve": [ 0.35, 24.89, 0.427, -2.86 ] + }, + { + "time": 0.5, + "value": -2.8, + "curve": [ 0.575, -2.73, 0.652, 24.5 ] + }, + { + "time": 0.7333, + "value": 24.55, + "curve": [ 0.828, 24.6, 0.932, -2.69 ] + }, + { "time": 1, "value": -2.79 } + ] + }, + "hair4": { + "rotate": [ + { + "value": -6.01, + "curve": [ 0.106, -5.97, 0.151, 18.62 ] + }, + { + "time": 0.2333, + "value": 18.72, + "curve": [ 0.336, 18.7, 0.405, -11.37 ] + }, + { + "time": 0.5, + "value": -11.45, + "curve": [ 0.626, -11.46, 0.629, 18.94 ] + }, + { + "time": 0.7333, + "value": 18.92, + "curve": [ 0.833, 18.92, 0.913, -6.06 ] + }, + { "time": 1, "value": -6.01 } + ], + "translate": [ + { "x": 0.03, "y": 1.35 } + ] + }, + "rear-bracer": { + "rotate": [ + { + "value": 10.06, + "curve": [ 0.044, 11.16, 0.063, 11.49 ] + }, + { + "time": 0.1, + "value": 11.49, + "curve": [ 0.215, 11.49, 0.336, 2.92 ] + }, + { + "time": 0.3667, + "value": 0.84, + "curve": [ 0.416, -2.52, 0.498, -10.84 ] + }, + { + "time": 0.6, + "value": -10.83, + "curve": [ 0.762, -10.71, 0.845, -3.05 ] + }, + { + "time": 0.8667, + "value": -1.34, + "curve": [ 0.917, 2.54, 0.977, 8.81 ] + }, + { "time": 1, "value": 10.06 } + ] + }, + "gun": { + "rotate": [ + { + "value": -14.67, + "curve": [ 0.086, -14.67, 0.202, 8.31 ] + }, + { + "time": 0.2333, + "value": 12.14, + "curve": [ 0.279, 17.71, 0.391, 25.79 ] + }, + { + "time": 0.5, + "value": 25.77, + "curve": [ 0.631, 25.74, 0.694, 4.53 ] + }, + { + "time": 0.7333, + "value": -0.65, + "curve": [ 0.768, -5.21, 0.902, -14.4 ] + }, + { "time": 1, "value": -14.67 } + ] + }, + "front-leg-target": { + "translate": [ + { + "x": -2.83, + "y": -8.48, + "curve": [ 0.008, -2.83, 0.058, 0.09, 0.001, 4.97, 0.058, 6.68 ] + }, + { + "time": 0.0667, + "x": 0.09, + "y": 6.68, + "curve": [ 0.3, 0.09, 0.767, -2.83, 0.3, 6.68, 0.767, -8.48 ] + }, + { "time": 1, "x": -2.83, "y": -8.48 } + ] + }, + "hair1": { + "rotate": [ + { + "curve": [ 0.028, 1.24, 0.016, 3.46 ] + }, + { + "time": 0.1, + "value": 3.45, + "curve": [ 0.159, 3.45, 0.189, 0.23 ] + }, + { + "time": 0.2333, + "value": -2.29, + "curve": [ 0.265, -4.32, 0.305, -5.92 ] + }, + { + "time": 0.3667, + "value": -5.94, + "curve": [ 0.446, -5.96, 0.52, 3.41 ] + }, + { + "time": 0.6, + "value": 3.42, + "curve": [ 0.717, 3.42, 0.772, -5.93 ] + }, + { + "time": 0.8667, + "value": -5.97, + "curve": [ 0.933, -5.99, 0.982, -0.94 ] + }, + { "time": 1 } + ] + }, + "hair3": { + "rotate": [ + { + "curve": [ 0.067, 0, 0.159, -10.48 ] + }, + { + "time": 0.2333, + "value": -10.49, + "curve": [ 0.334, -10.5, 0.439, -0.09 ] + }, + { + "time": 0.5, + "value": -0.09, + "curve": [ 0.569, -0.09, 0.658, -10.75 ] + }, + { + "time": 0.7333, + "value": -10.7, + "curve": [ 0.833, -10.63, 0.947, 0 ] + }, + { "time": 1 } + ] + }, + "gun-tip": { + "rotate": [ + { "time": 0.2333, "value": 0.11 } + ] + }, + "muzzle-ring": { + "rotate": [ + { "time": 0.2333, "value": 0.11 } + ] + }, + "muzzle-ring2": { + "rotate": [ + { "time": 0.2667, "value": 0.11 } + ] + }, + "muzzle-ring3": { + "rotate": [ + { "time": 0.2667, "value": 0.11 } + ] + }, + "muzzle-ring4": { + "rotate": [ + { "time": 0.2667, "value": 0.11 } + ] + }, + "back-shoulder": { + "translate": [ + { + "x": -0.18, + "y": -4.49, + "curve": [ 0.133, -0.18, 0.333, 7.69, 0.133, -4.49, 0.333, 2.77 ] + }, + { + "time": 0.4667, + "x": 7.69, + "y": 2.77, + "curve": [ 0.6, 7.69, 0.858, -0.18, 0.6, 2.77, 0.858, -4.49 ] + }, + { "time": 1, "x": -0.18, "y": -4.49 } + ] + }, + "front-shoulder": { + "translate": [ + { + "x": 1.46, + "y": 9.37, + "curve": [ 0.162, 1.41, 0.333, -1.66, 0.162, 9.37, 0.301, -7.23 ] + }, + { + "time": 0.5, + "x": -1.6, + "y": -7.27, + "curve": [ 0.735, -1.5, 0.847, 1.46, 0.723, -7.31, 0.838, 9.32 ] + }, + { "time": 1, "x": 1.46, "y": 9.37 } + ] + }, + "head-control": { + "translate": [ + { + "x": -6.46, + "y": -8.4, + "curve": [ 0.053, -5.31, 0.167, -3.64, 0.093, -8.4, 0.196, -3.81 ] + }, + { + "time": 0.2333, + "x": -3.64, + "y": -1.32, + "curve": [ 0.309, -3.64, 0.436, -5.84, 0.275, 1.43, 0.38, 10.3 ] + }, + { + "time": 0.5, + "x": -7.03, + "y": 10.29, + "curve": [ 0.538, -7.75, 0.66, -10.54, 0.598, 10.27, 0.694, 1.56 ] + }, + { + "time": 0.7333, + "x": -10.54, + "y": -1.26, + "curve": [ 0.797, -10.54, 0.933, -7.91, 0.768, -3.79, 0.875, -8.4 ] + }, + { "time": 1, "x": -6.46, "y": -8.4 } + ] + } + }, + "ik": { + "front-leg-ik": [ + { + "softness": 25.7, + "bendPositive": false, + "curve": [ 0.008, 1, 0.025, 1, 0.008, 25.7, 0.025, 9.9 ] + }, + { + "time": 0.0333, + "softness": 9.9, + "bendPositive": false, + "curve": [ 0.15, 1, 0.383, 1, 0.15, 9.9, 0.383, 43.2 ] + }, + { + "time": 0.5, + "softness": 43.2, + "bendPositive": false, + "curve": [ 0.625, 1, 0.875, 1, 0.625, 43.2, 0.846, 45.57 ] + }, + { "time": 1, "softness": 25.7, "bendPositive": false } + ], + "rear-leg-ik": [ + { "softness": 5, "bendPositive": false }, + { "time": 0.4333, "softness": 4.9, "bendPositive": false }, + { "time": 0.5, "softness": 28.81, "bendPositive": false }, + { "time": 0.6, "softness": 43.8, "bendPositive": false }, + { "time": 1, "softness": 5, "bendPositive": false } + ] + }, + "events": [ + { "name": "footstep" }, + { "time": 0.5, "name": "footstep" } + ] + } + } + } \ No newline at end of file diff --git a/e2e/.dev/public/spineboy.png b/e2e/.dev/public/spineboy.png new file mode 100644 index 0000000000..0ea9737f30 Binary files /dev/null and b/e2e/.dev/public/spineboy.png differ diff --git a/e2e/.dev/public/tank-pro.atlas b/e2e/.dev/public/tank-pro.atlas new file mode 100644 index 0000000000..0c1a46580e --- /dev/null +++ b/e2e/.dev/public/tank-pro.atlas @@ -0,0 +1,65 @@ +tank-pro.png +size:870,638 +filter:Linear,Linear +scale:0.5 +antenna +bounds:647,376,11,152 +cannon +bounds:2,359,466,29 +cannon-connector +bounds:812,448,56,68 +ground +bounds:2,16,512,177 +guntower +bounds:641,4,365,145 +rotate:90 +machinegun +bounds:470,359,166,29 +machinegun-mount +bounds:823,518,36,48 +rock +bounds:690,371,265,61 +offsets:7,1,290,64 +rotate:90 +smoke-glow +bounds:817,371,50,50 +smoke-puff01-bg +bounds:753,371,92,62 +rotate:90 +smoke-puff01-fg +bounds:753,465,86,57 +offsets:1,1,88,59 +rotate:90 +smoke-puff02-fg +bounds:788,127,85,53 +offsets:4,6,92,62 +rotate:90 +smoke-puff03-fg +bounds:788,214,85,53 +offsets:4,6,92,62 +rotate:90 +smoke-puff04-fg +bounds:788,47,78,48 +rotate:90 +tank-bottom +bounds:2,390,643,138 +tank-bottom-shadow +bounds:2,195,638,162 +offsets:1,8,646,171 +tank-top +bounds:2,530,687,106 +offsets:13,5,704,111 +tread +bounds:817,423,48,15 +tread-inside +bounds:611,81,13,14 +wheel-big +bounds:516,97,96,96 +wheel-big-overlay +bounds:516,2,93,93 +wheel-mid +bounds:753,553,68,68 +wheel-mid-overlay +bounds:788,301,68,68 +wheel-small +bounds:788,9,36,36 diff --git a/e2e/.dev/public/tank-pro.json b/e2e/.dev/public/tank-pro.json new file mode 100644 index 0000000000..324d313149 --- /dev/null +++ b/e2e/.dev/public/tank-pro.json @@ -0,0 +1 @@ +{"skeleton":{"hash":"nW5DL2uoEfA","spine":"4.2.40","x":-5852.65,"y":-348.5,"width":7202.61,"height":1298.88,"images":"./images/","audio":""},"bones":[{"name":"root"},{"name":"tank-root","parent":"root","y":146.79},{"name":"tank-treads","parent":"tank-root","y":48.35},{"name":"tank-body","parent":"tank-treads","y":10},{"name":"guntower","parent":"tank-body","x":-21.72,"y":245.48},{"name":"antenna-root","parent":"guntower","x":164.61,"y":202.53},{"name":"antenna1","parent":"antenna-root","length":40,"rotation":90,"y":40,"color":"ffee00ff"},{"name":"antenna2","parent":"antenna1","length":42,"x":42,"color":"ffee00ff"},{"name":"antenna3","parent":"antenna2","length":42,"x":42,"color":"ffee00ff"},{"name":"antenna4","parent":"antenna3","length":42,"x":42,"color":"ffee00ff"},{"name":"antenna5","parent":"antenna4","length":42,"x":42,"color":"ffee00ff"},{"name":"antenna6","parent":"antenna5","length":42,"x":42,"color":"ffee00ff"},{"name":"cannon-connector","parent":"guntower","x":-235.05,"y":96.07},{"name":"cannon-target","parent":"tank-root","x":-2276.67,"y":400.17,"color":"0096ffff","icon":"arrows"},{"name":"cannon","parent":"cannon-connector","length":946.68,"rotation":180,"color":"ff4000ff"},{"name":"machinegun-mount","parent":"guntower","length":90.98,"rotation":90,"x":-123.73,"y":218.33,"color":"15ff00ff"},{"name":"machinegun-target","parent":"tank-root","x":-2272.76,"y":607.77,"color":"0096ffff","icon":"ik"},{"name":"machinegun","parent":"machinegun-mount","length":208.95,"rotation":90,"x":91.52,"y":-1.03,"color":"15ff00ff"},{"name":"machinegun-tip","parent":"machinegun","x":210.43,"y":-12.21},{"name":"rock","parent":"root","x":-1925.2,"y":33.17},{"name":"smoke-root","parent":"tank-root","x":-1200.38,"y":405.76,"scaleX":-6.5,"scaleY":6.5,"color":"ff4000ff","icon":"particles"},{"name":"smoke-glow","parent":"smoke-root","x":62.92,"y":-0.71,"color":"ff4000ff","icon":"particles"},{"name":"smoke1","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke10","parent":"smoke-root","rotation":-103.52,"x":25.45,"y":2.48,"scaleX":3.9011,"scaleY":2.8523,"color":"ff4000ff","icon":"particles"},{"name":"smoke11","parent":"smoke-root","rotation":-103.52,"x":25.45,"y":2.48,"scaleX":3.9011,"scaleY":2.8523,"color":"ff4000ff","icon":"particles"},{"name":"smoke12","parent":"smoke-root","rotation":-103.52,"x":25.45,"y":2.48,"scaleX":3.9011,"scaleY":2.8523,"color":"ff4000ff","icon":"particles"},{"name":"smoke13","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke14","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke15","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke16","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke17","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke18","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke2","parent":"smoke-root","rotation":-84.14,"x":45.06,"y":29.7,"scaleX":3.3345,"scaleY":3.3345,"color":"ff4000ff","icon":"particles"},{"name":"smoke20","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke21","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke22","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke23","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke24","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke25","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke26","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke27","parent":"smoke-root","rotation":-179.99,"x":14.75,"y":-1.55,"scaleX":1.6484,"scaleY":1.6484,"color":"ff4000ff","icon":"particles"},{"name":"smoke3","parent":"smoke-root","rotation":-87.91,"x":55.15,"y":-17.5,"scaleX":3.0415,"scaleY":4.157,"color":"ff4000ff","icon":"particles"},{"name":"smoke4","parent":"smoke-root","rotation":-87.91,"x":69.25,"y":8.01,"scaleX":2.1808,"scaleY":2.9807,"color":"ff4000ff","icon":"particles"},{"name":"smoke5","parent":"smoke-root","rotation":-87.91,"x":80.63,"y":59.88,"scaleX":4.5119,"scaleY":2.9725,"color":"ff4000ff","icon":"particles"},{"name":"smoke6","parent":"smoke-root","rotation":-87.91,"x":96.19,"y":25.65,"scaleX":3.7912,"scaleY":3.0552,"color":"ff4000ff","icon":"particles"},{"name":"smoke7","parent":"smoke-root","rotation":153.68,"x":85.65,"y":-50.47,"scaleX":4.8523,"scaleY":3.6528,"color":"ff4000ff","icon":"particles"},{"name":"smoke8","parent":"smoke-root","rotation":67.58,"x":47.85,"y":-42.55,"scaleX":4.0006,"scaleY":3.4796,"color":"ff4000ff","icon":"particles"},{"name":"smoke9","parent":"smoke-root","rotation":150.05,"x":104.02,"y":-8.73,"scaleX":4.2074,"scaleY":3.0762,"color":"ff4000ff","icon":"particles"},{"name":"tank-glow","parent":"tank-root","x":-247.72,"y":404.37,"scaleX":1.0582,"scaleY":0.6785},{"name":"tread","parent":"tank-root","length":82,"rotation":180,"x":-22.9,"y":213.86,"scaleX":0.9933,"color":"e64344ff"},{"name":"wheel-mid-center","parent":"tank-root","y":-66.21},{"name":"tread-collider1","parent":"wheel-mid-center","x":-329.58,"y":-85.44,"color":"ff00fbff"},{"name":"tread-collider2","parent":"wheel-mid-center","x":-165.95,"y":-85.44,"color":"ff00fbff"},{"name":"tread-collider3","parent":"wheel-mid-center","y":-85.44,"color":"ff00fbff"},{"name":"tread-collider4","parent":"wheel-mid-center","x":163.56,"y":-85.44,"color":"ff00fbff"},{"name":"tread-collider5","parent":"wheel-mid-center","x":329.12,"y":-85.44,"color":"ff00fbff"},{"name":"tread-gravity1","parent":"tank-root","rotation":180,"x":-175.35,"y":149.31,"color":"ff00fbff"},{"name":"tread-gravity2","parent":"tank-root","rotation":180,"x":177.89,"y":144.78,"color":"ff00fbff"},{"name":"tread10","parent":"tread","length":82,"rotation":48.85,"x":662.9,"y":-120.35,"color":"e64344ff"},{"name":"tread11","parent":"tread","length":82,"rotation":97.99,"x":651.5,"y":-39.69,"color":"e64344ff"},{"name":"tread12","parent":"tread","length":82,"rotation":113.79,"x":618.43,"y":34.83,"color":"e64344ff"},{"name":"tread13","parent":"tread","length":82,"rotation":122.96,"x":573.82,"y":103.18,"color":"e64344ff"},{"name":"tread14","parent":"tread","length":82,"rotation":142.01,"x":509.19,"y":153.3,"color":"e64344ff"},{"name":"tread15","parent":"tread","length":82,"rotation":157.84,"x":433.25,"y":184.02,"color":"e64344ff"},{"name":"tread16","parent":"tread","length":82,"rotation":157.37,"x":357.56,"y":215.37,"color":"e64344ff"},{"name":"tread17","parent":"tread","length":82,"rotation":157.29,"x":281.92,"y":246.8,"color":"e64344ff"},{"name":"tread18","parent":"tread","length":82,"rotation":157.19,"x":206.33,"y":278.38,"color":"e64344ff"},{"name":"tread19","parent":"tread","length":82,"rotation":157.14,"x":130.77,"y":310.02,"color":"e64344ff"},{"name":"tread2","parent":"tread","length":82,"x":82,"color":"e64344ff"},{"name":"tread20","parent":"tread","length":82,"rotation":157.34,"x":55.1,"y":341.41,"color":"e64344ff"},{"name":"tread21","parent":"tread","length":82,"rotation":158.11,"x":-20.99,"y":371.77,"color":"e64344ff"},{"name":"tread22","parent":"tread","length":82,"rotation":157.99,"x":-97.02,"y":402.28,"color":"e64344ff"},{"name":"tread23","parent":"tread","length":82,"rotation":157.59,"x":-172.83,"y":433.33,"color":"e64344ff"},{"name":"tread24","parent":"tread","length":82,"rotation":156.86,"x":-248.23,"y":465.34,"color":"e64344ff"},{"name":"tread25","parent":"tread","length":82,"rotation":177.94,"x":-330.17,"y":468.27,"color":"e64344ff"},{"name":"tread26","parent":"tread","length":82,"rotation":-169.55,"x":-410.81,"y":453.5,"color":"e64344ff"},{"name":"tread27","parent":"tread","length":82,"rotation":-163.86,"x":-489.58,"y":430.86,"color":"e64344ff"},{"name":"tread28","parent":"tread","length":82,"rotation":-139.13,"x":-551.59,"y":377.57,"color":"e64344ff"},{"name":"tread29","parent":"tread","length":82,"rotation":-89.04,"x":-550.21,"y":296.14,"color":"e64344ff"},{"name":"tread3","parent":"tread","length":82,"rotation":-8.91,"x":163.01,"y":-12.61,"color":"e64344ff"},{"name":"tread30","parent":"tread","length":82,"rotation":-38.99,"x":-486.48,"y":244.89,"color":"e64344ff"},{"name":"tread31","parent":"tread","length":82,"rotation":-20.04,"x":-409.45,"y":216.98,"color":"e64344ff"},{"name":"tread32","parent":"tread","length":82,"rotation":-46.24,"x":-352.74,"y":158.15,"color":"e64344ff"},{"name":"tread33","parent":"tread","length":82,"rotation":-27.95,"x":-280.3,"y":119.98,"color":"e64344ff"},{"name":"tread34","parent":"tread","length":82,"rotation":10.46,"x":-199.66,"y":134.77,"color":"e64344ff"},{"name":"tread35","parent":"tread","length":82,"rotation":-17.9,"x":-121.63,"y":109.73,"color":"e64344ff"},{"name":"tread36","parent":"tread","length":82,"rotation":-36.82,"x":-55.99,"y":60.92,"color":"fbff00ff"},{"name":"tread4","parent":"tread","length":82,"rotation":-29.27,"x":234.55,"y":-52.43,"color":"e64344ff"},{"name":"tread5","parent":"tread","length":82,"rotation":-45.26,"x":292.26,"y":-110.28,"color":"e64344ff"},{"name":"tread6","parent":"tread","length":82,"rotation":-15.29,"x":371.36,"y":-131.76,"color":"e64344ff"},{"name":"tread7","parent":"tread","length":82,"rotation":-5.49,"x":452.98,"y":-139.55,"color":"e64344ff"},{"name":"tread8","parent":"tread","length":82,"rotation":-24.99,"x":527.31,"y":-173.95,"color":"e64344ff"},{"name":"tread9","parent":"tread","length":82,"rotation":-5.44,"x":608.94,"y":-181.68,"color":"e64344ff"},{"name":"wheel-big-root1","parent":"tank-treads","x":-549.6,"y":14.4,"color":"abe323ff"},{"name":"wheel-big-root2","parent":"tank-treads","x":547.34,"y":14.4},{"name":"wheel-big1","parent":"wheel-big-root1","x":-0.02,"color":"abe323ff"},{"name":"wheel-big2","parent":"wheel-big-root2"},{"name":"wheel-mid-root1","parent":"wheel-mid-center","x":-410.57,"color":"abe323ff"},{"name":"wheel-mid-root2","parent":"wheel-mid-center","x":-246.95},{"name":"wheel-mid-root3","parent":"wheel-mid-center","x":-82.73},{"name":"wheel-mid-root4","parent":"wheel-mid-center","x":80.89},{"name":"wheel-mid-root5","parent":"wheel-mid-center","x":244.51},{"name":"wheel-mid-root6","parent":"wheel-mid-center","x":408.74},{"name":"wheel-mid1","parent":"wheel-mid-root1","color":"abe323ff"},{"name":"wheel-mid2","parent":"wheel-mid-root2"},{"name":"wheel-mid3","parent":"wheel-mid-root3"},{"name":"wheel-mid4","parent":"wheel-mid-root4"},{"name":"wheel-mid5","parent":"wheel-mid-root5"},{"name":"wheel-mid6","parent":"wheel-mid-root6"},{"name":"wheel-small-root1","parent":"tank-treads","x":-337.39,"y":109.43},{"name":"wheel-small-root2","parent":"tank-treads","x":0.09,"y":109.43},{"name":"wheel-small-root3","parent":"tank-treads","x":334.69,"y":109.43},{"name":"wheel-small1","parent":"wheel-small-root1","color":"abe323ff"},{"name":"wheel-small2","parent":"wheel-small-root2"},{"name":"wheel-small3","parent":"wheel-small-root3"}],"slots":[{"name":"rock","bone":"rock","attachment":"rock"},{"name":"ground","bone":"root","attachment":"ground"},{"name":"ground2","bone":"root","attachment":"ground"},{"name":"ground3","bone":"root","attachment":"ground"},{"name":"ground4","bone":"root","attachment":"ground"},{"name":"ground5","bone":"root","attachment":"ground"},{"name":"ground6","bone":"root","attachment":"ground"},{"name":"ground7","bone":"root","attachment":"ground"},{"name":"tank-body-shadow","bone":"tank-body","color":"ffffffb9","attachment":"tank-bottom-shadow"},{"name":"bottom","bone":"tank-body","attachment":"tank-bottom"},{"name":"tread-inside1","bone":"tread","attachment":"tread-inside"},{"name":"tread-inside53","bone":"tread27","attachment":"tread-inside"},{"name":"tread-inside27","bone":"tread14","attachment":"tread-inside"},{"name":"tread-inside3","bone":"tread2","attachment":"tread-inside"},{"name":"tread-inside55","bone":"tread28","attachment":"tread-inside"},{"name":"tread-inside29","bone":"tread15","attachment":"tread-inside"},{"name":"tread-inside5","bone":"tread3","attachment":"tread-inside"},{"name":"tread-inside57","bone":"tread29","attachment":"tread-inside"},{"name":"tread-inside31","bone":"tread16","attachment":"tread-inside"},{"name":"tread-inside7","bone":"tread4","attachment":"tread-inside"},{"name":"tread-inside59","bone":"tread30","attachment":"tread-inside"},{"name":"tread-inside33","bone":"tread17","attachment":"tread-inside"},{"name":"tread-inside9","bone":"tread5","attachment":"tread-inside"},{"name":"tread-inside61","bone":"tread31","attachment":"tread-inside"},{"name":"tread-inside35","bone":"tread18","attachment":"tread-inside"},{"name":"tread-inside11","bone":"tread6","attachment":"tread-inside"},{"name":"tread-inside63","bone":"tread32","attachment":"tread-inside"},{"name":"tread-inside37","bone":"tread19","attachment":"tread-inside"},{"name":"tread-inside13","bone":"tread7","attachment":"tread-inside"},{"name":"tread-inside65","bone":"tread33","attachment":"tread-inside"},{"name":"tread-inside39","bone":"tread20","attachment":"tread-inside"},{"name":"tread-inside15","bone":"tread8","attachment":"tread-inside"},{"name":"tread-inside67","bone":"tread34","attachment":"tread-inside"},{"name":"tread-inside69","bone":"tread35","attachment":"tread-inside"},{"name":"tread-inside71","bone":"tread36","attachment":"tread-inside"},{"name":"tread-inside41","bone":"tread21","attachment":"tread-inside"},{"name":"tread-inside17","bone":"tread9","attachment":"tread-inside"},{"name":"tread-inside43","bone":"tread22","attachment":"tread-inside"},{"name":"tread-inside19","bone":"tread10","attachment":"tread-inside"},{"name":"tread-inside45","bone":"tread23","attachment":"tread-inside"},{"name":"tread-inside21","bone":"tread11","attachment":"tread-inside"},{"name":"tread-inside47","bone":"tread24","attachment":"tread-inside"},{"name":"tread-inside23","bone":"tread12","attachment":"tread-inside"},{"name":"tread-inside49","bone":"tread25","attachment":"tread-inside"},{"name":"tread-inside25","bone":"tread13","attachment":"tread-inside"},{"name":"tread-inside51","bone":"tread26","attachment":"tread-inside"},{"name":"tread-inside2","bone":"tread","attachment":"tread-inside"},{"name":"tread-inside54","bone":"tread27","attachment":"tread-inside"},{"name":"tread-inside28","bone":"tread14","attachment":"tread-inside"},{"name":"tread-inside4","bone":"tread2","attachment":"tread-inside"},{"name":"tread-inside56","bone":"tread28","attachment":"tread-inside"},{"name":"tread-inside30","bone":"tread15","attachment":"tread-inside"},{"name":"tread-inside6","bone":"tread3","attachment":"tread-inside"},{"name":"tread-inside58","bone":"tread29","attachment":"tread-inside"},{"name":"tread-inside32","bone":"tread16","attachment":"tread-inside"},{"name":"tread-inside8","bone":"tread4","attachment":"tread-inside"},{"name":"tread-inside60","bone":"tread30","attachment":"tread-inside"},{"name":"tread-inside34","bone":"tread17","attachment":"tread-inside"},{"name":"tread-inside10","bone":"tread5","attachment":"tread-inside"},{"name":"tread-inside62","bone":"tread31","attachment":"tread-inside"},{"name":"tread-inside36","bone":"tread18","attachment":"tread-inside"},{"name":"tread-inside12","bone":"tread6","attachment":"tread-inside"},{"name":"tread-inside64","bone":"tread32","attachment":"tread-inside"},{"name":"tread-inside38","bone":"tread19","attachment":"tread-inside"},{"name":"tread-inside14","bone":"tread7","attachment":"tread-inside"},{"name":"tread-inside66","bone":"tread33","attachment":"tread-inside"},{"name":"tread-inside40","bone":"tread20","attachment":"tread-inside"},{"name":"tread-inside16","bone":"tread8","attachment":"tread-inside"},{"name":"tread-inside68","bone":"tread34","attachment":"tread-inside"},{"name":"tread-inside70","bone":"tread35","attachment":"tread-inside"},{"name":"tread-inside72","bone":"tread36","attachment":"tread-inside"},{"name":"tread-inside42","bone":"tread21","attachment":"tread-inside"},{"name":"tread-inside18","bone":"tread9","attachment":"tread-inside"},{"name":"tread-inside44","bone":"tread22","attachment":"tread-inside"},{"name":"tread-inside20","bone":"tread10","attachment":"tread-inside"},{"name":"tread-inside46","bone":"tread23","attachment":"tread-inside"},{"name":"tread-inside22","bone":"tread11","attachment":"tread-inside"},{"name":"tread-inside48","bone":"tread24","attachment":"tread-inside"},{"name":"tread-inside24","bone":"tread12","attachment":"tread-inside"},{"name":"tread-inside50","bone":"tread25","attachment":"tread-inside"},{"name":"tread-inside26","bone":"tread13","attachment":"tread-inside"},{"name":"tread-inside52","bone":"tread26","attachment":"tread-inside"},{"name":"wheel-big","bone":"wheel-big1","color":"dbdbdbff","attachment":"wheel-big"},{"name":"wheel-big2","bone":"wheel-big2","color":"dbdbdbff","attachment":"wheel-big"},{"name":"wheel-mid","bone":"wheel-mid1","attachment":"wheel-mid"},{"name":"wheel-mid2","bone":"wheel-mid2","attachment":"wheel-mid"},{"name":"wheel-mid3","bone":"wheel-mid3","attachment":"wheel-mid"},{"name":"wheel-mid4","bone":"wheel-mid4","attachment":"wheel-mid"},{"name":"wheel-mid5","bone":"wheel-mid5","attachment":"wheel-mid"},{"name":"wheel-mid6","bone":"wheel-mid6","attachment":"wheel-mid"},{"name":"wheel-small","bone":"wheel-small1","attachment":"wheel-small"},{"name":"wheel-small2","bone":"wheel-small2","attachment":"wheel-small"},{"name":"wheel-small3","bone":"wheel-small3","attachment":"wheel-small"},{"name":"wheel-mid-overlay","bone":"wheel-mid-root1","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-mid-overlay2","bone":"wheel-mid-root2","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-mid-overlay3","bone":"wheel-mid-root3","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-mid-overlay4","bone":"wheel-mid-root4","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-mid-overlay5","bone":"wheel-mid-root5","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-mid-overlay6","bone":"wheel-mid-root6","color":"ffffffec","attachment":"wheel-mid-overlay","blend":"multiply"},{"name":"wheel-big-overlay1","bone":"wheel-big-root1","color":"ffffffe9","attachment":"wheel-big-overlay","blend":"multiply"},{"name":"wheel-big-overlay2","bone":"wheel-big-root2","color":"ffffffe9","attachment":"wheel-big-overlay","blend":"multiply"},{"name":"treads-path","bone":"tank-root","attachment":"treads-path"},{"name":"tread","bone":"tread","attachment":"tread"},{"name":"tread27","bone":"tread27","color":"adc9b8ff","attachment":"tread"},{"name":"tread14","bone":"tread14","attachment":"tread"},{"name":"tread2","bone":"tread2","attachment":"tread"},{"name":"tread28","bone":"tread28","attachment":"tread"},{"name":"tread15","bone":"tread15","color":"adc9b8ff","attachment":"tread"},{"name":"tread3","bone":"tread3","color":"adc9b8ff","attachment":"tread"},{"name":"tread29","bone":"tread29","color":"adc9b8ff","attachment":"tread"},{"name":"tread16","bone":"tread16","attachment":"tread"},{"name":"tread4","bone":"tread4","attachment":"tread"},{"name":"tread30","bone":"tread30","attachment":"tread"},{"name":"tread17","bone":"tread17","color":"adc9b8ff","attachment":"tread"},{"name":"tread5","bone":"tread5","color":"adc9b8ff","attachment":"tread"},{"name":"tread31","bone":"tread31","color":"adc9b8ff","attachment":"tread"},{"name":"tread18","bone":"tread18","attachment":"tread"},{"name":"tread6","bone":"tread6","attachment":"tread"},{"name":"tread32","bone":"tread32","attachment":"tread"},{"name":"tread19","bone":"tread19","color":"adc9b8ff","attachment":"tread"},{"name":"tread7","bone":"tread7","color":"adc9b8ff","attachment":"tread"},{"name":"tread33","bone":"tread33","color":"adc9b8ff","attachment":"tread"},{"name":"tread20","bone":"tread20","attachment":"tread"},{"name":"tread8","bone":"tread8","attachment":"tread"},{"name":"tread34","bone":"tread34","attachment":"tread"},{"name":"tread35","bone":"tread35","color":"adc9b8ff","attachment":"tread"},{"name":"tread36","bone":"tread36","color":"adc9b8ff","attachment":"tread"},{"name":"tread21","bone":"tread21","color":"adc9b8ff","attachment":"tread"},{"name":"tread9","bone":"tread9","color":"adc9b8ff","attachment":"tread"},{"name":"tread22","bone":"tread22","attachment":"tread"},{"name":"tread10","bone":"tread10","attachment":"tread"},{"name":"tread23","bone":"tread23","color":"adc9b8ff","attachment":"tread"},{"name":"tread11","bone":"tread11","color":"adc9b8ff","attachment":"tread"},{"name":"tread24","bone":"tread24","attachment":"tread"},{"name":"tread12","bone":"tread12","attachment":"tread"},{"name":"tread25","bone":"tread25","color":"adc9b8ff","attachment":"tread"},{"name":"tread13","bone":"tread13","color":"adc9b8ff","attachment":"tread"},{"name":"tread26","bone":"tread26","attachment":"tread"},{"name":"machinegun","bone":"machinegun","attachment":"machinegun"},{"name":"machinegun-mount","bone":"machinegun-mount","attachment":"machinegun-mount"},{"name":"tank-top","bone":"tank-body","attachment":"tank-top"},{"name":"guntower","bone":"guntower","attachment":"guntower"},{"name":"cannon","bone":"cannon","attachment":"cannon"},{"name":"cannon-connector","bone":"cannon-connector","attachment":"cannon-connector"},{"name":"antenna","bone":"antenna-root","attachment":"antenna"},{"name":"smoke-puff1-bg","bone":"smoke1","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg13","bone":"smoke13","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg14","bone":"smoke14","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg15","bone":"smoke15","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg16","bone":"smoke16","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg17","bone":"smoke17","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg18","bone":"smoke18","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg20","bone":"smoke20","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg21","bone":"smoke21","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg22","bone":"smoke22","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg23","bone":"smoke23","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg24","bone":"smoke24","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg25","bone":"smoke25","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg26","bone":"smoke26","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg27","bone":"smoke27","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg2","bone":"smoke2","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg5","bone":"smoke5","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg6","bone":"smoke6","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg7","bone":"smoke7","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg10","bone":"smoke10","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg11","bone":"smoke11","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg12","bone":"smoke12","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg8","bone":"smoke8","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg9","bone":"smoke9","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg4","bone":"smoke4","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-bg3","bone":"smoke3","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg","bone":"smoke1","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg13","bone":"smoke13","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg14","bone":"smoke14","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg15","bone":"smoke15","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg16","bone":"smoke16","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg17","bone":"smoke17","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg18","bone":"smoke18","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg20","bone":"smoke20","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg21","bone":"smoke21","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg22","bone":"smoke22","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg23","bone":"smoke23","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg24","bone":"smoke24","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg25","bone":"smoke25","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg26","bone":"smoke26","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg27","bone":"smoke27","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg2","bone":"smoke2","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg5","bone":"smoke5","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg6","bone":"smoke6","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg7","bone":"smoke7","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg10","bone":"smoke10","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg11","bone":"smoke11","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg12","bone":"smoke12","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg8","bone":"smoke8","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg9","bone":"smoke9","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg4","bone":"smoke4","color":"ecececff","dark":"000000"},{"name":"smoke-puff1-fg3","bone":"smoke3","color":"ecececff","dark":"000000"},{"name":"smoke-glow","bone":"smoke-glow","blend":"additive"},{"name":"clipping","bone":"tank-body","attachment":"clipping"},{"name":"tank-glow","bone":"tank-glow","color":"fcdc6da7","blend":"additive"}],"ik":[{"name":"cannon-ik","bones":["cannon"],"target":"cannon-target"},{"name":"machinegun-ik","order":1,"bones":["machinegun"],"target":"machinegun-target","mix":0}],"transform":[{"name":"wheel-big-transform","order":8,"bones":["wheel-big2"],"target":"wheel-big1","rotation":65.6,"mixX":0,"mixScaleX":0,"mixShearY":0},{"name":"wheel-mid1-transform","order":3,"bones":["wheel-mid2","wheel-mid4"],"target":"wheel-mid1","rotation":93,"mixX":0,"mixScaleX":0,"mixShearY":0},{"name":"wheel-mid2-transform","order":4,"bones":["wheel-mid3","wheel-mid5"],"target":"wheel-mid1","rotation":-89,"mixX":0,"mixScaleX":0,"mixShearY":0},{"name":"wheel-mid3-transform","order":5,"bones":["wheel-mid6"],"target":"wheel-mid1","rotation":-152.6,"mixX":0,"mixScaleX":0,"mixShearY":0},{"name":"wheel-small1-transform","order":6,"bones":["wheel-small2"],"target":"wheel-small1","rotation":87,"mixX":0,"mixScaleX":0,"mixShearY":0},{"name":"wheel-small2-transform","order":7,"bones":["wheel-small3"],"target":"wheel-small1","rotation":54.9,"mixX":0,"mixScaleX":0,"mixShearY":0}],"path":[{"name":"treads-path","order":2,"bones":["tread","tread2","tread3","tread4","tread5","tread6","tread7","tread8","tread9","tread10","tread11","tread12","tread13","tread14","tread15","tread16","tread17","tread18","tread19","tread20","tread21","tread22","tread23","tread24","tread25","tread26","tread27","tread28","tread29","tread30","tread31","tread32","tread33","tread34","tread35","tread36"],"target":"treads-path","rotateMode":"chain"}],"skins":[{"name":"default","attachments":{"antenna":{"antenna":{"type":"mesh","uvs":[0.64286,0.07876,0.65354,0.1535,0.66325,0.22138,0.67367,0.29433,0.68383,0.36543,0.6936,0.43374,0.70311,0.5003,0.71311,0.57031,0.72327,0.64139,0.73406,0.71689,0.74441,0.7893,0.75614,0.87141,0.76905,0.94311,1,0.94311,1,1,0,1,0,0.94311,0.20106,0.94311,0.20106,0.87094,0.21461,0.78847,0.22651,0.71607,0.23886,0.64099,0.25036,0.57105,0.26206,0.49983,0.27306,0.43291,0.2843,0.36454,0.29593,0.29382,0.308,0.22038,0.319,0.15345,0.33142,0.07796,0.34423,0,0.63161,0],"triangles":[30,31,0,29,30,0,29,0,1,28,29,1,28,1,2,27,28,2,27,2,3,26,3,4,25,26,4,25,4,5,26,27,3,24,5,6,23,24,6,7,23,6,24,25,5,22,7,8,21,22,8,21,8,9,7,22,23,20,9,10,19,20,10,20,21,9,19,10,11,18,19,11,17,18,11,17,11,12,15,16,17,12,13,14,15,17,12,14,15,12],"vertices":[2,10,65.38,-3.14,0.3125,11,23.38,-3.14,0.6875,2,10,42.73,-3.38,0.66667,11,0.73,-3.38,0.33333,2,9,64.17,-3.59,0.33333,10,22.17,-3.59,0.66667,2,9,42.06,-3.82,0.66667,10,0.06,-3.82,0.33333,2,8,62.52,-4.04,0.33333,9,20.52,-4.04,0.66667,2,8,41.82,-4.26,0.66667,9,-0.18,-4.26,0.33333,2,7,63.65,-4.47,0.33333,8,21.65,-4.47,0.66667,2,7,42.44,-4.69,0.66667,8,0.44,-4.69,0.33333,2,6,62.9,-4.91,0.33333,7,20.9,-4.91,0.66667,2,6,40.03,-5.15,0.66667,7,-1.97,-5.15,0.33333,2,5,5.38,58.09,0.4,6,18.09,-5.38,0.6,1,5,5.64,33.21,1,1,5,5.92,11.48,1,1,5,11,11.48,1,1,5,11,-5.76,1,1,5,-11,-5.76,1,1,5,-11,11.48,1,1,5,-6.58,11.48,1,1,5,-6.58,33.35,1,2,5,-6.28,58.34,0.4,6,18.34,6.28,0.6,2,6,40.27,6.02,0.66667,7,-1.73,6.02,0.33333,2,6,63.03,5.75,0.33333,7,21.03,5.75,0.66667,2,7,42.22,5.49,0.66667,8,0.22,5.49,0.33333,2,7,63.8,5.23,0.33333,8,21.8,5.23,0.66667,2,8,42.07,4.99,0.66667,9,0.07,4.99,0.33333,2,8,62.79,4.75,0.33333,9,20.79,4.75,0.66667,2,9,42.22,4.49,0.66667,10,0.22,4.49,0.33333,2,9,64.47,4.22,0.33333,10,22.47,4.22,0.66667,2,10,42.75,3.98,0.66667,11,0.75,3.98,0.33333,2,10,65.62,3.71,0.3125,11,23.62,3.71,0.6875,1,11,47.24,3.43,1,1,11,47.24,-2.9,1],"hull":32,"edges":[28,30,28,26,30,32,26,24,24,22,32,34,34,24,34,36,36,22,60,62,38,36,20,22,38,20,40,38,18,20,40,18,42,40,16,18,42,16,44,42,14,16,44,14,46,44,12,14,46,12,48,46,10,12,48,10,50,48,8,10,50,8,52,50,6,8,52,6,54,52,4,6,54,4,56,54,2,4,56,2,60,58,58,56,62,0,0,2,58,0],"width":22,"height":303}},"bottom":{"tank-bottom":{"x":-16.67,"y":9.89,"width":1285,"height":276}},"cannon":{"cannon":{"x":481.95,"y":-0.03,"rotation":180,"width":931,"height":58}},"cannon-connector":{"cannon-connector":{"type":"mesh","uvs":[1,0.03237,1,0.10603,0.90988,0.32859,0.81975,0.55116,0.72963,0.77373,0.6395,0.9963,0.42157,0.9963,0.20364,0.9963,0,0.85434,0,0.69902,0.02268,0.52884,0,0.31444,0.21602,0.12998,0.43368,0,0.63547,0.0037,0.48408,0.77059,0.31496,0.52497,0.64133,0.19648,0.21516,0.76766,0.58346,0.56471,0.68444,0.40146,0.46758,0.36649,0.28935,0.34604],"triangles":[7,18,6,6,18,15,7,8,18,8,9,18,18,16,15,15,16,19,9,10,18,18,10,16,16,21,19,19,21,20,10,22,16,10,11,22,16,22,21,21,17,20,21,12,13,17,13,14,17,21,13,11,12,22,21,22,12,6,15,5,5,15,4,15,19,4,4,19,3,19,20,3,3,20,2,20,17,2,2,17,1,17,14,1,14,0,1],"vertices":[1,12,35.91,69.08,1,1,12,35.91,59.14,1,1,12,25.82,29.09,1,1,12,15.72,-0.95,1,1,12,5.63,-31,1,1,12,-4.46,-61.05,1,2,12,-28.87,-61.05,0.33333,14,28.87,61.03,0.66667,1,14,53.28,61.02,1,1,14,76.09,41.84,1,1,14,71.17,21.63,1,1,14,72.83,-1.62,1,1,14,70.38,-29.12,1,1,14,50.67,-56.14,1,2,12,-28.43,74.38,0.41,14,28.43,-74.4,0.59,2,12,-4.92,72.95,0.52,14,4.92,-72.95,0.48,2,12,-21.87,-30.58,0.49,14,21.87,30.57,0.51,1,14,40.81,-2.6,1,2,12,-4.26,46.93,0.49,14,4.26,-46.93,0.51,1,14,51.99,30.15,1,2,12,-10.74,-2.78,0.49,14,10.74,2.78,0.51,2,12,0.57,19.25,0.49,14,-0.57,-19.25,0.51,1,14,23.72,-23.99,1,1,14,43.68,-26.76,1],"hull":15,"edges":[0,2,2,4,4,6,6,8,8,10,10,12,12,14,14,16,16,18,18,20,20,22,22,24,24,26,26,28,28,0],"width":112,"height":135}},"clipping":{"clipping":{"type":"clipping","end":"tank-glow","vertexCount":32,"vertices":[1,3,165.84,455.67,1,1,3,114.21,493.01,1,1,3,-38.53,492.23,1,1,3,-193.4,464.18,1,2,3,-280.85,415.48,0.752,14,24.09,-73.93,0.248,1,14,70.34,-27.32,1,1,14,412.56,-22.02,1,1,14,412.82,-29.21,1,1,14,539.26,-29.34,1,1,14,539.52,-17.09,1,1,14,894.02,-16.8,1,1,14,902.99,-28.89,1,1,14,942.06,-28.58,1,1,14,948.14,-16.64,1,1,14,947.9,14.29,1,1,14,539.3,14.55,1,1,14,539,29.22,1,1,14,412.51,29.88,1,1,14,412.51,21.73,1,1,14,74.24,27.28,1,1,3,-296.64,281.2,1,1,3,-316.06,225.71,1,1,3,-521.69,190.74,1,1,3,-610.03,141.02,1,1,3,-671.84,87.13,1,1,3,-652.23,-11.24,1,1,3,-618.53,-71.36,1,1,3,-478.77,-114.21,1,1,3,-274.11,-116.26,1,1,3,1.38,-45.75,1,1,3,189.67,148.78,1,1,3,215.75,276.59,1],"color":"ce3a3aff"}},"ground":{"ground":{"x":837.96,"y":-172,"width":1024,"height":353}},"ground2":{"ground":{"x":-179.89,"y":-172,"width":1024,"height":353}},"ground3":{"ground":{"x":-1213.48,"y":-172,"scaleX":1.035,"width":1024,"height":353}},"ground4":{"ground":{"x":-2268.51,"y":-172,"scaleX":1.04,"width":1024,"height":353}},"ground5":{"ground":{"x":-3306.54,"y":-172,"width":1024,"height":353}},"ground6":{"ground":{"x":-4322.71,"y":-172,"width":1024,"height":353}},"ground7":{"ground":{"x":-5340.65,"y":-172,"width":1024,"height":353}},"guntower":{"guntower":{"x":77.22,"y":122.59,"width":730,"height":289}},"machinegun":{"machinegun":{"x":44.85,"y":-5.72,"rotation":-180,"width":331,"height":57}},"machinegun-mount":{"machinegun-mount":{"x":47.42,"y":-1.54,"rotation":-90,"width":72,"height":96}},"rock":{"rock":{"x":25.24,"y":27.35,"width":580,"height":127}},"smoke-glow":{"smoke-glow":{"type":"mesh","uvs":[1,0.24906,1,0.51991,1,0.73165,0.70776,1,0.49012,1,0.24373,1,0,0.71158,0,0.50308,0,0.26235,0.28107,0,0.47435,0,0.73345,0,0.48858,0.51759],"triangles":[12,7,8,12,10,11,12,11,0,9,10,12,12,8,9,12,0,1,6,7,12,12,1,2,5,6,12,3,4,12,5,12,4,2,3,12],"vertices":[49.99,25.1,50,-1.98,50.01,-23.15,20.79,-50,-0.98,-50,-25.62,-50.01,-50,-21.17,-50,-0.32,-50.01,23.75,-21.9,50,-2.58,50,23.33,50.01,-1.14,-1.76],"hull":12,"edges":[2,24,24,14,20,24,24,8,2,0,20,22,0,22,18,20,14,16,18,16,12,14,8,10,12,10,6,8,2,4,6,4],"width":100,"height":100}},"smoke-puff1-bg":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg2":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg3":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg4":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg5":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg6":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg7":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg8":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg9":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg10":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg11":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg12":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg13":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg14":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg15":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg16":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg17":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg18":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg20":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg21":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg22":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg23":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg24":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg25":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg26":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-bg27":{"smoke-puff01-bg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123}},"smoke-puff1-fg":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg2":{"smoke-puff01-fg":{"x":-1.01,"y":-0.07,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.82,"y":-0.39,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.82,"y":-0.39,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.82,"y":-0.39,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg3":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.02,"y":-0.25,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1145,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.03,"y":-0.43,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg4":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.63,"y":-0.09,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.63,"y":-0.09,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.63,"y":-0.09,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg5":{"smoke-puff01-fg":{"x":-1.21,"y":-0.08,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.89,"y":-0.04,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.89,"y":-0.04,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.89,"y":-0.04,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg6":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.48,"y":-0.07,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.48,"y":-0.07,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.48,"y":-0.07,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg7":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-0.7,"y":-0.36,"scaleX":0.1216,"scaleY":0.1214,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-0.56,"y":-0.15,"scaleX":0.1224,"scaleY":0.1224,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-0.56,"y":-0.15,"scaleX":0.1224,"scaleY":0.1224,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg8":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-0.65,"y":0.01,"scaleX":0.1226,"scaleY":0.1226,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-0.75,"y":-0.15,"scaleX":0.1211,"scaleY":0.1211,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-0.75,"y":-0.15,"scaleX":0.1211,"scaleY":0.1211,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg9":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.99,"y":-0.09,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.99,"y":-0.09,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-0.95,"y":-0.48,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg10":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.27,"y":-0.37,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.27,"y":-0.37,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.27,"y":-0.37,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg11":{"smoke-puff04-fg":{"x":-1.27,"y":-0.37,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg12":{"smoke-puff04-fg":{"x":-1.27,"y":-0.37,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg13":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg14":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg15":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg16":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg17":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg18":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg20":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg21":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg22":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg23":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg24":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg25":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg26":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"smoke-puff1-fg27":{"smoke-puff01-fg":{"x":-0.5,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":175,"height":118},"smoke-puff02-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff03-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":184,"height":123},"smoke-puff04-fg":{"x":-1.38,"y":-0.12,"scaleX":0.1106,"scaleY":0.1106,"rotation":88.58,"width":155,"height":96}},"tank-body-shadow":{"tank-bottom-shadow":{"x":-11.44,"y":-42.89,"width":1291,"height":341}},"tank-glow":{"smoke-glow":{"type":"mesh","uvs":[1,1,0,1,1,0],"triangles":[1,2,0],"vertices":[469.64,-738.08,-1660.32,-738.08,469.64,1391.88],"hull":3,"edges":[0,2,0,4,2,4],"width":100,"height":100}},"tank-top":{"tank-top":{"x":6.8,"y":168.71,"width":1407,"height":222}},"tread":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread-inside1":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside2":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside3":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside4":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside5":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside6":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside7":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside8":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside9":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside10":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside11":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside12":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside13":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside14":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside15":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside16":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside17":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside18":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside19":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside20":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside21":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside22":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside23":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside24":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside25":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside26":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside27":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside28":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside29":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside30":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside31":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside32":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside33":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside34":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside35":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside36":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside37":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside38":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside39":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside40":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside41":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside42":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside43":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside44":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside45":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside46":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside47":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside48":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside49":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside50":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside51":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside52":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside53":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside54":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside55":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside56":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside57":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside58":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside59":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside60":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside61":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside62":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside63":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside64":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside65":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside66":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside67":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside68":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside69":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside70":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside71":{"tread-inside":{"x":60.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread-inside72":{"tread-inside":{"x":20.1,"y":12.56,"rotation":-180,"width":25,"height":28}},"tread2":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread3":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread4":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread5":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread6":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread7":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread8":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread9":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread10":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread11":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread12":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread13":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread14":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread15":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread16":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread17":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread18":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread19":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread20":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread21":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread22":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread23":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread24":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread25":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread26":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread27":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread28":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread29":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread30":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread31":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread32":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread33":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread34":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread35":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"tread36":{"tread":{"x":45.47,"y":-8.28,"rotation":-180,"width":96,"height":30}},"treads-path":{"treads-path":{"type":"path","closed":true,"lengths":[185.21,354.53,478.3,608.52,786,1058.49,1138.97,1223.96,1303.87,1388.23,1471.11,1551.64,1633.55,1713.27,1799.89,1882.28,2164.2,2326.85,2444.07,2584.91,2754.15,2940.62],"vertexCount":66,"vertices":[1,110,11.23,41.87,1,1,110,0.79,41.95,1,1,110,-34.72,42.24,1,1,56,-104.22,0.41,1,1,56,0.07,0.55,1,1,56,68.8,0.65,1,1,109,20.5,43.47,1,1,109,1.14,40.82,1,1,109,-27.38,36.85,1,1,93,147.07,105.01,1,1,93,96.21,96.63,1,1,93,43.87,87.72,1,1,93,16.18,103.35,1,1,93,-33.67,94.21,1,1,93,-99.36,81.25,1,1,93,-122.05,-1.7,1,1,93,-83.58,-47.93,1,1,93,-33.53,-109.37,1,1,97,-83.57,-66.1,1,1,97,-2.17,-67.9,1,2,97,56.68,-41.49,0.68,51,-24.31,-41.49,0.32,1,51,-26.59,16.7,1,1,51,-2.69,16.7,1,1,51,13.52,16.7,1,2,98,-52.42,-46.51,0.744,51,30.21,-46.52,0.256,1,98,-0.32,-68.92,1,2,98,52.09,-44.73,0.712,52,-28.91,-44.73,0.288,1,52,-22.81,16.24,1,1,52,-1.42,16.24,1,1,52,20.48,16.24,1,2,99,-47.21,-47.46,0.744,52,36.01,-47.46,0.256,1,99,-0.29,-69.66,1,2,99,45.24,-47.26,0.736,53,-37.49,-47.26,0.264,1,53,-23.76,15.28,1,1,53,-0.14,15.28,1,1,53,24.45,15.28,1,2,100,-47.37,-48.7,0.744,53,33.53,-48.7,0.256,1,100,-0.5,-70.4,1,2,100,49.09,-48.34,0.744,54,-33.58,-48.34,0.256,1,54,-20.89,15.84,1,1,54,-1.26,15.84,1,1,54,15.78,15.84,1,2,101,-52.5,-48.21,0.76,54,28.45,-48.22,0.24,1,101,-2.5,-68.92,1,2,101,55.72,-47.82,0.752,55,-28.88,-47.83,0.248,1,55,-21.64,16.7,1,1,55,-0.48,16.7,1,1,55,20.74,16.7,1,2,102,-53.65,-48.9,0.76,55,25.97,-48.9,0.24,1,102,2.28,-69.66,1,1,102,44.95,-69.74,1,1,94,76.03,-85.61,1,1,94,93.58,-42.24,1,1,94,118.67,19.75,1,1,94,78.59,76.62,1,1,94,37.27,95.07,1,1,94,31.45,97.67,1,1,94,-15.16,87.48,1,1,94,-79.8,92.52,1,1,94,-119.06,95.58,1,1,111,47.07,42.29,1,1,111,0.25,42.75,1,1,111,-29.64,43.29,1,1,57,-86.65,1.35,1,1,57,0.49,0.26,1,1,57,92.42,-0.9,1],"color":"ff8819ff"}},"wheel-big":{"wheel-big":{"width":191,"height":191}},"wheel-big-overlay1":{"wheel-big-overlay":{"width":186,"height":186}},"wheel-big-overlay2":{"wheel-big-overlay":{"width":186,"height":186}},"wheel-big2":{"wheel-big":{"width":191,"height":191}},"wheel-mid":{"wheel-mid":{"width":136,"height":136}},"wheel-mid-overlay":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid-overlay2":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid-overlay3":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid-overlay4":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid-overlay5":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid-overlay6":{"wheel-mid-overlay":{"width":136,"height":136}},"wheel-mid2":{"wheel-mid":{"width":136,"height":136}},"wheel-mid3":{"wheel-mid":{"width":136,"height":136}},"wheel-mid4":{"wheel-mid":{"width":136,"height":136}},"wheel-mid5":{"wheel-mid":{"width":136,"height":136}},"wheel-mid6":{"wheel-mid":{"width":136,"height":136}},"wheel-small":{"wheel-small":{"width":71,"height":71}},"wheel-small2":{"wheel-small":{"width":71,"height":71}},"wheel-small3":{"wheel-small":{"width":71,"height":71}}}}],"animations":{"drive":{"bones":{"tank-root":{"rotate":[{"time":2},{"time":2.0667,"value":1.99},{"time":2.5,"value":-15.63},{"time":2.6667,"value":-10.37,"curve":[2.718,-10.37,2.78,-8.34]},{"time":2.8333,"value":-6.13,"curve":[2.909,-2.8,2.974,0.8]},{"time":3,"value":1.84},{"time":3.0667,"value":5.32},{"time":3.1667,"value":10.99},{"time":3.2333,"value":9.73},{"time":3.4333,"value":-4.52,"curve":[3.474,-3.99,3.608,0.01]},{"time":3.6667,"value":0.01}],"translate":[{"curve":[1.019,0,1.608,-582.83,1.019,0,1.608,0]},{"time":2,"x":-1209.75},{"time":2.3333,"x":-1652.84,"y":26.05},{"time":2.5,"x":-1877.69,"y":71.5},{"time":2.6667,"x":-2053.37,"y":100.44},{"time":2.8333,"x":-2183.86,"y":97.42},{"time":3,"x":-2312.32,"y":74.12},{"time":3.0667,"x":-2340.68,"y":45.94},{"time":3.1333,"x":-2403.04,"y":17.04},{"time":3.1667,"x":-2439.84,"y":5.45},{"time":3.2333,"x":-2523.34,"y":-3.31},{"time":3.4333,"x":-2728.27,"y":-12.73},{"time":3.5,"x":-2795.65,"y":-6.14,"curve":[3.538,-2829.89,3.583,-2878.59,3.538,-4.93,3.583,-3.21]},{"time":3.6333,"x":-2938.53,"y":-1.09,"curve":[3.89,-3218.84,4.404,-3972.02,3.89,-0.79,4.404,0]},{"time":4.8333,"x":-3972.02},{"time":5,"x":-3991.31},{"time":5.3667,"x":-3973.94}]},"tread-collider1":{"translate":[{"time":2},{"time":2.0667,"y":9.99},{"time":2.1667,"y":37.69},{"time":2.3333,"y":53.45},{"time":2.5,"y":30.97},{"time":2.6667,"y":-2.89},{"time":2.8333,"y":-0.71},{"time":3.0667,"y":-13.64},{"time":3.1667,"y":59.3},{"time":3.2333,"y":48.2},{"time":3.4333,"y":-11.27},{"time":3.6333,"y":4.15}]},"tread-collider2":{"translate":[{"time":2},{"time":2.0667,"y":-2.83},{"time":2.1667,"y":-17.44},{"time":2.3333,"y":46.07},{"time":2.5,"y":19.45},{"time":2.6667,"y":13.46},{"time":2.8333,"y":-1.92,"curve":"stepped"},{"time":2.9667,"y":-1.92},{"time":3,"y":-13.17},{"time":3.0667,"y":-23.25},{"time":3.1667,"y":28.13},{"time":3.2333,"y":25.63},{"time":3.4333,"y":-1.52},{"time":3.6333,"y":1.15}]},"tread-collider3":{"translate":[{"time":2},{"time":2.0667,"y":-7.76},{"time":2.1667,"y":-16.61},{"time":2.5,"y":29.05},{"time":2.6667,"y":30.12},{"time":2.8333,"y":5.3},{"time":3,"y":-0.38},{"time":3.1667,"y":2.6},{"time":3.4333,"y":15.41},{"time":3.6333,"y":1.44}]},"tread-collider4":{"translate":[{"time":2},{"time":2.1667,"y":-6.72},{"time":2.3333,"y":-0.92},{"time":2.5,"y":18.37},{"time":2.6667,"y":38.77},{"time":2.8333,"y":30.6},{"time":3.1667,"y":12.61},{"time":3.2333,"y":-16},{"time":3.4333,"y":25.62},{"time":3.6333,"y":-0.68}]},"tread-collider5":{"translate":[{"time":2},{"time":2.1667,"y":3.35},{"time":2.3333,"y":22.17},{"time":2.6667,"y":13.35},{"time":2.8333,"y":39},{"time":3,"y":39.88},{"time":3.1667,"y":26.57},{"time":3.2333,"y":-10.15},{"time":3.4333,"y":35.98},{"time":3.6333,"y":-1.36}]},"wheel-mid-root6":{"translate":[{"time":2},{"time":2.1667,"y":5.61},{"time":2.3333,"y":27.21},{"time":2.5,"y":30.28},{"time":2.6667,"y":-2.81},{"time":2.8333,"y":19.59},{"time":3,"y":29.11},{"time":3.1667,"y":32.55},{"time":3.2333,"y":3.55},{"time":3.4333,"y":40.54},{"time":3.6333}]},"wheel-mid-root5":{"translate":[{"time":2},{"time":2.1667,"y":-7.46},{"time":2.3333,"y":9.53},{"time":2.6667,"y":36.78},{"time":2.8333,"y":46.11},{"time":3.1667,"y":7.55},{"time":3.2333,"y":-16.28},{"time":3.4333,"y":26.21},{"time":3.6333}]},"wheel-mid-root4":{"translate":[{"time":2},{"time":2.1667,"y":-13.98},{"time":2.3333,"y":-8.26},{"time":2.5,"y":24.27},{"time":2.6667,"y":34.42},{"time":2.8333,"y":8.88},{"time":3.1667,"y":10.32},{"time":3.2333,"y":-7.63},{"time":3.4333,"y":19.69},{"time":3.6333}]},"wheel-mid-root3":{"translate":[{"time":2},{"time":2.1667,"y":-21.14},{"time":2.3333,"y":22.83},{"time":2.5,"y":23.34},{"time":2.6667,"y":18.07},{"time":2.8333,"y":1.2},{"time":3.0667,"y":-13.36},{"time":3.1667,"y":15.48},{"time":3.2333,"y":13.34},{"time":3.4333,"y":6.4},{"time":3.6333}]},"wheel-mid-root2":{"translate":[{"time":2},{"time":2.0667,"y":-4.39},{"time":2.1667,"y":3.13},{"time":2.3333,"y":53.56},{"time":2.5,"y":16.65},{"time":2.6667,"y":8.39},{"time":3.0667,"y":-19.16},{"time":3.1667,"y":43.25},{"time":3.2333,"y":39.04},{"time":3.4333,"y":-8.61},{"time":3.6333}]},"wheel-mid-root1":{"translate":[{"time":2},{"time":2.0333,"y":22.64},{"time":2.0667,"y":53.65},{"time":2.1667,"y":71.18},{"time":2.5,"y":46.83},{"time":2.6667,"y":8.38},{"time":3.0667,"y":-10.03},{"time":3.1667,"y":72.71},{"time":3.2333,"y":64.74},{"time":3.4333,"y":-17.65},{"time":3.6333}]},"tank-body":{"rotate":[{"curve":[0.208,0,0.625,-4.39]},{"time":0.8333,"value":-4.39},{"time":2},{"time":2.1667,"value":-1.34},{"time":2.3333,"value":-6.23},{"time":2.5,"value":-5.45},{"time":2.9667,"value":-5.07},{"time":3.0667,"value":-2.39},{"time":3.1667,"value":-0.98},{"time":3.2333,"value":-1.1},{"time":3.4,"value":0.43,"curve":[3.433,0.43,3.483,-1.56]},{"time":3.5333,"value":-3.55,"curve":[3.675,-3.47,3.754,1.49]},{"time":3.8333,"value":1.93},{"time":4,"value":0.48},{"time":4.3333,"curve":[4.476,0.59,4.833,3.8]},{"time":5,"value":3.8,"curve":[5.286,3.8,5.35,-2.17]},{"time":5.4667,"value":-2.17},{"time":5.6,"value":-0.61}]},"wheel-big-root1":{"translate":[{"time":2},{"time":2.0667,"y":20.07},{"time":2.3333,"y":67.24},{"time":2.6667,"y":21.04},{"time":3,"y":10.28},{"time":3.1,"y":11.28},{"time":3.1667,"y":29.43},{"time":3.2333,"y":35.31},{"time":3.4333,"y":18.38},{"time":3.5}]},"tank-treads":{"rotate":[{},{"time":0.8333,"value":-2.4},{"time":2},{"time":2.0667,"value":1.72},{"time":2.4333,"value":-0.37},{"time":2.8},{"time":3,"value":-1.41},{"time":3.1667,"value":0.54},{"time":3.2667,"value":2.22,"curve":[3.348,2.22,3.392,-1.31]},{"time":3.4333,"value":-1.31},{"time":3.7333,"value":-1.14},{"time":4.3333,"curve":[4.476,0.35,4.833,2.24]},{"time":5,"value":2.24,"curve":[5.286,2.24,5.35,0]},{"time":5.4667}]},"cannon-target":{"translate":[{},{"time":0.8333,"y":121.95},{"time":2,"y":45.73}]},"wheel-big-root2":{"translate":[{"time":3.4333,"y":13.01}]},"wheel-big1":{"rotate":[{"curve":[0.51,0,0.804,57.81]},{"time":1,"value":120},{"time":1.2667,"value":240},{"time":1.5333,"value":360},{"time":1.7667,"value":480},{"time":2.0333,"value":600},{"time":2.2,"value":720},{"time":2.4,"value":840},{"time":2.5667,"value":960},{"time":2.7333,"value":1080},{"time":2.9333,"value":1200},{"time":3.1333,"value":1320},{"time":3.3333,"value":1440},{"time":3.5,"value":1560},{"time":3.6667,"value":1680},{"time":3.8667,"value":1800},{"time":4.0667,"value":1920},{"time":4.2667,"value":2040},{"time":4.5,"value":2160,"curve":[4.563,2194.34,4.695,2225.3]},{"time":4.8333,"value":2247.67}]},"wheel-mid1":{"rotate":[{"curve":[0.459,0,0.724,57.81]},{"time":0.9,"value":120},{"time":1.1667,"value":240},{"time":1.4333,"value":360},{"time":1.6333,"value":480},{"time":1.8333,"value":600},{"time":2,"value":720},{"time":2.1333,"value":840},{"time":2.2667,"value":960},{"time":2.4,"value":1080},{"time":2.5333,"value":1200},{"time":2.6667,"value":1320},{"time":2.8333,"value":1440},{"time":2.9667,"value":1560},{"time":3.1,"value":1680},{"time":3.2333,"value":1800},{"time":3.3667,"value":1920},{"time":3.5,"value":2040},{"time":3.6333,"value":2160},{"time":3.7667,"value":2280},{"time":3.9,"value":2400},{"time":4.0333,"value":2520},{"time":4.1667,"value":2640},{"time":4.3,"value":2760},{"time":4.4667,"value":2880,"curve":[4.538,2949.2,4.742,3000]},{"time":4.8333,"value":3000}]},"wheel-small1":{"rotate":[{"curve":[0.34,0,0.536,57.81]},{"time":0.6667,"value":120},{"time":0.8667,"value":240},{"time":1.0333,"value":360},{"time":1.1667,"value":480},{"time":1.3,"value":600},{"time":1.4333,"value":720},{"time":1.5333,"value":840},{"time":1.6333,"value":960},{"time":1.7333,"value":1080},{"time":1.8333,"value":1200},{"time":1.9333,"value":1320},{"time":2.0333,"value":1440},{"time":2.1333,"value":1560},{"time":2.2333,"value":1680},{"time":2.3333,"value":1800},{"time":2.4333,"value":1920},{"time":2.5333,"value":2040},{"time":2.6333,"value":2160},{"time":2.7333,"value":2280},{"time":2.8333,"value":2400},{"time":2.9333,"value":2520},{"time":3.0333,"value":2640},{"time":3.1333,"value":2760},{"time":3.2333,"value":2880},{"time":3.3333,"value":3000},{"time":3.4333,"value":3120},{"time":3.5333,"value":3240},{"time":3.6333,"value":3360},{"time":3.7333,"value":3480},{"time":3.8333,"value":3600},{"time":3.9333,"value":3720},{"time":4.0333,"value":3840},{"time":4.1333,"value":3960},{"time":4.2333,"value":4080},{"time":4.3333,"value":4200},{"time":4.4333,"value":4320},{"time":4.6667,"value":4440},{"time":4.9,"value":4490}]},"wheel-small-root1":{"translate":[{"time":2},{"time":2.1333,"y":12.37},{"time":2.4667,"y":32.37},{"time":2.7333,"y":-5.27},{"time":2.9667,"y":14.31},{"time":3.1667,"y":19.54},{"time":3.4667,"y":7.5},{"time":4.3667,"y":-2.4}]},"wheel-small-root2":{"translate":[{"time":2},{"time":2.9,"y":5.26},{"time":3.1667,"y":10.67},{"time":3.4667,"y":-4.71}]},"wheel-small-root3":{"translate":[{"time":2},{"time":2.4667,"y":-10.56},{"time":2.9,"y":-16.08},{"time":3.1667,"y":10.12},{"time":3.4667,"y":4.1},{"time":4.3667,"y":-0.03}]},"antenna1":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"antenna2":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"antenna3":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"antenna4":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"antenna5":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"antenna6":{"rotate":[{},{"time":0.3333,"value":1.05,"curve":[0.731,1.05,1.008,-4.55]},{"time":1.2333,"value":-4.55,"curve":[1.35,-4.55,1.583,0.53]},{"time":1.7,"value":0.53},{"time":2,"value":-2.13},{"time":2.2,"value":4.71},{"time":2.3667,"value":-7.26},{"time":2.5667,"value":2.26},{"time":2.7333,"value":-3.39},{"time":3.0667,"value":-5.53},{"time":3.2333,"value":7.78},{"time":3.4667,"value":-5.99},{"time":3.7,"value":3.11},{"time":3.9,"value":-3.05},{"time":4.1,"value":0.31},{"time":4.3,"value":-3.06},{"time":4.5333,"value":0.36},{"time":4.8667,"value":4.94,"curve":[4.925,4.94,5.042,-2.38]},{"time":5.1,"value":-2.38},{"time":5.2667,"value":3.65},{"time":5.4,"value":-3.04},{"time":5.5,"value":1.49},{"time":5.6,"value":-1.86},{"time":5.7,"value":0.42}]},"machinegun":{"rotate":[{"value":8.07,"curve":"stepped"},{"time":2.0667,"value":8.07},{"time":2.1667,"value":3.11},{"time":2.5667,"value":-10.99,"curve":"stepped"},{"time":3.1333,"value":-10.99},{"time":3.2667,"value":18.18},{"time":3.4333,"value":2.75,"curve":"stepped"},{"time":4.7,"value":2.75},{"time":4.9,"value":8.07}]}},"path":{"treads-path":{"position":[{"curve":[0.984,0,1.588,0.1788]},{"time":2,"value":0.385,"curve":[2.023,0.3916,2.045,0.3983]},{"time":2.0667,"value":0.405},{"time":2.3333,"value":0.555},{"time":2.5,"value":0.605},{"time":2.6667,"value":0.685},{"time":2.8333,"value":0.745},{"time":3,"value":0.785},{"time":3.0667,"value":0.8},{"time":3.1333,"value":0.825},{"time":3.1667,"value":0.835},{"time":3.2333,"value":0.87},{"time":3.5,"value":0.98,"curve":[3.726,1.0474,4.335,1.4]},{"time":4.8333,"value":1.4}]}}},"shoot":{"slots":{"rock":{"attachment":[{}]},"smoke-glow":{"rgba":[{"time":0.1333,"color":"ffffffff"},{"time":0.1667,"color":"ffbc8af4"},{"time":0.2,"color":"fc8e8e90"},{"time":0.2667,"color":"fa3e3e1e"}],"attachment":[{"time":0.0667,"name":"smoke-glow"},{"time":0.3}]},"smoke-puff1-bg":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":1.0333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.0667,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg2":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg3":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg4":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":0.9,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg5":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":0.9,"light":"ffd50c00","dark":"604a3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg6":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg7":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3333,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg8":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4333,"light":"ffd50cff","dark":"604b3f"},{"time":0.9333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg9":{"rgba2":[{"time":0.1333,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.5333,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg10":{"rgba2":[{"time":0.1333,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.5333,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604a3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg11":{"rgba2":[{"time":0.1333,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7667,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg12":{"rgba2":[{"time":0.3333,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":0.8667,"light":"ffd50c00","dark":"604a3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg13":{"rgba2":[{"time":0.3667,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":1,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg14":{"rgba2":[{"time":0.4333,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":1.0667,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.4333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg15":{"rgba2":[{"time":0.4,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.8333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.4,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg16":{"rgba2":[{"time":0.4,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.4,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg17":{"rgba2":[{"time":0.2333,"light":"ffd50cff","dark":"534035"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4,"light":"ffd50cff","dark":"604b3f"},{"time":0.6667,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.2333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg18":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.5,"light":"ffd50cff","dark":"604b3f"},{"time":0.8,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.2333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg20":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.8,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3333,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg21":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7,"light":"ffd50c00","dark":"604b3f"}]},"smoke-puff1-bg22":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7,"light":"ffd50c00","dark":"604b3f"}]},"smoke-puff1-bg23":{"rgba2":[{"time":0.0667,"light":"ffd50cff","dark":"3b2c23"},{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.5,"light":"ffd50cff","dark":"604b3f"},{"time":0.7667,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg24":{"rgba2":[{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg25":{"rgba2":[{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":1,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg26":{"rgba2":[{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.6,"light":"ffd50cff","dark":"604b3f"},{"time":0.9333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff01-bg"}]},"smoke-puff1-bg27":{"rgba2":[{"time":0.3,"light":"ffd50cff","dark":"604b3f","curve":"stepped"},{"time":0.4667,"light":"ffd50cff","dark":"604b3f"},{"time":0.7333,"light":"ffd50c00","dark":"604b3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff01-bg"}]},"smoke-puff1-fg":{"rgba2":[{"time":0.0667,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1333,"light":"fde252ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":1.0333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.0667,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg2":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg3":{"rgba2":[{"time":0.1333,"light":"ffe457ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg4":{"rgba2":[{"time":0.1333,"light":"fae781ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":0.9,"light":"ac8c7500","dark":"604a3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg5":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":0.9,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg6":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg7":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3333,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ac8c7500","dark":"604a3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg8":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4333,"light":"ab764cff","dark":"ac8d75"},{"time":0.9333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg9":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.5333,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg10":{"rgba2":[{"time":0.1333,"light":"fce35dff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.5333,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ac8c7500","dark":"604a3f"}],"attachment":[{"time":0.1333,"name":"smoke-puff01-fg"},{"time":0.1667,"name":"smoke-puff02-fg"},{"time":0.2,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg11":{"rgba2":[{"time":0.3333,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7667,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg12":{"rgba2":[{"time":0.3667,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":0.8667,"light":"ac8c7500","dark":"604a3f"}],"attachment":[{"time":0.3667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg13":{"rgba2":[{"time":0.3667,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":1,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg14":{"rgba2":[{"time":0.4333,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":1.0667,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.4333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg15":{"rgba2":[{"time":0.4,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.8333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.4,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg16":{"rgba2":[{"time":0.4,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.4,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg17":{"rgba2":[{"time":0.2333,"light":"e3c05eff","dark":"ab7e59"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4,"light":"ab764cff","dark":"ac8d75"},{"time":0.6667,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.2333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg18":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.5,"light":"ab764cff","dark":"ac8d75"},{"time":0.7667,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.2333,"name":"smoke-puff03-fg"},{"time":0.2667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg20":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.8,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3333,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg21":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7,"light":"ab764c00","dark":"ac8d75"}]},"smoke-puff1-fg22":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7,"light":"ab764c00","dark":"ac8d75"}]},"smoke-puff1-fg23":{"rgba2":[{"time":0.1333,"light":"ffdf31ff","dark":"ff0000"},{"time":0.1667,"light":"ffe568ff","dark":"e26425"},{"time":0.2,"light":"ffe568ff","dark":"ab774c"},{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.5,"light":"ab764cff","dark":"ac8d75"},{"time":0.7667,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg24":{"rgba2":[{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg25":{"rgba2":[{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":1,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg26":{"rgba2":[{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.6,"light":"ab764cff","dark":"ac8d75"},{"time":0.9333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3667,"name":"smoke-puff04-fg"}]},"smoke-puff1-fg27":{"rgba2":[{"time":0.3,"light":"ab764cff","dark":"ac8d75","curve":"stepped"},{"time":0.4667,"light":"ab764cff","dark":"ac8d75"},{"time":0.7333,"light":"ab764c00","dark":"ac8d75"}],"attachment":[{"time":0.3667,"name":"smoke-puff04-fg"}]},"tank-glow":{"rgba":[{"time":0.0667,"color":"fc994d84"},{"time":0.1333,"color":"f5b16bc8","curve":[0.221,0.96,0.252,0.98,0.221,0.69,0.252,0.62,0.221,0.42,0.252,0.33,0.221,0.78,0.252,0.32]},{"time":0.2667,"color":"fc994c30"}],"attachment":[{"time":0.0667,"name":"smoke-glow"},{"time":0.2667}]}},"bones":{"cannon":{"translate":[{"time":0.0667},{"time":0.1667,"x":34.77,"y":0.9},{"time":0.2667,"x":1.3}]},"tank-body":{"rotate":[{"time":0.0667},{"time":0.1667,"value":-4.29,"curve":[0.2,-4.29,0.267,2.37]},{"time":0.3,"value":2.37,"curve":[0.333,2.37,0.4,0]},{"time":0.4333}],"translate":[{"time":0.0667},{"time":0.1667,"x":31.04,"y":1.67,"curve":[0.2,31.04,0.267,-12.05,0.2,1.67,0.267,-0.23]},{"time":0.3,"x":-12.05,"y":-0.23},{"time":0.3667}]},"tank-treads":{"rotate":[{"time":0.0667},{"time":0.1667,"value":-3.08},{"time":0.3,"value":-0.42}]},"smoke1":{"rotate":[{"time":0.0667},{"time":0.1333,"value":2.88},{"time":0.1667,"value":2.34},{"time":0.2,"value":124.36},{"time":0.2667,"value":142.26},{"time":0.3333,"value":86.78},{"time":0.4667,"value":128.79},{"time":0.6333,"value":146.22},{"time":1.0333,"value":210.7}],"translate":[{"time":0.0667,"x":-9.69,"y":1.05},{"time":0.1333,"x":7.53,"y":1.21},{"time":0.1667,"x":3.26,"y":4.07},{"time":0.2,"x":29.64,"y":-17.46},{"time":0.2667,"x":86.97,"y":17.83},{"time":0.3333,"x":193.74,"y":-38.98},{"time":0.4,"x":341.67,"y":-39.52},{"time":0.6333,"x":393.24,"y":-4.01},{"time":1.0333,"x":410.76,"y":6.35}],"scale":[{"time":0.0667},{"time":0.1333,"x":3.171,"y":0.756},{"time":0.1667,"x":3.488,"y":1.279},{"time":0.2,"x":5.151,"y":2.369},{"time":0.2667,"x":4.735,"y":3.622},{"time":0.3,"x":4.735,"y":4.019},{"time":0.3333,"x":4.613,"y":3.339},{"time":0.3667,"x":4.918,"y":3.561},{"time":0.4,"x":4.6,"y":4.263},{"time":0.6333,"x":4.449,"y":2.62},{"time":1.0333,"x":3.09,"y":1.447}]},"smoke2":{"rotate":[{"time":0.1667,"value":31.55},{"time":0.3,"value":-22.63},{"time":0.4667,"value":142.89},{"time":0.6,"value":253.78},{"time":0.8333,"value":299.28}],"translate":[{"time":0.1667,"x":17.26,"y":4.86},{"time":0.2333,"x":141.22,"y":27.27},{"time":0.3,"x":178.86,"y":56.63},{"time":0.3667,"x":200.46,"y":71.05},{"time":0.4333,"x":213.12,"y":78.39},{"time":0.6333,"x":221.44,"y":73.1},{"time":0.8333,"x":223.32,"y":73.74}],"scale":[{"time":0.1667,"x":1.34,"y":1.34},{"time":0.2333,"x":2.81,"y":1.317},{"time":0.3,"x":2.932,"y":1.374},{"time":0.4667,"x":1.247,"y":0.639},{"time":0.8333,"x":0.778,"y":0.515}]},"smoke3":{"rotate":[{"time":0.1667,"value":-5.54},{"time":0.2333,"value":0.2},{"time":0.3333,"value":20.27},{"time":0.4,"value":31.36},{"time":0.4667,"value":68.52},{"time":0.5333,"value":99.74},{"time":0.6333,"value":145.8},{"time":0.8333,"value":193.28}],"translate":[{"time":0.1333,"x":1.17,"y":8.53},{"time":0.1667,"x":37.53,"y":4.84},{"time":0.2,"x":67.99,"y":9.85},{"time":0.2333,"x":134.14,"y":-13.5},{"time":0.2667,"x":181.31,"y":-19.93},{"time":0.3,"x":238.28,"y":-8.82},{"time":0.3333,"x":268.51,"y":-25.75},{"time":0.3667,"x":359.06,"y":-28.49},{"time":0.4,"x":432.96,"y":-24.11},{"time":0.4667,"x":452.16,"y":-16.73},{"time":0.6333,"x":456.28,"y":-0.41},{"time":0.8333,"x":454.14,"y":16.41}],"scale":[{"time":0.1333,"x":2.258,"y":1.366},{"time":0.1667,"x":2.656,"y":1.47},{"time":0.2,"x":3.202,"y":1.772},{"time":0.2333,"x":3.202,"y":1.93},{"time":0.2667,"x":3.124,"y":1.896},{"time":0.3,"x":3.593,"y":1.896},{"time":0.3333,"x":2.363,"y":1.247},{"time":0.3667,"x":1.845,"y":0.973},{"time":0.4,"x":1.754,"y":0.926},{"time":0.4333,"x":1.448,"y":0.695},{"time":0.4667,"x":1.441,"y":0.688},{"time":0.5333,"x":0.865,"y":0.456},{"time":0.7,"x":0.86,"y":0.454},{"time":0.8333,"x":0.211,"y":0.111}]},"smoke4":{"rotate":[{"time":0.1667,"value":-20.35},{"time":0.2333,"value":18.5},{"time":0.3,"value":57.77},{"time":0.4,"value":105.85},{"time":0.6,"value":161.28},{"time":0.9,"value":208.43}],"translate":[{"time":0.1667,"x":35.95,"y":25.54},{"time":0.2333,"x":34.17,"y":1.87},{"time":0.3,"x":136.7,"y":21.5},{"time":0.4,"x":138.61,"y":34.8},{"time":0.6,"x":160.38,"y":37.13},{"time":0.9,"x":196.41,"y":30.36}],"scale":[{"time":0.1667,"x":2.751,"y":1.754},{"time":0.2333,"x":3.486,"y":2.224},{"time":0.2667,"x":3.486,"y":2.586},{"time":0.3,"x":3.847,"y":2.109},{"time":0.4,"x":1.96,"y":1.074},{"time":0.9,"x":0.825,"y":0.452}]},"smoke5":{"rotate":[{"time":0.2,"value":23.09},{"time":0.2667,"value":12.24},{"time":0.3333,"value":36.92},{"time":0.4333,"value":-37.33},{"time":0.5333,"value":-0.66},{"time":0.9,"value":64.02}],"translate":[{"time":0.1333},{"time":0.2333,"x":123.76,"y":19.44},{"time":0.3,"x":239.08,"y":-49.72},{"time":0.3667,"x":280.23,"y":-51.46},{"time":0.7,"x":340.62,"y":-20.09},{"time":0.9,"x":349.18,"y":-5.25}],"scale":[{"time":0.1333},{"time":0.1667,"x":1.718,"y":1.718},{"time":0.2,"x":2.109,"y":2.109},{"time":0.2333,"x":1.781,"y":2.183},{"time":0.2667,"x":2.148,"y":2.633},{"time":0.3333,"x":2.234,"y":2.738},{"time":0.3667,"x":1.366,"y":2.148},{"time":0.4,"x":0.97,"y":1.524},{"time":0.4333,"x":1.078,"y":1.157},{"time":0.4667,"x":1.126,"y":1.005},{"time":0.7,"x":1.241,"y":1.301},{"time":0.9,"x":0.709,"y":0.893}]},"smoke6":{"rotate":[{"time":0.1667,"value":-37.43},{"time":0.2333,"value":-18.36},{"time":0.3333,"value":28.58},{"time":0.4,"value":150.54},{"time":0.7,"value":301.59}],"translate":[{"time":0.1333},{"time":0.2,"x":68.04,"y":16.15},{"time":0.2667,"x":214.52,"y":13.25},{"time":0.3333,"x":285.4,"y":17.95},{"time":0.4,"x":202.91,"y":101.43},{"time":0.4667,"x":189.25,"y":116.39},{"time":0.7,"x":182.77,"y":137.4}],"scale":[{"time":0.1333},{"time":0.1667,"x":1.152,"y":1.288},{"time":0.2,"x":1.939,"y":2.168},{"time":0.2333,"x":2.278,"y":2.223},{"time":0.2667,"x":2.023,"y":1.974},{"time":0.3,"x":2.644,"y":1.974},{"time":0.4,"x":1.539,"y":1.425},{"time":0.4667,"x":1.14,"y":0.939},{"time":0.7,"x":0.215,"y":0.161}]},"smoke7":{"rotate":[{"time":0.1667,"value":-243.11},{"time":0.4,"value":-182.02},{"time":0.8333,"value":-83.02}],"translate":[{"time":0.1333,"x":3.19,"y":-6.53},{"time":0.1667,"x":44.54,"y":1.12},{"time":0.2,"x":65.84,"y":6.02},{"time":0.2333,"x":173.84,"y":97.51},{"time":0.4,"x":167.39,"y":74.58},{"time":0.8333,"x":227.77,"y":84.64}],"scale":[{"time":0.1333,"x":0.878,"y":0.878},{"time":0.1667,"x":1.235,"y":1.235},{"time":0.2,"x":1.461,"y":1.461},{"time":0.2333,"x":1.114,"y":1.114},{"time":0.3333,"x":1.067,"y":1.067},{"time":0.4667,"x":0.81,"y":0.753},{"time":0.8333,"x":0.52,"y":0.484}]},"smoke8":{"rotate":[{"time":0.1667,"value":-156.52},{"time":0.2667,"value":-154.05},{"time":0.3333,"value":-108.35},{"time":0.6,"value":-93.14},{"time":0.9333,"value":-70.89}],"translate":[{"time":0.1667,"x":20.72,"y":0.25},{"time":0.2333,"x":46.1,"y":-10.06},{"time":0.3,"x":149.77,"y":0.92},{"time":0.3667,"x":241.21,"y":49.01},{"time":0.5333,"x":276,"y":58.76},{"time":0.7,"x":292.02,"y":65.91},{"time":0.9333,"x":308.7,"y":69.51}],"scale":[{"time":0.1333,"y":1.174},{"time":0.1667,"x":1.813,"y":1.438},{"time":0.2,"x":1.813,"y":1.878},{"time":0.2333,"x":1.211,"y":1.878},{"time":0.2667,"x":1.584,"y":1.596},{"time":0.3,"x":1.958,"y":1.878},{"time":0.4667,"x":1.139,"y":0.958},{"time":0.9333,"x":0.839,"y":0.591}]},"smoke9":{"rotate":[{"time":0.1333,"value":-44.34},{"time":0.1667,"value":14.73},{"time":0.2333,"value":116.07},{"time":0.2667,"value":118.29},{"time":0.3333,"value":148.13},{"time":0.3667,"value":172.74},{"time":0.4,"value":235.69},{"time":0.4333,"value":283.36},{"time":0.7667,"value":358.76}],"translate":[{"time":0.1333,"x":-3.49,"y":0.04},{"time":0.2,"x":87.4,"y":-7.97},{"time":0.2667,"x":233.69,"y":-33.86},{"time":0.3333,"x":296.44,"y":-30.87},{"time":0.4,"x":390.8,"y":4},{"time":0.4667,"x":391.42,"y":13.17},{"time":0.6333,"x":413.3,"y":36.13},{"time":0.7667,"x":408.59,"y":40.75}],"scale":[{"time":0.1333,"x":1.289,"y":1.501},{"time":0.2,"x":1.751,"y":2.039},{"time":0.2667,"x":2.064,"y":2.347},{"time":0.3333,"x":1.822,"y":2.072},{"time":0.4,"x":1.296,"y":1.045},{"time":0.4667,"x":1.872,"y":1.526},{"time":0.6333,"x":1.181,"y":1.037},{"time":0.7667,"x":0.716,"y":0.615}]},"smoke10":{"rotate":[{"time":0.1333,"value":12.16},{"time":0.2,"value":49.19},{"time":0.2667,"value":33.17},{"time":0.3333,"value":42.23},{"time":0.4,"value":11.69},{"time":0.4667,"value":41.83},{"time":0.5333,"value":54.86},{"time":0.6333,"value":75.25},{"time":0.8333,"value":126.4}],"translate":[{"time":0.1333,"x":7.74,"y":10.25},{"time":0.2,"x":42.9,"y":72.89},{"time":0.2667,"x":221.58,"y":82.27},{"time":0.3333,"x":297.31,"y":85.39},{"time":0.4,"x":322.91,"y":81.04},{"time":0.4667,"x":346.62,"y":76.68},{"time":0.6667,"x":377.46,"y":81.85},{"time":0.8333,"x":402.18,"y":101.03}],"scale":[{"time":0.1333,"x":0.537,"y":1.062},{"time":0.1667,"x":1.042,"y":0.841},{"time":0.2,"x":1.937,"y":1.563},{"time":0.2333,"x":1.937,"y":2.176},{"time":0.2667,"x":2.254,"y":2.532},{"time":0.3,"x":2.24,"y":2.516},{"time":0.5333,"x":1.731,"y":1.882},{"time":0.8333,"x":0.855,"y":0.867}]},"smoke-glow":{"translate":[{"time":0.0667,"x":-57.08,"y":0.01},{"time":0.1,"x":-49.68,"y":-1.46},{"time":0.1333,"x":6.3,"y":-2.92},{"time":0.1667,"x":31.57,"y":0.44},{"time":0.2,"x":34.04,"y":0.27},{"time":0.2333,"x":109.29,"y":1.02},{"time":0.4,"x":119.89,"y":1.01},{"time":0.4333,"x":135.2,"y":1.03},{"time":0.4667,"x":152.86,"y":1.06},{"time":0.5333,"x":164.64,"y":1.07},{"time":0.6,"x":179.94,"y":1.09},{"time":0.6333,"x":190.54,"y":1.1}],"scale":[{"time":0.0667,"x":0.233,"y":0.233},{"time":0.1,"x":0.42,"y":0.288},{"time":0.1333,"x":1.669,"y":1.072},{"time":0.1667,"x":1.669,"y":1.785,"curve":"stepped"},{"time":0.2,"x":1.669,"y":1.785},{"time":0.2333,"x":2.544,"y":1.785},{"time":0.4333,"x":3.48,"y":2.22},{"time":0.4667,"x":4.337,"y":2.655}]},"smoke11":{"rotate":[{"time":0.4,"value":47.07},{"time":0.4333,"value":109.71},{"time":0.4667,"value":164.62},{"time":0.8333,"value":276.93}],"translate":[{"time":0.3333,"x":280.31,"y":126.85},{"time":0.4,"x":296.27,"y":125.62},{"time":0.4667,"x":312.45,"y":131.57},{"time":0.6667,"x":310.5,"y":149.67},{"time":0.8333,"x":307.08,"y":153.94}],"scale":[{"time":0.3333,"x":1.491,"y":1.491},{"time":0.4667,"x":1.144,"y":0.948},{"time":0.5667,"x":0.491,"y":0.491},{"time":0.8333,"x":0.985,"y":0.91}]},"smoke12":{"rotate":[{"time":0.3667,"value":-37.96},{"time":0.4333,"value":28.55},{"time":0.5333,"value":108.53},{"time":0.8667,"value":191.85}],"translate":[{"time":0.3667,"x":390.22,"y":-1.06},{"time":0.4333,"x":411.78,"y":26.39},{"time":0.5333,"x":428.12,"y":56.28},{"time":0.8667,"x":444.34,"y":68.06}],"scale":[{"time":0.3667,"x":2.006,"y":1.821},{"time":0.5333,"x":1.719,"y":1.293},{"time":0.7333,"x":1.562,"y":1.304},{"time":0.8667,"x":0.727,"y":0.637}]},"smoke13":{"rotate":[{"time":0.3667,"value":305.8},{"time":0.4,"value":478.49},{"time":0.4333,"value":537.45},{"time":0.4667,"value":573.84},{"time":0.5333,"value":596.4},{"time":0.7,"value":622.3},{"time":1,"value":657.95}],"translate":[{"time":0.3667,"x":331.84,"y":-25.82},{"time":0.4,"x":417.88,"y":-42.62},{"time":0.4667,"x":451.61,"y":-42.21},{"time":0.5333,"x":453.81,"y":-37.03},{"time":0.6,"x":451.86,"y":-31.89},{"time":0.7,"x":453.37,"y":-27.28},{"time":1,"x":454.04,"y":-17.89}],"scale":[{"time":0.3667,"x":4.509,"y":3.114},{"time":0.4,"x":3.673,"y":2.537},{"time":0.4333,"x":4.201,"y":2.638},{"time":0.4667,"x":4.27,"y":2.399},{"time":0.6,"x":2.798,"y":1.932},{"time":0.8333,"x":2.316,"y":1.599},{"time":1,"x":1.081,"y":0.746}]},"smoke14":{"rotate":[{"time":0.4333,"value":271.03},{"time":0.7,"value":299.97},{"time":1.0667,"value":331.16}],"translate":[{"time":0.4333,"x":371.68,"y":-29.8},{"time":0.7667,"x":400.59,"y":-44.36},{"time":1.0667,"x":432.26,"y":-44.79}],"scale":[{"time":0.4333,"x":4.011,"y":3.366},{"time":0.7667,"x":2.071,"y":1.624},{"time":1.0667,"x":1.798,"y":1.111}]},"smoke15":{"rotate":[{"time":0.4,"value":111.75},{"time":0.4667,"value":171.93},{"time":0.6,"value":256.95},{"time":0.8333,"value":299.15}],"translate":[{"time":0.4,"x":266.71,"y":-53.04},{"time":0.4333,"x":290.84,"y":-51.43},{"time":0.5333,"x":305.65,"y":-44.32},{"time":0.6667,"x":318.96,"y":-38.95},{"time":0.8333,"x":342.65,"y":-27.33}],"scale":[{"time":0.4,"x":2.749,"y":2.095},{"time":0.4333,"x":3.302,"y":2.289},{"time":0.4667,"x":2.591,"y":1.895},{"time":0.5333,"x":1.777,"y":1.354},{"time":0.7,"x":1.932,"y":1.267},{"time":0.8333,"x":1.002,"y":1.546}]},"smoke16":{"rotate":[{"time":0.4,"value":89.78},{"time":0.4667,"value":137.83},{"time":0.5333,"value":193.49},{"time":0.6,"value":235.26},{"time":0.6333,"value":286.8}],"translate":[{"time":0.4,"x":217.23,"y":-21.45},{"time":0.4667,"x":249.95,"y":-13.73},{"time":0.5333,"x":264.96,"y":-9.87},{"time":0.6,"x":278.95,"y":6.37},{"time":0.6333,"x":245.65,"y":11.77}],"scale":[{"time":0.4,"x":2.265,"y":1.859},{"time":0.4333,"x":2.621,"y":1.955},{"time":0.4667,"x":1.953,"y":1.538},{"time":0.6,"x":1.005,"y":0.825},{"time":0.6333,"x":0.387,"y":0.318}]},"smoke17":{"rotate":[{"time":0.2333,"value":99.02},{"time":0.3,"value":58.06},{"time":0.3333,"value":34.05},{"time":0.3667,"value":-17.34},{"time":0.6667,"value":-62.36}],"translate":[{"time":0.2333,"x":18.91,"y":-62.91},{"time":0.3,"x":2.43,"y":-61.54},{"time":0.3333,"x":1.89,"y":-36.55},{"time":0.3667,"x":6.97,"y":-29.52},{"time":0.4333,"x":10.78,"y":-20.55},{"time":0.6667,"x":18.65,"y":-13.19}],"scale":[{"time":0.2333,"x":1.915,"y":1.915},{"time":0.3,"x":1.509,"y":1.509},{"time":0.3333,"x":1.01,"y":1.01},{"time":0.3667,"x":0.715,"y":0.715},{"time":0.4333,"x":0.949,"y":0.721},{"time":0.5667,"x":0.785,"y":0.74}]},"smoke18":{"rotate":[{"time":0.2333,"value":141.75},{"time":0.2667,"value":134.51},{"time":0.3333,"value":249.12},{"time":0.5,"value":363.82},{"time":0.7333,"value":450.54}],"translate":[{"time":0.2333,"x":60.81,"y":56.17},{"time":0.2667,"x":68.74,"y":69.4},{"time":0.3333,"x":76.85,"y":69.07},{"time":0.5,"x":101.49,"y":89.87},{"time":0.7333,"x":118.58,"y":101.16}],"scale":[{"time":0.2333,"x":2.288,"y":2.288},{"time":0.2667,"x":2.288,"y":1.628},{"time":0.3,"x":1.524,"y":1.308},{"time":0.5,"x":1.757,"y":1.385},{"time":0.5333,"x":2.08,"y":1.51},{"time":0.7333,"x":1.405,"y":0.896}]},"smoke20":{"rotate":[{"time":0.3333,"value":95.16},{"time":0.3667,"value":130.42},{"time":0.4,"value":170.7},{"time":0.4333,"value":266.75},{"time":0.4667,"value":299.82},{"time":0.5333,"value":326.88},{"time":0.6,"value":350.8},{"time":0.9,"value":403.14}],"translate":[{"time":0.3333,"x":124.61,"y":-46.55},{"time":0.5333,"x":173.8,"y":-36.62},{"time":0.7,"x":186.5,"y":-35.41},{"time":0.9,"x":188.56,"y":-37.75}],"scale":[{"time":0.3333,"x":3.346,"y":2.654},{"time":0.3667,"x":2.661,"y":2.111},{"time":0.4333,"x":2.751,"y":1.984},{"time":0.4667,"x":3.059,"y":2.21},{"time":0.5333,"x":2.159,"y":1.712},{"time":0.7,"x":1.601,"y":1.27},{"time":0.9,"x":1.679,"y":0.856}]},"smoke23":{"rotate":[{"time":0.3,"value":115.12},{"time":0.3667,"value":79.01},{"time":0.7667,"value":6.96}],"translate":[{"time":0.3,"x":75.15,"y":-50.92},{"time":0.3667,"x":59.33,"y":-53.52},{"time":0.7667,"x":39.68,"y":-48.64}],"scale":[{"time":0.3,"x":3.331,"y":2.096},{"time":0.4333,"x":2.4,"y":2.006},{"time":0.5,"x":2.555,"y":2.094},{"time":0.7667,"x":1.35,"y":1.241}]},"antenna1":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"antenna2":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"antenna3":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"antenna4":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"antenna5":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"antenna6":{"rotate":[{"time":0.0667},{"time":0.2,"value":11.78},{"time":0.3,"value":-9.52},{"time":0.4,"value":8.07},{"time":0.5,"value":-4.45},{"time":0.6,"value":1.54},{"time":0.7,"value":-0.34}]},"smoke24":{"rotate":[{"time":0.3,"value":71.32},{"time":0.3667,"value":112.39},{"time":0.4667,"value":159.56},{"time":0.7,"value":224.21}],"translate":[{"time":0.3,"x":90.72,"y":-18.79},{"time":0.3667,"x":149.69,"y":-7.78},{"time":0.4667,"x":176.26,"y":12.31},{"time":0.7,"x":184.07,"y":31.75}],"scale":[{"time":0.3,"x":2.906,"y":2.311},{"time":0.4333,"x":3.567,"y":2.58},{"time":0.4667,"x":3.157,"y":2.41},{"time":0.7,"x":1.705,"y":1.356}]},"smoke25":{"rotate":[{"time":0.3667,"value":91.25},{"time":0.4333,"value":117.56},{"time":0.6333,"value":150.9},{"time":1,"value":189.47}],"translate":[{"time":0.3667,"x":187.21,"y":-51.18},{"time":0.5333,"x":245.48,"y":-46.28},{"time":0.6667,"x":277.36,"y":-43.12},{"time":1,"x":313.27,"y":-38.14}],"scale":[{"time":0.3667,"x":3.606,"y":2.657},{"time":0.4333,"x":4.166,"y":2.792},{"time":0.5333,"x":3.09,"y":2.091},{"time":1,"x":3.062,"y":1.801}]},"smoke26":{"rotate":[{"time":0.3667,"value":10.64},{"time":0.4,"value":60.85},{"time":0.4667,"value":89.45},{"time":0.7,"value":125.01},{"time":0.9333,"value":155.24}],"translate":[{"time":0.3667,"x":442.07,"y":-13.19},{"time":0.4,"x":453.7,"y":0.81},{"time":0.4667,"x":443.57,"y":-6.95},{"time":0.7,"x":460.97,"y":15.79},{"time":0.9333,"x":465.22,"y":20.92}],"scale":[{"time":0.3667,"x":2.726,"y":2.726},{"time":0.4333,"x":3.729,"y":2.822},{"time":0.4667,"x":3.398,"y":2.441},{"time":0.7,"x":4.324,"y":3.159},{"time":0.9,"x":1.977,"y":1.48}]},"smoke27":{"rotate":[{"time":0.3667,"value":24.75},{"time":0.4333,"value":-5.43},{"time":0.5333,"value":-39.76},{"time":0.8333,"value":-56.25}],"translate":[{"time":0.3667,"x":92.98,"y":-49.06},{"time":0.5333,"x":129.81,"y":-33.09},{"time":0.8333,"x":143.68,"y":-25.27}],"scale":[{"time":0.3667,"x":3.633,"y":2.223},{"time":0.4333,"x":2.745,"y":2.283},{"time":0.4667,"x":2.962,"y":2.122},{"time":0.5333,"x":2.007,"y":1.266}]},"cannon-target":{"translate":[{"time":0.1333},{"time":0.2,"y":128.38,"curve":[0.4,0,0.8,0,0.4,128.38,0.8,0]},{"time":1}],"scale":[{"time":0.4333,"x":0.632,"y":1.244},{"time":0.4667,"x":0.477,"y":1.487}]},"machinegun-target":{"scale":[{"time":0.4333,"x":0.632,"y":1.244},{"time":0.4667,"x":0.477,"y":1.487}]},"machinegun":{"rotate":[{"value":8.07,"curve":"stepped"},{"time":0.0667,"value":8.07},{"time":0.2333,"value":-18.67,"curve":[0.894,-18.44,0.832,7.5]},{"time":0.9,"value":8.07}]},"tank-root":{"translate":[{"time":0.0667},{"time":0.1667,"x":46.59,"curve":[0.192,46.59,0.242,0,0.192,0,0.242,0]},{"time":0.2667}]},"tank-glow":{"translate":[{"time":0.1333,"x":198.14,"curve":[0.199,190.76,0.222,-255.89,0.199,0,0.222,0]},{"time":0.2333,"x":-390}],"scale":[{"time":0.0667},{"time":0.1333,"x":1.185,"y":0.945,"curve":[0.199,1.182,0.222,1.048,0.199,0.939,0.222,0.579]},{"time":0.2333,"x":1.008,"y":0.471}]}},"attachments":{"default":{"clipping":{"clipping":{"deform":[{"time":0.0667,"offset":54,"vertices":[4.59198,-4.59192]},{"time":0.1333,"offset":8,"vertices":[-8.97369,-1.88211,9.11177,1.02258,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-14.73321,-45.16878,-30.31448,-84.4631,-32.24969,-108.78421,70.26825,-36.90201]},{"time":0.1667,"offset":8,"vertices":[-11.32373,-1.65065,11.42179,0.53259,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-15.36503,-69.18713,-4.45626,-121.90839,5.46554,-115.23274,71.78526,-33.85687]},{"time":0.2,"offset":8,"vertices":[-8.70522,1.02196,8.65102,-1.4101,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4.59198,-4.59192]},{"time":0.2333,"offset":8,"vertices":[-5.23146,0.85796,5.23882,-0.81519]},{"time":0.2667,"offset":54,"vertices":[4.59198,-4.59192]}]}},"smoke-glow":{"smoke-glow":{"deform":[{"time":0.1333,"vertices":[-14.17073,19.14352,0,0,-10.97961,-15.09065,-5.79558,-24.82121,0.68117,-17.78759,-1.1179,-5.4463,0,0,0,0,17.52957,6.89397,-0.33841,-2.21582,5.51004,18.88118,-6.80153,20.91101]},{"time":0.1667,"vertices":[-4.34264,39.78125,5.6649,-2.42686,-8.39346,-22.52338,-2.66431,5.08595,-19.28093,3.98568,-11.21397,10.2879,4.56749,4.1329,-19.50706,-2.28786,11.35747,4.55941,9.04341,-11.72194,2.15381,5.14344,-12.82158,16.08209,-23.19814,1.81836]},{"time":0.2,"vertices":[-3.95581,36.12203,37.20779,-0.87419,21.29579,-15.76854,-2.02438,6.16526,-5.92201,4.19709,-1.39027,9.92793,7.70584,-0.7169,-6.69733,-2.62048,17.91826,7.77333,-12.2858,3.25454,-12.75876,3.71516,9.67891,15.48546]},{"time":0.2333,"vertices":[-11.9371,26.01078,2.91821,-0.27533,7.69899,-17.45375,-2.02438,6.16526,-5.92201,4.19709,-1.39027,9.92793,7.70584,-0.7169,-6.69733,-2.62048,17.91826,7.77333,-4.30551,-6.01406,-12.75876,3.71516,-5.10017,17.59191]},{"time":0.2667,"vertices":[0.5959,23.58176,20.74303,0.93943,7.69899,-17.45375,-2.02438,6.16526,-5.92201,4.19709,-1.39027,9.92793,20.51733,2.52203,13.35544,2.64274,24.32408,-1.94308,8.50604,-20.99353,13.14276,5.73959,6.31876,19.2114,16.98909,0.80981]}]}}}},"drawOrder":[{"time":0.3,"offsets":[{"slot":"smoke-puff1-bg2","offset":24},{"slot":"smoke-puff1-bg8","offset":19},{"slot":"smoke-puff1-bg9","offset":22},{"slot":"smoke-puff1-bg3","offset":17},{"slot":"smoke-puff1-fg17","offset":13},{"slot":"smoke-puff1-fg2","offset":2},{"slot":"smoke-puff1-fg5","offset":8},{"slot":"smoke-puff1-fg6","offset":4},{"slot":"smoke-puff1-fg7","offset":-4},{"slot":"smoke-puff1-fg4","offset":-4}]},{"time":0.3333,"offsets":[{"slot":"smoke-puff1-bg2","offset":8},{"slot":"smoke-puff1-bg8","offset":5},{"slot":"smoke-puff1-bg9","offset":3},{"slot":"smoke-puff1-fg","offset":24},{"slot":"smoke-puff1-fg5","offset":-14},{"slot":"smoke-puff1-fg6","offset":-21},{"slot":"smoke-puff1-fg7","offset":-19},{"slot":"smoke-puff1-fg10","offset":-21}]},{"time":0.3667,"offsets":[{"slot":"smoke-puff1-bg2","offset":7},{"slot":"smoke-puff1-bg9","offset":4},{"slot":"smoke-puff1-fg","offset":24},{"slot":"smoke-puff1-fg2","offset":5},{"slot":"smoke-puff1-fg6","offset":-22},{"slot":"smoke-puff1-fg7","offset":-18},{"slot":"smoke-puff1-fg10","offset":-20}]},{"time":0.4,"offsets":[{"slot":"smoke-puff1-bg2","offset":5},{"slot":"smoke-puff1-bg4","offset":0},{"slot":"smoke-puff1-fg","offset":24},{"slot":"smoke-puff1-fg2","offset":5},{"slot":"smoke-puff1-fg6","offset":-21},{"slot":"smoke-puff1-fg7","offset":-18},{"slot":"smoke-puff1-fg10","offset":-22}]},{"time":0.4333,"offsets":[{"slot":"smoke-puff1-bg2","offset":4},{"slot":"smoke-puff1-bg9","offset":4},{"slot":"smoke-puff1-fg","offset":24},{"slot":"smoke-puff1-fg2","offset":5},{"slot":"smoke-puff1-fg6","offset":-17},{"slot":"smoke-puff1-fg7","offset":-19},{"slot":"smoke-puff1-fg10","offset":-23}]},{"time":0.5333,"offsets":[{"slot":"smoke-puff1-bg2","offset":9},{"slot":"smoke-puff1-bg12","offset":0},{"slot":"smoke-puff1-fg","offset":24},{"slot":"smoke-puff1-fg2","offset":6},{"slot":"smoke-puff1-fg6","offset":-20},{"slot":"smoke-puff1-fg7","offset":-19},{"slot":"smoke-puff1-fg10","offset":-23},{"slot":"smoke-puff1-fg4","offset":-5}]}]}}} \ No newline at end of file diff --git a/e2e/.dev/public/tank-pro.png b/e2e/.dev/public/tank-pro.png new file mode 100644 index 0000000000..badc613ca2 Binary files /dev/null and b/e2e/.dev/public/tank-pro.png differ diff --git a/e2e/case/spine-spineboy.ts b/e2e/case/spine-spineboy.ts new file mode 100644 index 0000000000..706be981e6 --- /dev/null +++ b/e2e/case/spine-spineboy.ts @@ -0,0 +1,35 @@ +/** + * @title Spine Spineboy + * @category Spine + */ +import { Camera, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +Logger.enable(); + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 20); + cameraEntity.transform.lookAt(new Vector3(0, 0, 0)); + + engine.resourceManager + .load({ + urls: ["/spineboy.json", "/spineboy.atlas", "/spineboy.png"], + type: "Spine" + }) + .then((resource: any) => { + const spineEntity = resource.instantiate(); + spineEntity.transform.setPosition(-0.5, -3.2, 0); + root.addChild(spineEntity); + spineEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + updateForE2E(engine); + initScreenshot(engine, camera); + }); +}); diff --git a/e2e/case/spine-tint-black.ts b/e2e/case/spine-tint-black.ts new file mode 100644 index 0000000000..62d565ced0 --- /dev/null +++ b/e2e/case/spine-tint-black.ts @@ -0,0 +1,40 @@ +/** + * @title Spine TintBlack + * @category Spine + */ +import { Camera, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +Logger.enable(); + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 60); + cameraEntity.transform.lookAt(new Vector3(0, 0, 0)); + + engine.resourceManager + .load({ + urls: ["/tank-pro.json", "/tank-pro.atlas", "/tank-pro.png"], + type: "Spine" + }) + .then((resource: any) => { + const spineEntity = resource.instantiate(); + const spine = spineEntity.getComponent(SpineAnimationRenderer); + // tank-pro ships with per-slot darkColor; tintBlack renders the two-color tint. + spine.tintBlack = true; + spineEntity.transform.setPosition(3, -5, 0); + root.addChild(spineEntity); + spine.state.setAnimation(0, "shoot", true); + + // Sample ~0.4s into "shoot": the muzzle-smoke darkColor makes the two-color tint + // clearly visible (tintBlack on/off differs ~7% here, vs ~0.01% on a static frame). + updateForE2E(engine, 40, 10); + initScreenshot(engine, camera); + }); +}); diff --git a/e2e/case/sprite-filled.ts b/e2e/case/sprite-filled.ts new file mode 100644 index 0000000000..e32be407a3 --- /dev/null +++ b/e2e/case/sprite-filled.ts @@ -0,0 +1,62 @@ +/** + * @title SpriteFilled + * @category Sprite + */ +import { + AssetType, + Camera, + Sprite, + SpriteDrawMode, + SpriteFilledMode, + SpriteRenderer, + Texture2D, + WebGLEngine +} from "@galacean/engine"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +// One baseline covering every fill mode (rows) across fill amounts 0/0.25/0.5/0.75/1 (columns). +// The amount steps hit the topology boundaries (45° quad↔triangle, 90° quadrant edges). +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 50); + const camera = cameraEntity.addComponent(Camera); + camera.isOrthographic = true; + camera.orthographicSize = 9; + + engine.resourceManager + .load({ + url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*rgNGR4Vb7lQAAAAAAAAAAAAAARQnAQ", + type: AssetType.Texture + }) + .then((texture) => { + const sprite = new Sprite(engine, texture); + const modes = [ + SpriteFilledMode.Horizontal, + SpriteFilledMode.Vertical, + SpriteFilledMode.Radial90, + SpriteFilledMode.Radial180, + SpriteFilledMode.Radial360 + ]; + const amounts = [0, 0.25, 0.5, 0.75, 1]; + + modes.forEach((mode, row) => { + amounts.forEach((amount, col) => { + const entity = rootEntity.createChild(`filled-${row}-${col}`); + entity.transform.setPosition((col - 2) * 3, (2 - row) * 3, 0); + const renderer = entity.addComponent(SpriteRenderer); + renderer.sprite = sprite; + renderer.width = 2.4; + renderer.height = 2.4; + renderer.drawMode = SpriteDrawMode.Filled; + renderer.filledMode = mode; + renderer.filledAmount = amount; + }); + }); + + updateForE2E(engine); + initScreenshot(engine, camera); + }); +}); diff --git a/e2e/config.ts b/e2e/config.ts index d7399ef8e1..75a973a570 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -1,4 +1,18 @@ export const E2E_CONFIG = { + Spine: { + spineboy: { + category: "Spine", + caseFileName: "spine-spineboy", + threshold: 0, + diffPercentage: 0.05 + }, + tintBlack: { + category: "Spine", + caseFileName: "spine-tint-black", + threshold: 0, + diffPercentage: 0.05 + } + }, Animator: { additive: { category: "Animator", @@ -578,5 +592,13 @@ export const E2E_CONFIG = { threshold: 0, diffPercentage: 0 } + }, + Sprite: { + filled: { + category: "Sprite", + caseFileName: "sprite-filled", + threshold: 0.1, + diffPercentage: 0.3 + } } }; diff --git a/e2e/fixtures/originImage/Spine_spine-spineboy.jpg b/e2e/fixtures/originImage/Spine_spine-spineboy.jpg new file mode 100644 index 0000000000..76065431eb --- /dev/null +++ b/e2e/fixtures/originImage/Spine_spine-spineboy.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58b5f1a156b82707ead037eb64d0b130534834c146d907b45be9b0cb16dbed49 +size 74591 diff --git a/e2e/fixtures/originImage/Spine_spine-tint-black.jpg b/e2e/fixtures/originImage/Spine_spine-tint-black.jpg new file mode 100644 index 0000000000..410a970ccf --- /dev/null +++ b/e2e/fixtures/originImage/Spine_spine-tint-black.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51fc0948e2ce8b819093526b5665b10e9975a810d4c1975fa661c091f2842e81 +size 122845 diff --git a/e2e/fixtures/originImage/Sprite_sprite-filled.jpg b/e2e/fixtures/originImage/Sprite_sprite-filled.jpg new file mode 100644 index 0000000000..79272e5169 --- /dev/null +++ b/e2e/fixtures/originImage/Sprite_sprite-filled.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef3c3796189e73349329df7cc1d0b35b5a5c47ef8d9e5757b5c68dd918b327a +size 215254 diff --git a/e2e/package.json b/e2e/package.json index 5aaec39214..5e9ac7cd67 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,7 +1,7 @@ { "name": "@galacean/engine-e2e", "private": true, - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "license": "MIT", "scripts": { "case": "vite serve .dev --config .dev/vite.config.js", @@ -19,6 +19,8 @@ "@galacean/engine-rhi-webgl": "workspace:*", "@galacean/engine-physics-physx": "workspace:*", "@galacean/engine-ui": "workspace:*", + "@galacean/engine-spine": "workspace:*", + "@galacean/engine-spine-core-4.2": "workspace:*", "dat.gui": "^0.7.9", "vite": "3.1.6", "sass": "^1.55.0" diff --git a/examples/package.json b/examples/package.json index 72baee02cd..82f8e21566 100644 --- a/examples/package.json +++ b/examples/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-examples", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "private": true, "license": "MIT", "main": "dist/main.js", @@ -20,6 +20,9 @@ "@galacean/engine-rhi-webgl": "workspace:*", "@galacean/engine-shader": "workspace:*", "@galacean/engine-shader-compiler": "workspace:*", + "@galacean/engine-spine": "workspace:*", + "@galacean/engine-spine-core-3.8": "workspace:*", + "@galacean/engine-spine-core-4.2": "workspace:*", "@galacean/engine-toolkit": "latest", "@galacean/engine-toolkit-stats": "latest", "@galacean/engine-ui": "workspace:*" diff --git a/examples/src/spine-keli-4.2.ts b/examples/src/spine-keli-4.2.ts new file mode 100644 index 0000000000..8ad862a4a0 --- /dev/null +++ b/examples/src/spine-keli-4.2.ts @@ -0,0 +1,60 @@ +/** + * @title Spine Keli (4.2) + * @category Spine + */ +import { Camera, Entity, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer, SpineResource } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import * as dat from "dat.gui"; + +Logger.enable(); + +const gui = new dat.GUI(); +const state = { animation: "" }; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 2, 10); + cameraEntity.transform.lookAt(new Vector3(0, 2, 0)); + + engine.run(); + + engine.resourceManager + .load({ + urls: [ + "/spine/keli/keli.json", + "/spine/keli/keli.atlas", + "/spine/keli/images/arm_l.png", + "/spine/keli/images/arm_r.png", + "/spine/keli/images/head.png", + "/spine/keli/images/legs.png", + "/spine/keli/images/torso.png" + ], + type: "Spine" + }) + .then((resource) => { + const spineEntity: Entity = resource.instantiate(); + root.addChild(spineEntity); + + const animator = spineEntity.getComponent(SpineAnimationRenderer); + const animationNames = resource.skeletonData.animations.map((animation) => animation.name); + + state.animation = animationNames[0]; + animator.state.setAnimation(0, state.animation, true); + + gui + .add(state, "animation", animationNames) + .name("Animation") + .onChange((name: string) => { + animator.state.setAnimation(0, name, true); + }); + }) + .catch((error) => { + console.error("Failed to load keli (4.2):", error); + }); +}); diff --git a/examples/src/spine-otakugirl-3.8.ts b/examples/src/spine-otakugirl-3.8.ts new file mode 100644 index 0000000000..410db286dd --- /dev/null +++ b/examples/src/spine-otakugirl-3.8.ts @@ -0,0 +1,55 @@ +/** + * @title Spine Otakugirl (3.8) + * @category Spine + * @remarks + * A genuine spine 3.8.99 export (binary .skel), unlike the keli asset used in the 4.2 example — + * this verifies the 3.8 backend against data it's actually meant to parse. + */ +import { Camera, Entity, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer, SpineResource } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-3.8"; +import * as dat from "dat.gui"; + +Logger.enable(); + +const gui = new dat.GUI(); +const state = { animation: "" }; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 2, 10); + cameraEntity.transform.lookAt(new Vector3(0, 2, 0)); + + engine.run(); + + engine.resourceManager + .load({ + urls: ["/spine/otakugirl/otakugirl.skel", "/spine/otakugirl/otakugirl.atlas", "/spine/otakugirl/otakugirl.png"], + type: "Spine" + }) + .then((resource) => { + const spineEntity: Entity = resource.instantiate(); + root.addChild(spineEntity); + + const animator = spineEntity.getComponent(SpineAnimationRenderer); + const animationNames = resource.skeletonData.animations.map((animation) => animation.name); + + state.animation = animationNames[0]; + animator.state.setAnimation(0, state.animation, true); + + gui + .add(state, "animation", animationNames) + .name("Animation") + .onChange((name: string) => { + animator.state.setAnimation(0, name, true); + }); + }) + .catch((error) => { + console.error("Failed to load otakugirl (3.8):", error); + }); +}); diff --git a/examples/src/sprite-mask.ts b/examples/src/sprite-mask.ts new file mode 100644 index 0000000000..f8dc719105 --- /dev/null +++ b/examples/src/sprite-mask.ts @@ -0,0 +1,87 @@ +/** + * @title Sprite Mask + * @category 2D + */ +import { + AssetType, + Camera, + Sprite, + SpriteMask, + SpriteMaskInteraction, + SpriteMaskLayer, + SpriteRenderer, + Texture2D, + WebGLEngine +} from "@galacean/engine"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor.set(0.05, 0.05, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 50); + cameraEntity.addComponent(Camera); + + engine.resourceManager + .load({ + url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*ApFPTZSqcMkAAAAAAAAAAAAAARQnAQ", + type: AssetType.Texture2D + }) + .then((texture) => { + const sprite = new Sprite(engine, texture); + const maskSprite = new Sprite(engine, createSolidTexture(engine)); + + const spriteWidth = sprite.width; + const spriteHeight = sprite.height; + // Mask covers ~half of the sprite so the cut is obvious. + const maskWidth = spriteWidth * 0.6; + const maskHeight = spriteHeight * 0.6; + // Lay the two characters out side by side based on sprite size. + const groupOffsetX = spriteWidth * 0.6; + + // --- Left: VisibleInsideMask -> only the part covered by the square mask is visible --- + const leftGroup = root.createChild("LeftGroup"); + leftGroup.transform.setPosition(-groupOffsetX, 0, 0); + + const leftMaskEntity = leftGroup.createChild("Mask"); + const leftMask = leftMaskEntity.addComponent(SpriteMask); + leftMask.sprite = maskSprite; + leftMask.width = maskWidth; + leftMask.height = maskHeight; + leftMask.influenceLayers = SpriteMaskLayer.Layer0; + + const leftSpriteEntity = leftGroup.createChild("Sprite"); + const leftSprite = leftSpriteEntity.addComponent(SpriteRenderer); + leftSprite.sprite = sprite; + leftSprite.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + leftSprite.maskLayer = SpriteMaskLayer.Layer0; + + // --- Right: VisibleOutsideMask -> character with a square hole punched out --- + const rightGroup = root.createChild("RightGroup"); + rightGroup.transform.setPosition(groupOffsetX, 0, 0); + + const rightMaskEntity = rightGroup.createChild("Mask"); + const rightMask = rightMaskEntity.addComponent(SpriteMask); + rightMask.sprite = maskSprite; + rightMask.width = maskWidth; + rightMask.height = maskHeight; + rightMask.influenceLayers = SpriteMaskLayer.Layer1; + + const rightSpriteEntity = rightGroup.createChild("Sprite"); + const rightSprite = rightSpriteEntity.addComponent(SpriteRenderer); + rightSprite.sprite = sprite; + rightSprite.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + rightSprite.maskLayer = SpriteMaskLayer.Layer1; + }); + + engine.run(); +}); + +function createSolidTexture(engine: WebGLEngine): Texture2D { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return texture; +} diff --git a/examples/src/ui-mask-alpha.ts b/examples/src/ui-mask-alpha.ts new file mode 100644 index 0000000000..9b5f340fd0 --- /dev/null +++ b/examples/src/ui-mask-alpha.ts @@ -0,0 +1,97 @@ +/** + * @title UI Mask Alpha Cutoff + * @category UI + */ +import * as dat from "dat.gui"; +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + const circleSprite = createCircleSprite(engine, 256); + + const groupEntity = canvasEntity.createChild("Group"); + (groupEntity.transform).setPosition(0, 40, 0); + + // Circular mask + const maskEntity = groupEntity.createChild("Mask"); + (maskEntity.transform).size.set(300, 300); + const mask = maskEntity.addComponent(Mask); + mask.sprite = circleSprite; + mask.alphaCutoff = 0.5; + + // Background visible inside circle + const insideEntity = groupEntity.createChild("Inside"); + (insideEntity.transform).size.set(500, 500); + const inside = insideEntity.addComponent(Image); + inside.sprite = solidSprite; + inside.color.set(0.95, 0.61, 0.07, 1); + inside.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const labelEntity = canvasEntity.createChild("Label"); + (labelEntity.transform).size.set(800, 80); + (labelEntity.transform).setPosition(0, -260, 0); + const label = labelEntity.addComponent(Text); + label.text = "Drag the slider to change alphaCutoff"; + label.fontSize = 28; + label.color.set(0.85, 0.9, 1, 1); + + const gui = new dat.GUI(); + const state = { alphaCutoff: 0.5 }; + gui + .add(state, "alphaCutoff", 0.0, 1.0, 0.01) + .name("Mask Alpha Cutoff") + .onChange((value: number) => { + mask.alphaCutoff = value; + }); + + engine.run(); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} + +/** Soft circle: alpha falls off radially so alphaCutoff has visible effect. */ +function createCircleSprite(engine: WebGLEngine, size: number): Sprite { + const buffer = new Uint8Array(size * size * 4); + const cx = size * 0.5; + const cy = size * 0.5; + const radius = size * 0.5; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - cx; + const dy = y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const t = Math.max(0, 1 - dist / radius); + const alpha = Math.min(255, Math.floor(t * 255)); + const i = (y * size + x) * 4; + buffer[i] = 255; + buffer[i + 1] = 255; + buffer[i + 2] = 255; + buffer[i + 3] = alpha; + } + } + const texture = new Texture2D(engine, size, size, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(buffer); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-mask-overlay.ts b/examples/src/ui-mask-overlay.ts new file mode 100644 index 0000000000..e7fa6b5096 --- /dev/null +++ b/examples/src/ui-mask-overlay.ts @@ -0,0 +1,120 @@ +/** + * @title UI Mask Overlay + * @category UI + */ +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + // Camera is required for default scene rendering even though overlay UI doesn't use it for projection. + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // ===== Left half: SpriteMask (Mask component) ===== + const maskGroup = canvasEntity.createChild("MaskGroup"); + (maskGroup.transform).setPosition(-300, 60, 0); + + const maskEntity = maskGroup.createChild("Mask"); + (maskEntity.transform).size.set(280, 280); + const mask = maskEntity.addComponent(Mask); + mask.sprite = solidSprite; + + const insideEntity = maskGroup.createChild("InsideImage"); + (insideEntity.transform).size.set(440, 440); + const inside = insideEntity.addComponent(Image); + inside.sprite = solidSprite; + inside.color.set(0.91, 0.3, 0.24, 1); + inside.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const maskLabelEntity = maskGroup.createChild("Label"); + (maskLabelEntity.transform).size.set(360, 60); + (maskLabelEntity.transform).setPosition(0, -260, 0); + const maskLabel = maskLabelEntity.addComponent(Text); + maskLabel.text = "Mask (Overlay)"; + maskLabel.fontSize = 32; + maskLabel.color.set(1, 1, 1, 1); + + // ===== Right half: RectMask2D ===== + const rectGroup = canvasEntity.createChild("RectGroup"); + (rectGroup.transform).setPosition(300, 60, 0); + + const viewportEntity = rectGroup.createChild("Viewport"); + (viewportEntity.transform).size.set(360, 280); + const viewport = viewportEntity.addComponent(Image); + viewport.sprite = solidSprite; + viewport.color.set(0.17, 0.18, 0.2, 1); + viewportEntity.addComponent(RectMask2D); + + const contentEntity = viewportEntity.createChild("Content"); + (contentEntity.transform).size.set(560, 480); + (contentEntity.transform).setPosition(60, -50, 0); + + const tileColors = [ + new Color(0.91, 0.3, 0.24, 1), + new Color(0.16, 0.5, 0.73, 1), + new Color(0.18, 0.8, 0.44, 1), + new Color(0.95, 0.61, 0.07, 1) + ]; + const tileSize = 220; + const gap = 20; + for (let row = 0; row < 2; row++) { + for (let col = 0; col < 2; col++) { + const i = row * 2 + col; + const tileEntity = contentEntity.createChild(`Tile_${i}`); + const t = tileEntity.transform; + t.size.set(tileSize, tileSize); + t.setPosition(col * (tileSize + gap) - (tileSize + gap) / 2, (tileSize + gap) / 2 - row * (tileSize + gap), 0); + + const tile = tileEntity.addComponent(Image); + tile.sprite = solidSprite; + tile.color = tileColors[i]; + + const labelEntity = tileEntity.createChild("Label"); + (labelEntity.transform).size.set(tileSize, tileSize); + const label = labelEntity.addComponent(Text); + label.text = `${i + 1}`; + label.fontSize = 64; + label.color.set(1, 1, 1, 1); + } + } + + const rectLabelEntity = rectGroup.createChild("Label"); + (rectLabelEntity.transform).size.set(360, 60); + (rectLabelEntity.transform).setPosition(0, -260, 0); + const rectLabel = rectLabelEntity.addComponent(Text); + rectLabel.text = "RectMask2D (Overlay)"; + rectLabel.fontSize = 32; + rectLabel.color.set(1, 1, 1, 1); + + // Top header + const headerEntity = canvasEntity.createChild("Header"); + (headerEntity.transform).size.set(900, 80); + (headerEntity.transform).setPosition(0, 320, 0); + const header = headerEntity.addComponent(Text); + header.text = "ScreenSpaceOverlay · Mask & RectMask2D"; + header.fontSize = 36; + header.color.set(0.85, 0.92, 1, 1); + + engine.run(); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-mask.ts b/examples/src/ui-mask.ts new file mode 100644 index 0000000000..260392e6e1 --- /dev/null +++ b/examples/src/ui-mask.ts @@ -0,0 +1,85 @@ +/** + * @title UI Mask + * @category UI + */ +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // --- Left group: VisibleInsideMask --- + const leftGroupEntity = canvasEntity.createChild("LeftGroup"); + (leftGroupEntity.transform).setPosition(-300, 0, 0); + + // Square mask + const leftMaskEntity = leftGroupEntity.createChild("Mask"); + (leftMaskEntity.transform).size.set(300, 300); + const leftMask = leftMaskEntity.addComponent(Mask); + leftMask.sprite = solidSprite; + + // Image clipped to inside the mask + const insideImageEntity = leftGroupEntity.createChild("InsideImage"); + (insideImageEntity.transform).size.set(500, 500); + const insideImage = insideImageEntity.addComponent(Image); + insideImage.sprite = solidSprite; + insideImage.color.set(0.91, 0.3, 0.24, 1); + insideImage.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const leftLabelEntity = leftGroupEntity.createChild("Label"); + (leftLabelEntity.transform).size.set(300, 60); + (leftLabelEntity.transform).setPosition(0, -210, 0); + const leftLabel = leftLabelEntity.addComponent(Text); + leftLabel.text = "VisibleInsideMask"; + leftLabel.fontSize = 30; + leftLabel.color.set(1, 1, 1, 1); + + // --- Right group: VisibleOutsideMask --- + const rightGroupEntity = canvasEntity.createChild("RightGroup"); + (rightGroupEntity.transform).setPosition(300, 0, 0); + + const rightMaskEntity = rightGroupEntity.createChild("Mask"); + (rightMaskEntity.transform).size.set(300, 300); + const rightMask = rightMaskEntity.addComponent(Mask); + rightMask.sprite = solidSprite; + + const outsideImageEntity = rightGroupEntity.createChild("OutsideImage"); + (outsideImageEntity.transform).size.set(500, 500); + const outsideImage = outsideImageEntity.addComponent(Image); + outsideImage.sprite = solidSprite; + outsideImage.color.set(0.16, 0.5, 0.73, 1); + outsideImage.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + + const rightLabelEntity = rightGroupEntity.createChild("Label"); + (rightLabelEntity.transform).size.set(300, 60); + (rightLabelEntity.transform).setPosition(0, -210, 0); + const rightLabel = rightLabelEntity.addComponent(Text); + rightLabel.text = "VisibleOutsideMask"; + rightLabel.fontSize = 30; + rightLabel.color.set(1, 1, 1, 1); + + engine.run(); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-rect-mask-nested.ts b/examples/src/ui-rect-mask-nested.ts new file mode 100644 index 0000000000..0eae971bfb --- /dev/null +++ b/examples/src/ui-rect-mask-nested.ts @@ -0,0 +1,76 @@ +/** + * @title UI RectMask2D Nested + * @category UI + */ +import { Camera, Color, Sprite, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // Outer mask (wide, short) + const outerEntity = canvasEntity.createChild("OuterMask"); + (outerEntity.transform).size.set(560, 240); + (outerEntity.transform).setPosition(0, 60, 0); + const outerImage = outerEntity.addComponent(Image); + outerImage.sprite = solidSprite; + outerImage.color.set(0.13, 0.18, 0.28, 1); + outerEntity.addComponent(RectMask2D); + + // Inner mask (tall, narrow), child of outer + const innerEntity = outerEntity.createChild("InnerMask"); + (innerEntity.transform).size.set(240, 480); + (innerEntity.transform).setPosition(0, 0, 0); + const innerImage = innerEntity.addComponent(Image); + innerImage.sprite = solidSprite; + innerImage.color.set(0.2, 0.3, 0.5, 1); + innerEntity.addComponent(RectMask2D); + + // Big colored content under both masks — only the intersection of outer ∩ inner remains visible + const contentEntity = innerEntity.createChild("Content"); + (contentEntity.transform).size.set(800, 800); + const content = contentEntity.addComponent(Image); + content.sprite = solidSprite; + content.color.set(0.95, 0.61, 0.07, 1); + + const labelTopEntity = canvasEntity.createChild("LabelTop"); + (labelTopEntity.transform).size.set(900, 60); + (labelTopEntity.transform).setPosition(0, 220, 0); + const labelTop = labelTopEntity.addComponent(Text); + labelTop.text = "Outer 560x240 ∩ Inner 240x480 → visible: 240x240"; + labelTop.fontSize = 28; + labelTop.color.set(1, 1, 1, 1); + + const labelBottomEntity = canvasEntity.createChild("LabelBottom"); + (labelBottomEntity.transform).size.set(900, 60); + (labelBottomEntity.transform).setPosition(0, -260, 0); + const labelBottom = labelBottomEntity.addComponent(Text); + labelBottom.text = "Nested RectMask2D takes the rect intersection of all ancestor masks."; + labelBottom.fontSize = 24; + labelBottom.color.set(0.77, 0.82, 0.89, 1); + + engine.run(); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-rect-mask.ts b/examples/src/ui-rect-mask.ts new file mode 100644 index 0000000000..92078d6c6a --- /dev/null +++ b/examples/src/ui-rect-mask.ts @@ -0,0 +1,135 @@ +/** + * @title UI RectMask2D + * @category UI + */ +import * as dat from "dat.gui"; +import { Camera, Color, Sprite, Texture2D, Vector2, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // Frame card + const frameEntity = canvasEntity.createChild("Frame"); + (frameEntity.transform).size.set(560, 460); + (frameEntity.transform).setPosition(-180, 20, 0); + const frame = frameEntity.addComponent(Image); + frame.sprite = solidSprite; + frame.color.set(0.09, 0.11, 0.15, 1); + + // Viewport with RectMask2D + const viewportEntity = frameEntity.createChild("Viewport"); + (viewportEntity.transform).size.set(440, 320); + (viewportEntity.transform).setPosition(30, -10, 0); + const viewport = viewportEntity.addComponent(Image); + viewport.sprite = solidSprite; + viewport.color.set(0.17, 0.18, 0.2, 1); + const rectMask = viewportEntity.addComponent(RectMask2D); + + // 3x3 colored tiles overflow the viewport + const contentEntity = viewportEntity.createChild("Content"); + (contentEntity.transform).size.set(740, 560); + (contentEntity.transform).setPosition(80, -60, 0); + + const colors = [ + new Color(0.91, 0.3, 0.24, 1), + new Color(0.16, 0.5, 0.73, 1), + new Color(0.18, 0.8, 0.44, 1), + new Color(0.95, 0.61, 0.07, 1), + new Color(0.56, 0.27, 0.68, 1), + new Color(0.2, 0.6, 0.86, 1), + new Color(0.83, 0.33, 0.33, 1), + new Color(0.1, 0.74, 0.61, 1), + new Color(0.93, 0.78, 0.0, 1) + ]; + + const tileWidth = 180; + const tileHeight = 180; + const gap = 10; + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + const index = row * 3 + col; + const tileEntity = contentEntity.createChild(`Tile_${index}`); + const t = tileEntity.transform; + t.size.set(tileWidth, tileHeight); + t.setPosition(col * (tileWidth + gap) - 170, 170 - row * (tileHeight + gap), 0); + + const tile = tileEntity.addComponent(Image); + tile.sprite = solidSprite; + tile.color = colors[index]; + + const labelEntity = tileEntity.createChild("Label"); + (labelEntity.transform).size.set(tileWidth, tileHeight); + const label = labelEntity.addComponent(Text); + label.text = `${index + 1}`; + label.fontSize = 56; + label.color.set(1, 1, 1, 1); + } + } + + // Right info card + const noteEntity = canvasEntity.createChild("Note"); + (noteEntity.transform).size.set(360, 220); + (noteEntity.transform).setPosition(290, 20, 0); + const note = noteEntity.addComponent(Image); + note.sprite = solidSprite; + note.color.set(0.08, 0.09, 0.12, 1); + + const noteTextEntity = noteEntity.createChild("Copy"); + (noteTextEntity.transform).size.set(320, 180); + const noteText = noteTextEntity.addComponent(Text); + noteText.text = + "RectMask2D clips Image\nand Text by an axis-\naligned rectangle.\n\nUse the GUI to tweak\nsoftness / alphaClip."; + noteText.fontSize = 26; + noteText.color.set(0.77, 0.82, 0.89, 1); + + const gui = new dat.GUI(); + const state = { + softnessX: 0, + softnessY: 0, + alphaClip: false + }; + gui + .add(state, "softnessX", 0, 80, 1) + .name("softness.x") + .onChange((v: number) => { + rectMask.softness = new Vector2(v, state.softnessY); + }); + gui + .add(state, "softnessY", 0, 80, 1) + .name("softness.y") + .onChange((v: number) => { + rectMask.softness = new Vector2(state.softnessX, v); + }); + gui + .add(state, "alphaClip") + .name("alphaClip (discard)") + .onChange((v: boolean) => { + rectMask.alphaClip = v; + }); + + engine.run(); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-text-outline.ts b/examples/src/ui-text-outline.ts new file mode 100644 index 0000000000..fae8865a9f --- /dev/null +++ b/examples/src/ui-text-outline.ts @@ -0,0 +1,121 @@ +/** + * @title UI Text Outline + * @category UI + */ +import * as dat from "dat.gui"; +import { Camera, Color, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.05, 0.07, 0.1, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1280, 800); + + // ---------- Matrix grid ---------- + // Rows = outlineWidth (0 / 1 / 2 / 4 / 8 px) + // Cols = (text color, outline color) presets + const widthCases = [0, 1, 2, 4, 8]; + const colorCases: { name: string; fill: Color; outline: Color }[] = [ + { name: "white / black", fill: new Color(1, 1, 1, 1), outline: new Color(0, 0, 0, 1) }, + { name: "black / white", fill: new Color(0, 0, 0, 1), outline: new Color(1, 1, 1, 1) }, + { name: "yellow / red", fill: new Color(1, 0.85, 0.1, 1), outline: new Color(0.85, 0.1, 0.1, 1) } + ]; + + const cellW = 380; + const cellH = 110; + const startX = -cellW * (colorCases.length - 1) * 0.5; + const startY = 220; + + // Column headers + for (let c = 0; c < colorCases.length; c++) { + const headerEntity = canvasEntity.createChild(`header-${c}`); + const ht = headerEntity.transform; + ht.size.set(cellW, 32); + ht.setPosition(startX + c * cellW, startY + cellH * 0.5 + 20, 0); + const header = headerEntity.addComponent(Text); + header.text = `text/outline = ${colorCases[c].name}`; + header.fontSize = 18; + header.color.set(0.7, 0.78, 0.9, 1); + } + + for (let r = 0; r < widthCases.length; r++) { + const w = widthCases[r]; + + // Row label + const labelEntity = canvasEntity.createChild(`row-label-${r}`); + const lt = labelEntity.transform; + lt.size.set(120, cellH); + lt.setPosition(startX - cellW * 0.5 - 60, startY - r * cellH, 0); + const label = labelEntity.addComponent(Text); + label.text = `width = ${w}px`; + label.fontSize = 18; + label.color.set(0.7, 0.78, 0.9, 1); + + for (let c = 0; c < colorCases.length; c++) { + const { fill, outline } = colorCases[c]; + + const cell = canvasEntity.createChild(`cell-${r}-${c}`); + const ct = cell.transform; + ct.size.set(cellW, cellH); + ct.setPosition(startX + c * cellW, startY - r * cellH, 0); + const text = cell.addComponent(Text); + text.text = "Hello 描边 Outline"; + text.fontSize = 36; + text.color = fill; + text.outlineWidth = w; + text.outlineColor = outline; + } + } + + // ---------- Live preview controlled by dat.gui ---------- + const previewEntity = canvasEntity.createChild("preview"); + const previewTransform = previewEntity.transform; + previewTransform.size.set(900, 160); + previewTransform.setPosition(0, -300, 0); + const preview = previewEntity.addComponent(Text); + preview.text = "实时 Live 预览 Preview"; + preview.fontSize = 72; + preview.color = new Color(1, 1, 1, 1); + preview.outlineWidth = 3; + preview.outlineColor = new Color(0, 0, 0, 1); + + const state = { + fontSize: preview.fontSize, + outlineWidth: preview.outlineWidth, + fillColor: [255, 255, 255], + outlineColor: [0, 0, 0], + text: preview.text + }; + + const gui = new dat.GUI(); + gui.add(state, "text").onChange((v: string) => { + preview.text = v; + }); + gui.add(state, "fontSize", 12, 120, 1).onChange((v: number) => { + preview.fontSize = v; + }); + gui.add(state, "outlineWidth", 0, 8, 0.1).onChange((v: number) => { + preview.outlineWidth = v; + }); + gui.addColor(state, "fillColor").onChange((rgb: number[]) => { + preview.color.set(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1); + }); + gui.addColor(state, "outlineColor").onChange((rgb: number[]) => { + preview.outlineColor.set(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1); + }); + + engine.run(); +}); diff --git a/package.json b/package.json index b96706d3e9..9759985992 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-root", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "packageManager": "pnpm@9.3.0", "private": true, "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index 2082de1ce4..dcac8e9d09 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-core", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/core/src/2d/assembler/FilledSpriteAssembler.ts b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts new file mode 100644 index 0000000000..4ecf56f104 --- /dev/null +++ b/packages/core/src/2d/assembler/FilledSpriteAssembler.ts @@ -0,0 +1,619 @@ +import { BoundingBox, Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; +import { ISpriteAssembler } from "./ISpriteAssembler"; +import { ISpriteRenderer } from "./ISpriteRenderer"; + +/** + * Assemble vertex data for the sprite renderer in filled mode. + */ +@StaticInterfaceImplement() +export class FilledSpriteAssembler { + private static _matrix = new Matrix(); + private static _worldPositions = [ + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3(), + new Vector3() + ]; + private static _uvs = [ + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2(), + new Vector2() + ]; + private static _inPositions: Vector3[] = []; + private static _inUVs: Vector2[] = []; + private static _outPositions: Vector3[] = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; + private static _outUVs: Vector2[] = [new Vector2(), new Vector2(), new Vector2(), new Vector2()]; + private static _vertexOffset = 0; + private static _indicesOffset = 0; + // Fill amounts at or below this are treated as empty (render nothing), avoiding degenerate geometry. + private static readonly _fillAmountEpsilon = 0.001; + + static resetData(renderer: ISpriteRenderer): void { + const manager = renderer._getChunkManager(); + const lastSubChunk = renderer._subChunk; + lastSubChunk && manager.freeSubChunk(lastSubChunk); + // Allocate the maximum any fill mode can produce (Radial360 = 4 quadrants x 4 verts = 16). + // A fixed size avoids reallocating the sub-chunk when filledMode changes at runtime. + const subChunk = manager.allocateSubChunk(16); + subChunk.indices = []; + renderer._subChunk = subChunk; + } + + static updatePositions( + renderer: ISpriteRenderer, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean + ): void { + const { x: pivotX, y: pivotY } = pivot; + const modelMatrix = FilledSpriteAssembler._matrix; + const { elements: wE } = modelMatrix; + const { elements: pWE } = worldMatrix; + const sx = flipX ? -width : width; + const sy = flipY ? -height : height; + (wE[0] = pWE[0] * sx), (wE[1] = pWE[1] * sx), (wE[2] = pWE[2] * sx); + (wE[4] = pWE[4] * sy), (wE[5] = pWE[5] * sy), (wE[6] = pWE[6] * sy); + (wE[8] = pWE[8]), (wE[9] = pWE[9]), (wE[10] = pWE[10]); + wE[12] = pWE[12] - pivotX * wE[0] - pivotY * wE[4]; + wE[13] = pWE[13] - pivotX * wE[1] - pivotY * wE[5]; + wE[14] = pWE[14] - pivotX * wE[2] - pivotY * wE[6]; + + switch (renderer.filledMode) { + case SpriteFilledMode.Horizontal: + this._filledLinear(renderer, modelMatrix, true); + break; + case SpriteFilledMode.Vertical: + this._filledLinear(renderer, modelMatrix, false); + break; + case SpriteFilledMode.Radial90: + this._filledRadial90( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); + break; + case SpriteFilledMode.Radial180: + this._filledRadial180( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); + break; + case SpriteFilledMode.Radial360: + this._filledRadial360( + renderer, + modelMatrix, + renderer.filledOrigin, + renderer.filledAmount, + renderer.filledClockWise + ); + break; + default: + break; + } + + // @ts-ignore + BoundingBox.transform(renderer.sprite._getBounds(), modelMatrix, renderer._bounds); + } + + static updateUVs(renderer: ISpriteRenderer): void { + // UVs are computed in updatePositions. + } + + static updateColor(renderer: ISpriteRenderer, alpha: number): void { + const subChunk = renderer._subChunk; + const { r, g, b, a } = renderer.color; + const finalAlpha = a * alpha; + const vertices = subChunk.chunk.vertices; + const vertexArea = subChunk.vertexArea; + for (let i = 0, o = vertexArea.start + 5, n = vertexArea.size / 9; i < n; ++i, o += 9) { + vertices[o] = r; + vertices[o + 1] = g; + vertices[o + 2] = b; + vertices[o + 3] = finalAlpha; + } + } + + private static _filledLinear(renderer: ISpriteRenderer, matrix: Matrix, isHorizontal: boolean): void { + const amount = renderer.filledAmount; + if (amount <= this._fillAmountEpsilon) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + const subChunk = renderer._subChunk; + const vertices = subChunk.chunk.vertices; + + let x0: number, y0: number, u0: number, v0: number; + let x1: number, y1: number, u1: number, v1: number; + let x2: number, y2: number, u2: number, v2: number; + let x3: number, y3: number, u3: number, v3: number; + + if (isHorizontal) { + const originIsStart = renderer.filledOrigin === SpriteFilledOrigin.Left; + const startX = originIsStart ? lPosLB.x : lPosRB.x - (lPosRB.x - lPosLB.x) * amount; + const endX = originIsStart ? lPosLB.x + (lPosRB.x - lPosLB.x) * amount : lPosRB.x; + const startU = originIsStart ? left : right - (right - left) * amount; + const endU = originIsStart ? left + (right - left) * amount : right; + (x0 = startX), (y0 = lPosLB.y), (u0 = startU), (v0 = bottom); + (x1 = endX), (y1 = lPosRB.y), (u1 = endU), (v1 = bottom); + (x2 = startX), (y2 = lPosLT.y), (u2 = startU), (v2 = top); + (x3 = endX), (y3 = lPosRT.y), (u3 = endU), (v3 = top); + } else { + const originIsStart = renderer.filledOrigin === SpriteFilledOrigin.Bottom; + const startY = originIsStart ? lPosLB.y : lPosLT.y - (lPosLT.y - lPosLB.y) * amount; + const endY = originIsStart ? lPosLB.y + (lPosLT.y - lPosLB.y) * amount : lPosLT.y; + const startV = originIsStart ? bottom : top - (top - bottom) * amount; + const endV = originIsStart ? bottom + (top - bottom) * amount : top; + (x0 = lPosLB.x), (y0 = startY), (u0 = left), (v0 = startV); + (x1 = lPosRB.x), (y1 = startY), (u1 = right), (v1 = startV); + (x2 = lPosLT.x), (y2 = endY), (u2 = left), (v2 = endV); + (x3 = lPosRT.x), (y3 = endY), (u3 = right), (v3 = endV); + } + + const { elements: wE } = matrix; + const start = subChunk.vertexArea.start; + // LB + vertices[start] = wE[0] * x0 + wE[4] * y0 + wE[12]; + vertices[start + 1] = wE[1] * x0 + wE[5] * y0 + wE[13]; + vertices[start + 2] = wE[2] * x0 + wE[6] * y0 + wE[14]; + vertices[start + 3] = u0; + vertices[start + 4] = v0; + // RB + vertices[start + 9] = wE[0] * x1 + wE[4] * y1 + wE[12]; + vertices[start + 10] = wE[1] * x1 + wE[5] * y1 + wE[13]; + vertices[start + 11] = wE[2] * x1 + wE[6] * y1 + wE[14]; + vertices[start + 12] = u1; + vertices[start + 13] = v1; + // LT + vertices[start + 18] = wE[0] * x2 + wE[4] * y2 + wE[12]; + vertices[start + 19] = wE[1] * x2 + wE[5] * y2 + wE[13]; + vertices[start + 20] = wE[2] * x2 + wE[6] * y2 + wE[14]; + vertices[start + 21] = u2; + vertices[start + 22] = v2; + // RT + vertices[start + 27] = wE[0] * x3 + wE[4] * y3 + wE[12]; + vertices[start + 28] = wE[1] * x3 + wE[5] * y3 + wE[13]; + vertices[start + 29] = wE[2] * x3 + wE[6] * y3 + wE[14]; + vertices[start + 30] = u3; + vertices[start + 31] = v3; + + const indices = subChunk.indices; + indices[0] = 0; + indices[1] = 1; + indices[2] = 2; + indices[3] = 2; + indices[4] = 1; + indices[5] = 3; + indices.length = 6; + } + + private static _filledRadial90( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= this._fillAmountEpsilon) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // Transform 4 corners to world space + const [wLB, wRB, wLT, wRT] = this._worldPositions; + const [uvLB, uvRB, uvLT, uvRT] = this._uvs; + wLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + + // Map vertices based on origin corner: + // [center, CW-adjacent, CCW-adjacent, opposite] + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + switch (origin) { + case SpriteFilledOrigin.BottomRight: + (inPositions[0] = wRB), (inUVs[0] = uvRB); + (inPositions[1] = wRT), (inUVs[1] = uvRT); + (inPositions[2] = wLB), (inUVs[2] = uvLB); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + break; + case SpriteFilledOrigin.TopRight: + (inPositions[0] = wRT), (inUVs[0] = uvRT); + (inPositions[1] = wLT), (inUVs[1] = uvLT); + (inPositions[2] = wRB), (inUVs[2] = uvRB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + break; + case SpriteFilledOrigin.TopLeft: + (inPositions[0] = wLT), (inUVs[0] = uvLT); + (inPositions[1] = wLB), (inUVs[1] = uvLB); + (inPositions[2] = wRT), (inUVs[2] = uvRT); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + break; + // BottomLeft is the default; any unsupported origin falls back to it instead of rendering stale data. + case SpriteFilledOrigin.BottomLeft: + default: + (inPositions[0] = wLB), (inUVs[0] = uvLB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wLT), (inUVs[2] = uvLT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + break; + } + + const startAngle = cw ? 90 - amount * 90 : 0; + const endAngle = cw ? 90 : amount * 90; + + this._vertexOffset = this._indicesOffset = 0; + this._radialCut(renderer._subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + renderer._subChunk.indices.length = this._indicesOffset; + } + + private static _filledRadial180( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= this._fillAmountEpsilon) { + renderer._subChunk.indices.length = 0; + return; + } + + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // Transform corners and compute edge midpoints + const [wLB, wMB, wRB, wLM, , wRM, wLT, wMT, wRT] = this._worldPositions; + const [uvLB, uvMB, uvRB, uvLM, , uvRM, uvLT, uvMT, uvRT] = this._uvs; + wLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + Vector3.lerp(wLB, wRB, 0.5, wMB), Vector2.lerp(uvLB, uvRB, 0.5, uvMB); + Vector3.lerp(wLB, wLT, 0.5, wLM), Vector2.lerp(uvLB, uvLT, 0.5, uvLM); + Vector3.lerp(wLT, wRT, 0.5, wMT), Vector2.lerp(uvLT, uvRT, 0.5, uvMT); + Vector3.lerp(wRB, wRT, 0.5, wRM), Vector2.lerp(uvRB, uvRT, 0.5, uvRM); + + const startAngle = cw ? 180 - amount * 180 : 0; + const endAngle = cw ? 180 : amount * 180; + + this._vertexOffset = this._indicesOffset = 0; + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + const { _subChunk: subChunk } = renderer; + + // Center is at the origin edge midpoint; two quadrants cover the full sprite. + // Quadrant A (0°-90°), Quadrant B (90°-180°) + switch (origin) { + case SpriteFilledOrigin.Top: + // Center=MT, A: [MT,LT,MB,LB], B: [MT,MB,RT,RB] + (inPositions[0] = wMT), (inUVs[0] = uvMT); + (inPositions[1] = wLT), (inUVs[1] = uvLT); + (inPositions[2] = wMB), (inUVs[2] = uvMB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wMB), (inUVs[1] = uvMB); + (inPositions[2] = wRT), (inUVs[2] = uvRT); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + case SpriteFilledOrigin.Left: + // Center=LM, A: [LM,LB,RM,RB], B: [LM,RM,LT,RT] + (inPositions[0] = wLM), (inUVs[0] = uvLM); + (inPositions[1] = wLB), (inUVs[1] = uvLB); + (inPositions[2] = wRM), (inUVs[2] = uvRM); + (inPositions[3] = wRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wRM), (inUVs[1] = uvRM); + (inPositions[2] = wLT), (inUVs[2] = uvLT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + case SpriteFilledOrigin.Right: + // Center=RM, A: [RM,RT,LM,LT], B: [RM,LM,RB,LB] + (inPositions[0] = wRM), (inUVs[0] = uvRM); + (inPositions[1] = wRT), (inUVs[1] = uvRT); + (inPositions[2] = wLM), (inUVs[2] = uvLM); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wLM), (inUVs[1] = uvLM); + (inPositions[2] = wRB), (inUVs[2] = uvRB); + (inPositions[3] = wLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + // Bottom is the default; any unsupported origin falls back to it instead of rendering stale data. + case SpriteFilledOrigin.Bottom: + default: + // Center=MB, A: [MB,RB,MT,RT], B: [MB,MT,LB,LT] + (inPositions[0] = wMB), (inUVs[0] = uvMB); + (inPositions[1] = wRB), (inUVs[1] = uvRB); + (inPositions[2] = wMT), (inUVs[2] = uvMT); + (inPositions[3] = wRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, startAngle, endAngle, outPositions, outUVs); + (inPositions[1] = wMT), (inUVs[1] = uvMT); + (inPositions[2] = wLB), (inUVs[2] = uvLB); + (inPositions[3] = wLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, startAngle - 90, endAngle - 90, outPositions, outUVs); + break; + } + + subChunk.indices.length = this._indicesOffset; + } + + private static _filledRadial360( + renderer: ISpriteRenderer, + matrix: Matrix, + origin: SpriteFilledOrigin, + amount: number, + cw: boolean + ): void { + if (amount <= this._fillAmountEpsilon) { + renderer._subChunk.indices.length = 0; + return; + } + + let startAngle = 0; + switch (origin) { + case SpriteFilledOrigin.Top: + startAngle = cw ? 450 - amount * 360 : 90; + break; + case SpriteFilledOrigin.Left: + startAngle = cw ? 540 - amount * 360 : 180; + break; + case SpriteFilledOrigin.Bottom: + startAngle = cw ? 630 - amount * 360 : 270; + break; + // Right is handled here, and this branch also catches any unsupported origin as a fallback. + case SpriteFilledOrigin.Right: + default: + startAngle = cw ? 360 - amount * 360 : 0; + break; + } + const endAngle = startAngle + amount * 360; + + this._processRadialGrid(renderer, matrix, startAngle, endAngle); + } + + /** + * Prepare the 3x3 grid and process 4 quadrants for radial fill. + */ + private static _processRadialGrid( + renderer: ISpriteRenderer, + matrix: Matrix, + startAngle: number, + endAngle: number + ): void { + const sprite = renderer.sprite; + const [lPosLB, lPosRB, lPosLT, lPosRT] = sprite._getPositions(); + const spriteUVs = sprite._getUVs(); + const { x: left, y: bottom } = spriteUVs[0]; + const { x: right, y: top } = spriteUVs[3]; + + // --------------- + // LT - MT - RT + // | | | + // LM - C - RM + // | | | + // LB - MB - RB + // --------------- + const [wPosLB, wPosMB, wPosRB, wPosLM, wPosC, wPosRM, wPosLT, wPosMT, wPosRT] = this._worldPositions; + const [uvLB, uvMB, uvRB, uvLM, uvC, uvRM, uvLT, uvMT, uvRT] = this._uvs; + + wPosLB.set(lPosLB.x, lPosLB.y, 0).transformToVec3(matrix), uvLB.set(left, bottom); + wPosRB.set(lPosRB.x, lPosRB.y, 0).transformToVec3(matrix), uvRB.set(right, bottom); + wPosLT.set(lPosLT.x, lPosLT.y, 0).transformToVec3(matrix), uvLT.set(left, top); + wPosRT.set(lPosRT.x, lPosRT.y, 0).transformToVec3(matrix), uvRT.set(right, top); + Vector3.lerp(wPosLB, wPosRB, 0.5, wPosMB), Vector2.lerp(uvLB, uvRB, 0.5, uvMB); + Vector3.lerp(wPosLB, wPosLT, 0.5, wPosLM), Vector2.lerp(uvLB, uvLT, 0.5, uvLM); + Vector3.lerp(wPosLT, wPosRT, 0.5, wPosMT), Vector2.lerp(uvLT, uvRT, 0.5, uvMT); + Vector3.lerp(wPosRB, wPosRT, 0.5, wPosRM), Vector2.lerp(uvRB, uvRT, 0.5, uvRM); + Vector3.lerp(wPosLB, wPosRT, 0.5, wPosC), Vector2.lerp(uvLB, uvRT, 0.5, uvC); + + this._vertexOffset = this._indicesOffset = 0; + const { _inPositions: inPositions, _inUVs: inUVs, _outPositions: outPositions, _outUVs: outUVs } = this; + const { _subChunk: subChunk } = renderer; + let quadrantStart = 0; + let quadrantEnd = 0; + (inPositions[0] = wPosC), (inUVs[0] = uvC); + + { + // First quadrant (0°-90°) + if (startAngle >= 90) { + quadrantStart = startAngle - 360; + quadrantEnd = endAngle - 360; + } else { + quadrantStart = startAngle; + quadrantEnd = endAngle; + } + (inPositions[1] = wPosRM), (inUVs[1] = uvRM); + (inPositions[2] = wPosMT), (inUVs[2] = uvMT); + (inPositions[3] = wPosRT), (inUVs[3] = uvRT); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Second quadrant (90°-180°) + if (startAngle >= 180) { + quadrantStart = startAngle - 360 - 90; + quadrantEnd = endAngle - 360 - 90; + } else { + quadrantStart = startAngle - 90; + quadrantEnd = endAngle - 90; + } + (inPositions[1] = wPosMT), (inUVs[1] = uvMT); + (inPositions[2] = wPosLM), (inUVs[2] = uvLM); + (inPositions[3] = wPosLT), (inUVs[3] = uvLT); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Third quadrant (180°-270°) + if (startAngle >= 270) { + quadrantStart = startAngle - 360 - 180; + quadrantEnd = endAngle - 360 - 180; + } else { + quadrantStart = startAngle - 180; + quadrantEnd = endAngle - 180; + } + (inPositions[1] = wPosLM), (inUVs[1] = uvLM); + (inPositions[2] = wPosMB), (inUVs[2] = uvMB); + (inPositions[3] = wPosLB), (inUVs[3] = uvLB); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + { + // Fourth quadrant (270°-360°) + if (startAngle >= 360) { + quadrantStart = startAngle - 360 - 270; + quadrantEnd = endAngle - 360 - 270; + } else { + quadrantStart = startAngle - 270; + quadrantEnd = endAngle - 270; + } + (inPositions[1] = wPosMB), (inUVs[1] = uvMB); + (inPositions[2] = wPosRM), (inUVs[2] = uvRM); + (inPositions[3] = wPosRB), (inUVs[3] = uvRB); + this._radialCut(subChunk, inPositions, inUVs, quadrantStart, quadrantEnd, outPositions, outUVs); + } + + subChunk.indices.length = this._indicesOffset; + } + + private static _radialCut( + subChunk: SubPrimitiveChunk, + positions: Vector3[], + uvs: Vector2[], + start: number, + end: number, + outPositions: Vector3[], + outUVs: Vector2[] + ): void { + if (start >= 90 || end <= 0) return; + outPositions[0].copyFrom(positions[0]); + outUVs[0].copyFrom(uvs[0]); + + if (start <= 0) { + outPositions[1].copyFrom(positions[1]); + outUVs[1].copyFrom(uvs[1]); + } else { + const startTan = Math.tan((start * Math.PI) / 180); + if (startTan < 1) { + Vector3.lerp(positions[1], positions[3], startTan, outPositions[1]); + Vector2.lerp(uvs[1], uvs[3], startTan, outUVs[1]); + } else { + Vector3.lerp(positions[2], positions[3], 1 / startTan, outPositions[1]); + Vector2.lerp(uvs[2], uvs[3], 1 / startTan, outUVs[1]); + } + } + + if (end >= 90) { + outPositions[2].copyFrom(positions[2]); + outUVs[2].copyFrom(uvs[2]); + } else { + const endTan = Math.tan((end * Math.PI) / 180); + if (endTan < 1) { + Vector3.lerp(positions[1], positions[3], endTan, outPositions[2]); + Vector2.lerp(uvs[1], uvs[3], endTan, outUVs[2]); + } else { + Vector3.lerp(positions[2], positions[3], 1 / endTan, outPositions[2]); + Vector2.lerp(uvs[2], uvs[3], 1 / endTan, outUVs[2]); + } + } + + // 45° is the quadrant diagonal: a sector spanning it reaches the far corner and needs a quad; + // otherwise the cut produces a triangle. + if (start < 45 && end > 45) { + outPositions[3].copyFrom(positions[3]); + outUVs[3].copyFrom(uvs[3]); + this._addQuad(subChunk, outPositions, outUVs); + } else { + this._addTriangle(subChunk, outPositions, outUVs); + } + } + + private static _addTriangle(subChunk: SubPrimitiveChunk, positions: Vector3[], uvs: Vector2[]): void { + const vertices = subChunk.chunk.vertices; + const indices = subChunk.indices; + const vertexOffset = this._vertexOffset; + const vertexCount = vertexOffset / 9; + const start = subChunk.vertexArea.start + vertexOffset; + for (let i = 0, o = start; i < 3; ++i, o += 9) { + const position = positions[i]; + const uv = uvs[i]; + vertices[o] = position.x; + vertices[o + 1] = position.y; + vertices[o + 2] = position.z; + vertices[o + 3] = uv.x; + vertices[o + 4] = uv.y; + } + const indicesOffset = this._indicesOffset; + indices[indicesOffset] = vertexCount; + indices[indicesOffset + 1] = vertexCount + 1; + indices[indicesOffset + 2] = vertexCount + 2; + this._vertexOffset += 3 * 9; + this._indicesOffset += 3; + } + + private static _addQuad(subChunk: SubPrimitiveChunk, positions: Vector3[], uvs: Vector2[]): void { + const vertices = subChunk.chunk.vertices; + const indices = subChunk.indices; + const vertexOffset = this._vertexOffset; + const vertexCount = vertexOffset / 9; + const start = subChunk.vertexArea.start + vertexOffset; + for (let i = 0, o = start; i < 4; ++i, o += 9) { + const position = positions[i]; + const uv = uvs[i]; + vertices[o] = position.x; + vertices[o + 1] = position.y; + vertices[o + 2] = position.z; + vertices[o + 3] = uv.x; + vertices[o + 4] = uv.y; + } + const indicesOffset = this._indicesOffset; + indices[indicesOffset] = vertexCount; + indices[indicesOffset + 1] = vertexCount + 1; + indices[indicesOffset + 2] = vertexCount + 2; + indices[indicesOffset + 3] = vertexCount + 2; + indices[indicesOffset + 4] = vertexCount + 1; + indices[indicesOffset + 5] = vertexCount + 3; + this._vertexOffset += 4 * 9; + this._indicesOffset += 6; + } +} diff --git a/packages/core/src/2d/assembler/ISpriteRenderer.ts b/packages/core/src/2d/assembler/ISpriteRenderer.ts index a72f4e9436..04b50b3fc9 100644 --- a/packages/core/src/2d/assembler/ISpriteRenderer.ts +++ b/packages/core/src/2d/assembler/ISpriteRenderer.ts @@ -1,6 +1,8 @@ import { Color } from "@galacean/engine-math"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; import { SpriteTileMode } from "../enums/SpriteTileMode"; import { Sprite } from "../sprite"; @@ -12,6 +14,10 @@ export interface ISpriteRenderer { color?: Color; tileMode?: SpriteTileMode; tiledAdaptiveThreshold?: number; + filledMode?: SpriteFilledMode; + filledAmount?: number; + filledOrigin?: SpriteFilledOrigin; + filledClockWise?: boolean; _subChunk: SubPrimitiveChunk; _getChunkManager(): PrimitiveChunkManager; } diff --git a/packages/core/src/2d/atlas/FontAtlas.ts b/packages/core/src/2d/atlas/FontAtlas.ts index e84bfae4a9..b4e860fc16 100644 --- a/packages/core/src/2d/atlas/FontAtlas.ts +++ b/packages/core/src/2d/atlas/FontAtlas.ts @@ -12,10 +12,10 @@ export class FontAtlas extends ReferResource { texture: Texture2D; _charInfoMap: Record = {}; - private _space: number = 1; - private _curX: number = 1; - private _curY: number = 1; - private _nextY: number = 1; + private _space: number = 4; + private _curX: number = 4; + private _curY: number = 4; + private _nextY: number = 4; constructor(engine: Engine) { super(engine); diff --git a/packages/core/src/2d/enums/SpriteDrawMode.ts b/packages/core/src/2d/enums/SpriteDrawMode.ts index 46bcfd3783..6f35d8c1ef 100644 --- a/packages/core/src/2d/enums/SpriteDrawMode.ts +++ b/packages/core/src/2d/enums/SpriteDrawMode.ts @@ -7,5 +7,7 @@ export enum SpriteDrawMode { /** When modifying the size of the renderer, it scales to fill the range according to the sprite border settings. */ Sliced, /** When modifying the size of the renderer, it will tile to fill the range according to the sprite border settings. */ - Tiled + Tiled, + /** Fill the sprite partially, controlled by fill amount, mode and origin. */ + Filled } diff --git a/packages/core/src/2d/enums/SpriteFilledMode.ts b/packages/core/src/2d/enums/SpriteFilledMode.ts new file mode 100644 index 0000000000..a01cda9e53 --- /dev/null +++ b/packages/core/src/2d/enums/SpriteFilledMode.ts @@ -0,0 +1,15 @@ +/** + * Sprite's filled mode enumeration. + */ +export enum SpriteFilledMode { + /** Fill horizontally. */ + Horizontal, + /** Fill vertically. */ + Vertical, + /** Fill radially over 90 degrees. */ + Radial90, + /** Fill radially over 180 degrees. */ + Radial180, + /** Fill radially over 360 degrees. */ + Radial360 +} diff --git a/packages/core/src/2d/enums/SpriteFilledOrigin.ts b/packages/core/src/2d/enums/SpriteFilledOrigin.ts new file mode 100644 index 0000000000..e9b3193970 --- /dev/null +++ b/packages/core/src/2d/enums/SpriteFilledOrigin.ts @@ -0,0 +1,21 @@ +/** + * Sprite's filled origin enumeration. + */ +export enum SpriteFilledOrigin { + /** Origin at the right. */ + Right, + /** Origin at the top-right. */ + TopRight, + /** Origin at the top. */ + Top, + /** Origin at the top-left. */ + TopLeft, + /** Origin at the left. */ + Left, + /** Origin at the bottom-left. */ + BottomLeft, + /** Origin at the bottom. */ + Bottom, + /** Origin at the bottom-right. */ + BottomRight +} diff --git a/packages/core/src/2d/enums/TextOverflow.ts b/packages/core/src/2d/enums/TextOverflow.ts index adef863f1f..bf8fdf8aa4 100644 --- a/packages/core/src/2d/enums/TextOverflow.ts +++ b/packages/core/src/2d/enums/TextOverflow.ts @@ -1,9 +1,11 @@ /** - * The way to handle the situation where wrapped text is too tall to fit in the height. + * The way to handle the situation where the text is too large to fit in the bounds. */ export enum OverflowMode { /** Overflow when the text is too tall */ Overflow = 0, /** Truncate with height when the text is too tall */ - Truncate = 1 + Truncate = 1, + /** Shrink the font size until the text fits within the bounds (both width and height) */ + Shrink = 2 } diff --git a/packages/core/src/2d/index.ts b/packages/core/src/2d/index.ts index 47be64ccfc..5481f42b28 100644 --- a/packages/core/src/2d/index.ts +++ b/packages/core/src/2d/index.ts @@ -1,11 +1,14 @@ export type { ISpriteAssembler } from "./assembler/ISpriteAssembler"; export type { ISpriteRenderer } from "./assembler/ISpriteRenderer"; +export { FilledSpriteAssembler } from "./assembler/FilledSpriteAssembler"; export { SimpleSpriteAssembler } from "./assembler/SimpleSpriteAssembler"; export { SlicedSpriteAssembler } from "./assembler/SlicedSpriteAssembler"; export { TiledSpriteAssembler } from "./assembler/TiledSpriteAssembler"; export { SpriteAtlas } from "./atlas/SpriteAtlas"; export { FontStyle } from "./enums/FontStyle"; export { SpriteDrawMode } from "./enums/SpriteDrawMode"; +export { SpriteFilledMode } from "./enums/SpriteFilledMode"; +export { SpriteFilledOrigin } from "./enums/SpriteFilledOrigin"; export { SpriteMaskInteraction } from "./enums/SpriteMaskInteraction"; export { SpriteModifyFlags } from "./enums/SpriteModifyFlags"; export { SpriteTileMode } from "./enums/SpriteTileMode"; diff --git a/packages/core/src/2d/sprite/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts new file mode 100644 index 0000000000..11f1731494 --- /dev/null +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -0,0 +1,394 @@ +import { BoundingBox, Vector2, Vector3 } from "@galacean/engine-math"; +import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; +import { Renderer, RendererUpdateFlags } from "../../Renderer"; +import { ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { ShaderProperty } from "../../shader/ShaderProperty"; +import type { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; +import { Sprite } from "./Sprite"; +import { SpriteMaskUtils } from "./SpriteMaskUtils"; + +/** + * Public contract of the MaskRenderable mixin, used for declaration file generation. + */ +export interface IMaskRenderable { + influenceLayers: SpriteMaskLayer; + flipX: boolean; + flipY: boolean; + sprite: Sprite; + alphaCutoff: number; + _renderElement: RenderElement; + _maskIndex: number; + _containsWorldPoint(worldPoint: Vector3): boolean; + _initMask(): void; + _cloneMaskData(target: IMaskRenderable): void; + _destroyMaskResources(): void; + _updateMaskBounds(worldBounds: BoundingBox): void; + _renderMask(distanceForSort: number): void; + _onSpriteChange(type: SpriteModifyFlags): void; + _onSpriteChangeExtra(type: SpriteModifyFlags): void; + _getSpriteWidth(): number; + _getSpriteHeight(): number; + _getSpritePivot(): Vector2; +} + +type RendererConstructor = abstract new (...args: any[]) => Renderer; + +/** + * Mixin that provides shared mask rendering logic for both 2D SpriteMask and UI Mask. + */ +export function MaskRenderable( + Base: T +): (abstract new (...args: any[]) => IMaskRenderable) & T { + abstract class MaskRenderableBase extends Base implements IMaskRenderable { + private static _maskTextureProperty = ShaderProperty.getByName("renderer_MaskTexture"); + private static _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + + private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; + /** @internal */ + @ignoreClone + _renderElement: RenderElement; + /** @internal */ + @ignoreClone + _maskIndex: number = -1; + @ignoreClone + private _sprite: Sprite = null; + private _flipX: boolean = false; + private _flipY: boolean = false; + private _alphaCutoff: number = 0.5; + + /** + * The mask layers the sprite mask influence to. + */ + get influenceLayers(): SpriteMaskLayer { + return this._influenceLayers; + } + + set influenceLayers(value: SpriteMaskLayer) { + if (this._influenceLayers !== value) { + this._influenceLayers = value; + // @ts-ignore + if (this._phasedActiveInScene) { + // @ts-ignore + this.scene._maskManager.onMaskInfluenceLayersChange(); + } + } + } + + /** + * Flips the sprite on the X axis. + */ + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + if (this._flipX !== value) { + this._flipX = value; + // @ts-ignore + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * Flips the sprite on the Y axis. + */ + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + if (this._flipY !== value) { + this._flipY = value; + // @ts-ignore + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * The Sprite to render. + */ + get sprite(): Sprite { + return this._sprite; + } + + set sprite(value: Sprite | null) { + const lastSprite = this._sprite; + if (lastSprite !== value) { + if (lastSprite) { + // @ts-ignore + this._addResourceReferCount(lastSprite, -1); + lastSprite._updateFlagManager.removeListener(this._onSpriteChange); + } + // @ts-ignore + this._dirtyUpdateFlag |= MaskDirtyFlags.All; + if (value) { + // @ts-ignore + this._addResourceReferCount(value, 1); + value._updateFlagManager.addListener(this._onSpriteChange); + // @ts-ignore + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, value.texture); + } else { + // @ts-ignore + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, null); + } + this._sprite = value; + } + } + + /** + * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. + */ + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + // @ts-ignore + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, value); + } + } + + /** + * @internal + */ + // @ts-ignore + override _canBatch(preElement: RenderElement, curElement: RenderElement): boolean { + return VertexMergeBatcher.canBatchSpriteMask(preElement, curElement); + } + + /** + * @internal + */ + // @ts-ignore + override _batch(preElement: RenderElement | null, curElement: RenderElement): void { + VertexMergeBatcher.batch(preElement, curElement); + } + + /** + * @internal + */ + // @ts-ignore + override _onEnableInScene(): void { + // @ts-ignore + super._onEnableInScene(); + // @ts-ignore + this.scene._maskManager.addSpriteMask(this); + } + + /** + * @internal + */ + // @ts-ignore + override _onDisableInScene(): void { + // @ts-ignore + super._onDisableInScene(); + // @ts-ignore + this.scene._maskManager.removeSpriteMask(this); + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + return SpriteMaskUtils.containsWorldPoint( + worldPoint, + this._sprite, + // @ts-ignore + this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._flipX, + this._flipY, + this._alphaCutoff + ); + } + + /** + * @internal + * Initialize shared mask resources. Must be called from subclass constructor. + */ + _initMask(): void { + SimpleSpriteAssembler.resetData(this as unknown as ISpriteRenderer); + // @ts-ignore + this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); + // @ts-ignore + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, this._alphaCutoff); + this._renderElement = new RenderElement(); + this._onSpriteChange = this._onSpriteChange.bind(this); + } + + /** + * @internal + * Clone mask data to target. Called from subclass _cloneTo. + */ + _cloneMaskData(target: MaskRenderableBase): void { + target.sprite = this._sprite; + } + + /** + * @internal + * Release mask sprite resources. Called from subclass _onDestroy. + */ + _destroyMaskResources(): void { + const sprite = this._sprite; + if (sprite) { + // @ts-ignore + this._addResourceReferCount(sprite, -1); + sprite._updateFlagManager.removeListener(this._onSpriteChange); + } + this._sprite = null; + this._renderElement = null; + } + + /** + * @internal + * Update bounds using SimpleSpriteAssembler directly. + */ + _updateMaskBounds(worldBounds: BoundingBox): void { + const sprite = this._sprite; + if (sprite) { + SimpleSpriteAssembler.updatePositions( + this as unknown as ISpriteRenderer, + // @ts-ignore + this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._flipX, + this._flipY + ); + } else { + // @ts-ignore + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + /** + * @internal + * Shared render logic for mask geometry. + */ + _renderMask(distanceForSort: number): void { + const { _sprite: sprite } = this; + const width = this._getSpriteWidth(); + const height = this._getSpriteHeight(); + if (!sprite?.texture || !width || !height) { + return; + } + + // @ts-ignore + let material = this.getMaterial(); + if (!material) { + return; + } + if (material.destroyed) { + // @ts-ignore + material = this._engine._basicResources.spriteMaskDefaultMaterial; + } + + // Update position + // @ts-ignore + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + SimpleSpriteAssembler.updatePositions( + this as unknown as ISpriteRenderer, + // @ts-ignore + this._transformEntity.transform.worldMatrix, + width, + height, + this._getSpritePivot(), + this._flipX, + this._flipY + ); + // @ts-ignore + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + // @ts-ignore + if (this._dirtyUpdateFlag & MaskDirtyFlags.UV) { + SimpleSpriteAssembler.updateUVs(this as unknown as ISpriteRenderer); + // @ts-ignore + this._dirtyUpdateFlag &= ~MaskDirtyFlags.UV; + } + + const renderElement = this._renderElement; + const subChunk = (this as any)._subChunk; + // @ts-ignore + renderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, sprite.texture, subChunk); + // @ts-ignore + renderElement.priority = this.priority; + renderElement.distanceForSort = distanceForSort; + renderElement.subShader = material.shader.subShaders[0]; + } + + /** @internal */ + @ignoreClone + _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + // @ts-ignore + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, this.sprite.texture); + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + // @ts-ignore + this._dirtyUpdateFlag |= MaskDirtyFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + // @ts-ignore + this._dirtyUpdateFlag |= MaskDirtyFlags.UV; + break; + case SpriteModifyFlags.destroy: + this.sprite = null; + break; + default: + this._onSpriteChangeExtra(type); + break; + } + } + + /** + * @internal + * Hook for subclass-specific sprite change handling. + * SpriteMask overrides this to handle size/pivot changes. + */ + _onSpriteChangeExtra(type: SpriteModifyFlags): void {} + + /** @internal */ + _getSpriteWidth(): number { + return 0; + } + /** @internal */ + _getSpriteHeight(): number { + return 0; + } + /** @internal */ + _getSpritePivot(): Vector2 { + return null; + } + } + + return MaskRenderableBase as unknown as (abstract new (...args: any[]) => IMaskRenderable) & T; +} + +/** + * @remarks Extends `RendererUpdateFlags`. + */ +export enum MaskDirtyFlags { + /** UV. */ + UV = 0x2, + /** Automatic Size. */ + AutomaticSize = 0x8, + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x3, + /** All. */ + All = 0xb +} diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 1f740081f9..130a754896 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,60 +1,32 @@ import { BoundingBox } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { RenderElement } from "../../RenderPipeline/RenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; -import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderProperty } from "../../shader/ShaderProperty"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; -import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; -import { Sprite } from "./Sprite"; +import { MaskDirtyFlags, MaskRenderable } from "./MaskRenderable"; /** * A component for masking Sprites. */ -export class SpriteMask extends Renderer implements ISpriteRenderer { - /** @internal */ - static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); +export class SpriteMask extends MaskRenderable(Renderer) { /** @internal */ static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - - /** The mask layers the sprite mask influence to. */ - @assignmentClone - influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ - @ignoreClone - _renderElement: RenderElement; - + static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); /** @internal */ @ignoreClone _subChunk: SubPrimitiveChunk; - /** @internal */ - @ignoreClone - _maskIndex: number = -1; - - @ignoreClone - private _sprite: Sprite = null; @ignoreClone private _automaticWidth: number = 0; @ignoreClone private _automaticHeight: number = 0; - @assignmentClone private _customWidth: number = undefined; - @assignmentClone private _customHeight: number = undefined; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - - @assignmentClone - private _alphaCutoff: number = 0.5; /** * Render width (in world coordinates). @@ -67,7 +39,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { if (this._customWidth !== undefined) { return this._customWidth; } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); + this._dirtyUpdateFlag & MaskDirtyFlags.AutomaticSize && this._calDefaultSize(); return this._automaticWidth; } } @@ -90,7 +62,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { if (this._customHeight !== undefined) { return this._customHeight; } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); + this._dirtyUpdateFlag & MaskDirtyFlags.AutomaticSize && this._calDefaultSize(); return this._automaticHeight; } } @@ -102,84 +74,12 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } } - /** - * Flips the sprite on the X axis. - */ - get flipX(): boolean { - return this._flipX; - } - - set flipX(value: boolean) { - if (this._flipX !== value) { - this._flipX = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * Flips the sprite on the Y axis. - */ - get flipY(): boolean { - return this._flipY; - } - - set flipY(value: boolean) { - if (this._flipY !== value) { - this._flipY = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; - } - - set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; - if (value) { - this._addResourceReferCount(value, 1); - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(SpriteMask._textureProperty, value.texture); - } else { - this.shaderData.setTexture(SpriteMask._textureProperty, null); - } - this._sprite = value; - } - } - - /** - * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. - */ - get alphaCutoff(): number { - return this._alphaCutoff; - } - - set alphaCutoff(value: number) { - if (this._alphaCutoff !== value) { - this._alphaCutoff = value; - this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, value); - } - } - /** * @internal */ constructor(entity: Entity) { super(entity); - SimpleSpriteAssembler.resetData(this); - this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); - this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, this._alphaCutoff); - this._renderElement = new RenderElement(); - this._onSpriteChange = this._onSpriteChange.bind(this); + this._initMask(); } /** @@ -195,37 +95,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { */ override _cloneTo(target: SpriteMask): void { super._cloneTo(target); - target.sprite = this._sprite; - } - - /** - * @internal - */ - override _canBatch(preElement: RenderElement, curElement: RenderElement): boolean { - return VertexMergeBatcher.canBatchSpriteMask(preElement, curElement); - } - - /** - * @internal - */ - override _batch(preElement: RenderElement | null, curElement: RenderElement): void { - VertexMergeBatcher.batch(preElement, curElement); - } - - /** - * @internal - */ - override _onEnableInScene(): void { - super._onEnableInScene(); - this.scene._maskManager.addSpriteMask(this); - } - - /** - * @internal - */ - override _onDisableInScene(): void { - super._onDisableInScene(); - this.scene._maskManager.removeSpriteMask(this); + this._cloneMaskData(target); } /** @@ -236,144 +106,64 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; - if (sprite) { - SimpleSpriteAssembler.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - } else { - const { worldPosition } = this._transformEntity.transform; - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } + this._updateMaskBounds(worldBounds); } /** * @inheritdoc */ protected override _render(context: RenderContext): void { - const { _sprite: sprite } = this; - if (!sprite?.texture || !this.width || !this.height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - const { _engine: engine } = this; - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - material = engine._basicResources.spriteMaskDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SimpleSpriteAssembler.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV) { - SimpleSpriteAssembler.updateUVs(this); - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; - } - - const renderElement = this._renderElement; - const subChunk = this._subChunk; - renderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); - renderElement.priority = this.priority; - renderElement.distanceForSort = this._distanceForSort; - renderElement.subShader = material.shader.subShaders[0]; + this._renderMask(this._distanceForSort); } /** * @inheritdoc */ protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(this._onSpriteChange); - } + this._destroyMaskResources(); super._onDestroy(); - this._sprite = null; if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); this._subChunk = null; } + } - this._renderElement = null; + override _getSpriteWidth(): number { + return this.width; } - private _calDefaultSize(): void { - const sprite = this._sprite; - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.AutomaticSize; + override _getSpriteHeight(): number { + return this.height; } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { + override _getSpritePivot() { + return this.sprite?.pivot; + } + + override _onSpriteChangeExtra(type: SpriteModifyFlags): void { switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(SpriteMask._textureProperty, this.sprite.texture); - break; case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.AutomaticSize; + this._dirtyUpdateFlag |= MaskDirtyFlags.AutomaticSize; if (this._customWidth === undefined || this._customHeight === undefined) { this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.UV; - break; case SpriteModifyFlags.pivot: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - default: - break; } } -} -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteMaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 + private _calDefaultSize(): void { + const sprite = this.sprite; + if (sprite) { + this._automaticWidth = sprite.width; + this._automaticHeight = sprite.height; + } else { + this._automaticWidth = this._automaticHeight = 0; + } + this._dirtyUpdateFlag &= ~MaskDirtyFlags.AutomaticSize; + } } diff --git a/packages/core/src/2d/sprite/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts new file mode 100644 index 0000000000..b7033ee800 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteMaskUtils.ts @@ -0,0 +1,136 @@ +import { Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { Texture2D, TextureFormat } from "../../texture"; +import { Sprite } from "./Sprite"; + +/** + * Internal helpers for sprite mask hit testing. + * @internal + */ +export class SpriteMaskUtils { + private static _tempMat: Matrix = new Matrix(); + private static _tempVec3: Vector3 = new Vector3(); + private static _u8Buffer1 = new Uint8Array(1); + private static _u8Buffer2 = new Uint8Array(2); + private static _u8Buffer4 = new Uint8Array(4); + private static _u16Buffer1 = new Uint16Array(1); + private static _u16Buffer4 = new Uint16Array(4); + private static _f32Buffer4 = new Float32Array(4); + private static _u32Buffer4 = new Uint32Array(4); + + static containsWorldPoint( + worldPoint: Vector3, + sprite: Sprite | null, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, + alphaCutoff: number = 0 + ): boolean { + if (!sprite || !width || !height) { + return false; + } + + const worldMatrixInv = SpriteMaskUtils._tempMat; + Matrix.invert(worldMatrix, worldMatrixInv); + const localPosition = SpriteMaskUtils._tempVec3; + Vector3.transformCoordinate(worldPoint, worldMatrixInv, localPosition); + + const sx = flipX ? -width : width; + const sy = flipY ? -height : height; + if (!sx || !sy) { + return false; + } + + const spriteX = localPosition.x / sx + pivot.x; + const spriteY = localPosition.y / sy + pivot.y; + const spritePositions = sprite._getPositions(); + const { x: left, y: bottom } = spritePositions[0]; + const { x: right, y: top } = spritePositions[3]; + if (!(spriteX >= left && spriteX <= right && spriteY >= bottom && spriteY <= top)) { + return false; + } + + if (alphaCutoff <= 0) { + return true; + } + + const texture = sprite.texture; + if (!texture) { + return false; + } + + const spriteUVs = sprite._getUVs(); + const leftU = spriteUVs[0].x; + const bottomV = spriteUVs[0].y; + const rightU = spriteUVs[3].x; + const topV = spriteUVs[3].y; + const positionWidth = right - left; + const positionHeight = top - bottom; + if (!positionWidth || !positionHeight) { + return false; + } + + const tx = (spriteX - left) / positionWidth; + const ty = (spriteY - bottom) / positionHeight; + const u = leftU + (rightU - leftU) * tx; + const v = bottomV + (topV - bottomV) * ty; + const x = Math.min(Math.max(Math.floor(u * texture.width), 0), texture.width - 1); + const y = Math.min(Math.max(Math.floor(v * texture.height), 0), texture.height - 1); + return SpriteMaskUtils._sampleTextureAlpha(texture, x, y) >= alphaCutoff; + } + + private static _sampleTextureAlpha(texture: Texture2D, x: number, y: number): number { + try { + switch (texture.format) { + case TextureFormat.R8G8B8A8: { + const buffer = SpriteMaskUtils._u8Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 255; + } + case TextureFormat.R4G4B4A4: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return (buffer[0] & 0xf) / 15; + } + case TextureFormat.R5G5B5A1: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] & 0x1; + } + case TextureFormat.Alpha8: + case TextureFormat.R8: { + const buffer = SpriteMaskUtils._u8Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] / 255; + } + case TextureFormat.LuminanceAlpha: + case TextureFormat.R8G8: { + const buffer = SpriteMaskUtils._u8Buffer2; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[1] / 255; + } + case TextureFormat.R16G16B16A16: { + const buffer = SpriteMaskUtils._u16Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 65535; + } + case TextureFormat.R32G32B32A32: { + const buffer = SpriteMaskUtils._f32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3]; + } + case TextureFormat.R32G32B32A32_UInt: { + const buffer = SpriteMaskUtils._u32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 4294967295; + } + default: + return 1; + } + } catch { + return 1; + } + } +} diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 2a6986af6a..f01a4112c4 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -6,14 +6,17 @@ import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { RenderElement } from "../../RenderPipeline/RenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; -import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { FilledSpriteAssembler } from "../assembler/FilledSpriteAssembler"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; +import { SpriteFilledMode } from "../enums/SpriteFilledMode"; +import { SpriteFilledOrigin } from "../enums/SpriteFilledOrigin"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { SpriteTileMode } from "../enums/SpriteTileMode"; @@ -35,12 +38,13 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { private _drawMode: SpriteDrawMode; @ignoreClone private _assembler: ISpriteAssembler; - @assignmentClone private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; - @assignmentClone private _tiledAdaptiveThreshold: number = 0.5; + private _filledMode: SpriteFilledMode = SpriteFilledMode.Radial360; + private _filledAmount: number = 1; + private _filledOrigin: SpriteFilledOrigin = SpriteFilledOrigin.Bottom; + private _filledClockWise: boolean = true; - @deepClone private _color: Color = new Color(1, 1, 1, 1); @ignoreClone private _sprite: Sprite = null; @@ -49,13 +53,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { private _automaticWidth: number = 0; @ignoreClone private _automaticHeight: number = 0; - @assignmentClone private _customWidth: number = undefined; - @assignmentClone private _customHeight: number = undefined; - @assignmentClone private _flipX: boolean = false; - @assignmentClone private _flipY: boolean = false; /** @@ -78,6 +78,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._assembler = TiledSpriteAssembler; break; + case SpriteDrawMode.Filled: + this._assembler = FilledSpriteAssembler; + break; default: break; } @@ -119,6 +122,76 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { } } + /** + * The fill amount of the sprite renderer, range from 0 to 1. (Only works in filled mode.) + */ + get filledAmount(): number { + return this._filledAmount; + } + + set filledAmount(value: number) { + value = MathUtil.clamp(value, 0, 1); + if (this._filledAmount !== value) { + this._filledAmount = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill mode of the sprite renderer. (Only works in filled mode.) + */ + get filledMode(): SpriteFilledMode { + return this._filledMode; + } + + set filledMode(value: SpriteFilledMode) { + if (this._filledMode !== value) { + this._filledMode = value; + // Keep the current origin if it is still valid for the new mode, otherwise snap to the mode's + // default, since each mode only accepts a subset of origins. + this._filledOrigin = SpriteRenderer._correctOrigin(value, this._filledOrigin); + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill origin of the sprite renderer. (Only works in filled mode.) + */ + get filledOrigin(): SpriteFilledOrigin { + return this._filledOrigin; + } + + set filledOrigin(value: SpriteFilledOrigin) { + // Snap to a value valid for the current mode so state stays self-consistent (getter === rendered). + value = SpriteRenderer._correctOrigin(this._filledMode, value); + if (this._filledOrigin !== value) { + this._filledOrigin = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * Whether the fill is clockwise. (Only works in filled radial mode.) + */ + get filledClockWise(): boolean { + return this._filledClockWise; + } + + set filledClockWise(value: boolean) { + if (this._filledClockWise !== value) { + this._filledClockWise = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; + } + } + } + /** * The Sprite to render. */ @@ -437,6 +510,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; break; + case SpriteDrawMode.Filled: + this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; + break; } break; case SpriteModifyFlags.border: @@ -456,7 +532,11 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; break; case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.UV; + // Filled mode bakes UVs inside `updatePositions`, so a UV-only refresh would be a no-op. + this._dirtyUpdateFlag |= + this._drawMode === SpriteDrawMode.Filled + ? SpriteRendererUpdateFlags.WorldVolumeAndUV + : SpriteRendererUpdateFlags.UV; break; case SpriteModifyFlags.pivot: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; @@ -471,6 +551,39 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { private _onColorChanged(): void { this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.Color; } + + /** + * Returns an origin valid for the given fill mode: the passed origin when it belongs to the mode's + * accepted subset, otherwise the mode's default. Keeps `filledMode`/`filledOrigin` always consistent. + */ + private static _correctOrigin(mode: SpriteFilledMode, origin: SpriteFilledOrigin): SpriteFilledOrigin { + switch (mode) { + case SpriteFilledMode.Horizontal: + return origin === SpriteFilledOrigin.Left || origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Left; + case SpriteFilledMode.Vertical: + return origin === SpriteFilledOrigin.Top || origin === SpriteFilledOrigin.Bottom + ? origin + : SpriteFilledOrigin.Bottom; + case SpriteFilledMode.Radial90: + // Radial90 accepts the four corner origins. + return origin === SpriteFilledOrigin.TopLeft || + origin === SpriteFilledOrigin.TopRight || + origin === SpriteFilledOrigin.BottomLeft || + origin === SpriteFilledOrigin.BottomRight + ? origin + : SpriteFilledOrigin.BottomLeft; + default: + // Radial180 / Radial360 accept the four edge origins. + return origin === SpriteFilledOrigin.Top || + origin === SpriteFilledOrigin.Bottom || + origin === SpriteFilledOrigin.Left || + origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Bottom; + } + } } /** diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index 162d016472..c48fb9261b 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,3 +1,6 @@ +export type { IMaskRenderable } from "./MaskRenderable"; +export { MaskDirtyFlags, MaskRenderable } from "./MaskRenderable"; export { Sprite } from "./Sprite"; export { SpriteMask } from "./SpriteMask"; +export { SpriteMaskUtils } from "./SpriteMaskUtils"; export { SpriteRenderer } from "./SpriteRenderer"; diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index 3649180c79..47b4daa8ff 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -1,14 +1,15 @@ -import { BoundingBox, Color, Vector3 } from "@galacean/engine-math"; +import { BoundingBox, Color, Vector2, Vector3 } from "@galacean/engine-math"; import { Engine } from "../../Engine"; import { Entity } from "../../Entity"; -import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; -import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; import { Renderer } from "../../Renderer"; import { TransformModifyFlags } from "../../Transform"; -import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderData, ShaderProperty } from "../../shader"; import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; import { Texture2D } from "../../texture"; @@ -21,54 +22,45 @@ import { Font } from "./Font"; import { ITextRenderer } from "./ITextRenderer"; import { SubFont } from "./SubFont"; import { TextUtils } from "./TextUtils"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; /** * Renders a text for 2D graphics. */ export class TextRenderer extends Renderer implements ITextRenderer { - private static _textureProperty = ShaderProperty.getByName("renderElement_TextTexture"); + private static _tempVec20 = new Vector2(); private static _tempVec30 = new Vector3(); private static _tempVec31 = new Vector3(); + private static _textureProperty = ShaderProperty.getByName("renderElement_TextTexture"); + private static _textTextureSizeProperty = ShaderProperty.getByName("renderElement_TextTextureSize"); + private static _outlineColorProperty = ShaderProperty.getByName("renderer_OutlineColor"); + private static _outlineWidthProperty = ShaderProperty.getByName("renderer_OutlineWidth"); private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; private static _charRenderInfos: CharRenderInfo[] = []; @ignoreClone private _textChunks = Array(); /** @internal */ - @assignmentClone _subFont: SubFont = null; /** @internal */ @ignoreClone _dirtyFlag = DirtyFlag.Font; - @deepClone private _color = new Color(1, 1, 1, 1); - @assignmentClone private _text = ""; - @assignmentClone private _width = 0; - @assignmentClone private _height = 0; @ignoreClone private _localBounds = new BoundingBox(); - @assignmentClone private _font: Font = null; - @assignmentClone private _fontSize = 24; - @assignmentClone private _fontStyle = FontStyle.None; - @assignmentClone private _lineSpacing = 0; - @assignmentClone private _characterSpacing = 0; - @assignmentClone private _horizontalAlignment = TextHorizontalAlignment.Center; - @assignmentClone private _verticalAlignment = TextVerticalAlignment.Center; - @assignmentClone private _enableWrapping = false; - @assignmentClone private _overflowMode = OverflowMode.Overflow; + private _outlineColor = new Color(0, 0, 0, 1); + private _outlineWidth = 0; /** * Rendering color for the Text. @@ -255,6 +247,35 @@ export class TextRenderer extends Renderer implements ITextRenderer { } } + /** + * The outline width in pixels. 0 means outline is disabled. Clamped to [0, 8]. + */ + get outlineWidth(): number { + return this._outlineWidth; + } + + set outlineWidth(value: number) { + value = Math.max(0, Math.min(value, 3)); + if (this._outlineWidth !== value) { + this._outlineWidth = value; + this.shaderData.setFloat(TextRenderer._outlineWidthProperty, value); + this._setDirtyFlagTrue(DirtyFlag.Position); + } + } + + /** + * The outline color. Only effective when outlineWidth > 0. + */ + get outlineColor(): Color { + return this._outlineColor; + } + + set outlineColor(value: Color) { + if (this._outlineColor !== value) { + this._outlineColor.copyFrom(value); + } + } + /** * Interacts with the masks. */ @@ -309,8 +330,13 @@ export class TextRenderer extends Renderer implements ITextRenderer { this._font = engine._textDefaultFont; this._addResourceReferCount(this._font, 1); this.setMaterial(engine._basicResources.textDefaultMaterial); + const shaderData = this.shaderData; + shaderData.setFloat(TextRenderer._outlineWidthProperty, this._outlineWidth); + shaderData.setColor(TextRenderer._outlineColorProperty, this._outlineColor); //@ts-ignore this._color._onValueChanged = this._onColorChanged.bind(this); + // @ts-ignore + this._outlineColor._onValueChanged = this._onOutlineColorChanged.bind(this); } /** @@ -330,18 +356,6 @@ export class TextRenderer extends Renderer implements ITextRenderer { this._subFont && (this._subFont = null); } - /** - * @internal - */ - override _cloneTo(target: TextRenderer): void { - super._cloneTo(target); - target.font = this._font; - target._subFont = this._subFont; - } - - /** - * @internal - */ _isContainDirtyFlag(type: number): boolean { return (this._dirtyFlag & type) != 0; } @@ -382,7 +396,7 @@ export class TextRenderer extends Renderer implements ITextRenderer { * @internal */ override _canBatch(preElement: RenderElement, curElement: RenderElement): boolean { - return VertexMergeBatcher.canBatchSprite(preElement, curElement); + return VertexMergeBatcher.canBatchText(preElement, curElement); } /** @@ -436,12 +450,17 @@ export class TextRenderer extends Renderer implements ITextRenderer { const distanceForSort = this._distanceForSort; const renderPipeline = camera._renderPipeline; const textChunks = this._textChunks; + const textTextureSize = TextRenderer._tempVec20; for (let i = 0, n = textChunks.length; i < n; ++i) { const { subChunk, texture } = textChunks[i]; const renderElement = textRenderElementPool.get(); renderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); renderElement.shaderData ||= new ShaderData(ShaderDataGroup.RenderElement); renderElement.shaderData.setTexture(TextRenderer._textureProperty, texture); + renderElement.shaderData.setVector2( + TextRenderer._textTextureSizeProperty, + textTextureSize.set(texture.width, texture.height) + ); renderElement.priority = priority; renderElement.distanceForSort = distanceForSort; renderPipeline.pushRenderElement(context, renderElement); @@ -454,6 +473,16 @@ export class TextRenderer extends Renderer implements ITextRenderer { this._subFont.nativeFontString = TextUtils.getNativeFontString(font.name, this.fontSize, this.fontStyle); } + /** + * Switch the sub font to a specific font size, used by the SHRINK overflow measurement. + */ + private _applyFontSizeForShrink(fontSize: number): void { + const font = this._font; + const subFont = font._getSubFont(fontSize, this._fontStyle); + subFont.nativeFontString = TextUtils.getNativeFontString(font.name, fontSize, this._fontStyle); + this._subFont = subFont; + } + private _updatePosition(): void { const { transform } = this.entity; const e = transform.worldMatrix.elements; @@ -527,22 +556,37 @@ export class TextRenderer extends Renderer implements ITextRenderer { const { _pixelsPerUnit } = Engine; const { min, max } = this._localBounds; const charRenderInfos = TextRenderer._charRenderInfos; + const rendererWidth = this.width * _pixelsPerUnit; + const rendererHeight = this.height * _pixelsPerUnit; + let fontSize = this._fontSize; + let textMetrics: ReturnType; + if (this._overflowMode === OverflowMode.Shrink) { + const result = TextUtils.measureTextWithShrink( + this, + rendererWidth, + rendererHeight, + this._fontSize, + this._lineSpacing, + this._characterSpacing, + this.enableWrapping, + (size) => this._applyFontSizeForShrink(size) + ); + fontSize = result.fontSize; + textMetrics = result.metrics; + } else { + const characterSpacing = this._characterSpacing * fontSize; + textMetrics = this.enableWrapping + ? TextUtils.measureTextWithWrap( + this, + rendererWidth, + rendererHeight, + this._lineSpacing * fontSize, + characterSpacing + ) + : TextUtils.measureTextWithoutWrap(this, rendererHeight, this._lineSpacing * fontSize, characterSpacing); + } const charFont = this._getSubFont(); - const characterSpacing = this._characterSpacing * this._fontSize; - const textMetrics = this.enableWrapping - ? TextUtils.measureTextWithWrap( - this, - this.width * _pixelsPerUnit, - this.height * _pixelsPerUnit, - this._lineSpacing * this._fontSize, - characterSpacing - ) - : TextUtils.measureTextWithoutWrap( - this, - this.height * _pixelsPerUnit, - this._lineSpacing * this._fontSize, - characterSpacing - ); + const characterSpacing = this._characterSpacing * fontSize; const { height, lines, lineWidths, lineHeight, lineMaxSizes } = textMetrics; const charRenderInfoPool = this.engine._charRenderInfoPool; const linesLen = lines.length; @@ -551,9 +595,7 @@ export class TextRenderer extends Renderer implements ITextRenderer { if (linesLen > 0) { const { horizontalAlignment } = this; const pixelsPerUnitReciprocal = 1.0 / _pixelsPerUnit; - const rendererWidth = this._width * _pixelsPerUnit; const halfRendererWidth = rendererWidth * 0.5; - const rendererHeight = this._height * _pixelsPerUnit; const halfLineHeight = lineHeight * 0.5; let startY = 0; @@ -564,7 +606,10 @@ export class TextRenderer extends Renderer implements ITextRenderer { startY = rendererHeight * 0.5 - halfLineHeight + topDiff; break; case TextVerticalAlignment.Center: - startY = height * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; + // Center the text block (lineHeight * lineCount) within the renderer, independent of + // `height` — which equals the renderer height for Truncate/Shrink and would otherwise + // push the text upward by (rendererHeight - blockHeight) / 2 when the box is taller. + startY = lineHeight * linesLen * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; break; case TextVerticalAlignment.Bottom: startY = height - rendererHeight * 0.5 - halfLineHeight - bottomDiff; @@ -606,10 +651,11 @@ export class TextRenderer extends Renderer implements ITextRenderer { charRenderInfo.texture = charFont._getTextureByIndex(charInfo.index); charRenderInfo.uvs = charInfo.uvs; const { w, ascent, descent } = charInfo; - const left = startX * pixelsPerUnitReciprocal; - const right = (startX + w) * pixelsPerUnitReciprocal; - const top = (startY + ascent) * pixelsPerUnitReciprocal; - const bottom = (startY - descent) * pixelsPerUnitReciprocal; + const ow = this._outlineWidth * pixelsPerUnitReciprocal; + const left = startX * pixelsPerUnitReciprocal - ow; + const right = (startX + w) * pixelsPerUnitReciprocal + ow; + const top = (startY + ascent) * pixelsPerUnitReciprocal + ow; + const bottom = (startY - descent) * pixelsPerUnitReciprocal - ow; localPositions.set(left, top, right, bottom); i === firstLine && (maxY = Math.max(maxY, top)); minY = Math.min(minY, bottom); @@ -688,7 +734,7 @@ export class TextRenderer extends Renderer implements ITextRenderer { this._text === "" || this._fontSize === 0 || (this.enableWrapping && this.width <= 0) || - (this.overflowMode === OverflowMode.Truncate && this.height <= 0) + ((this.overflowMode === OverflowMode.Truncate || this.overflowMode === OverflowMode.Shrink) && this.height <= 0) ); } @@ -700,6 +746,10 @@ export class TextRenderer extends Renderer implements ITextRenderer { const vertices = subChunk.chunk.vertices; const indices = (subChunk.indices = []); const charRenderInfos = textChunk.charRenderInfos; + const ow = this._outlineWidth; + const texture = textChunk.texture; + const owU = ow > 0 ? ow / texture.width : 0; + const owV = ow > 0 ? ow / texture.height : 0; for (let i = 0, ii = 0, io = 0, vo = subChunk.vertexArea.start + 3; i < count; ++i, io += 4) { const charRenderInfo = charRenderInfos[i]; charRenderInfo.indexInChunk = i; @@ -709,10 +759,13 @@ export class TextRenderer extends Renderer implements ITextRenderer { indices[ii++] = tempIndices[j] + io; } - // Set uv and color for vertices + // Set uv and color for vertices, expand uv outward by outline width for (let j = 0; j < 4; ++j, vo += 9) { const uv = charRenderInfo.uvs[j]; - uv.copyToArray(vertices, vo); + const su = j === 1 || j === 2 ? 1 : -1; + const sv = j >= 2 ? 1 : -1; + vertices[vo] = uv.x + owU * su; + vertices[vo + 1] = uv.y + owV * sv; vertices[vo + 2] = r; vertices[vo + 3] = g; vertices[vo + 4] = b; @@ -745,6 +798,11 @@ export class TextRenderer extends Renderer implements ITextRenderer { private _onColorChanged(): void { this._setDirtyFlagTrue(DirtyFlag.Color); } + + @ignoreClone + private _onOutlineColorChanged(): void { + this.shaderData.setColor(TextRenderer._outlineColorProperty, this._outlineColor); + } } class TextChunk { diff --git a/packages/core/src/2d/text/TextUtils.ts b/packages/core/src/2d/text/TextUtils.ts index ce2440573f..5305049fa9 100644 --- a/packages/core/src/2d/text/TextUtils.ts +++ b/packages/core/src/2d/text/TextUtils.ts @@ -100,7 +100,8 @@ export class TextUtils { rendererWidth: number, rendererHeight: number, lineSpacing: number, - characterSpacing: number + characterSpacing: number, + uploadCharTexture: boolean = true ): TextMetrics { const subFont = renderer._getSubFont(); const fontString = subFont.nativeFontString; @@ -137,7 +138,7 @@ export class TextUtils { for (let j = 0, m = subText.length; j < m; ++j) { const char = subText[j]; - const charInfo = TextUtils._getCharInfo(char, fontString, subFont); + const charInfo = TextUtils._getCharInfo(char, fontString, subFont, uploadCharTexture); const charCode = char.charCodeAt(0); const isSpace = charCode === 32; @@ -284,7 +285,8 @@ export class TextUtils { renderer: ITextRenderer, rendererHeight: number, lineSpacing: number, - characterSpacing: number + characterSpacing: number, + uploadCharTexture: boolean = true ): TextMetrics { const subFont = renderer._getSubFont(); const fontString = subFont.nativeFontString; @@ -306,7 +308,7 @@ export class TextUtils { let maxDescent = 0; for (let j = 0; j < lineLength; ++j) { - const charInfo = TextUtils._getCharInfo(line[j], fontString, subFont); + const charInfo = TextUtils._getCharInfo(line[j], fontString, subFont, uploadCharTexture); curWidth += charInfo.xAdvance; const { offsetY } = charInfo; const halfH = charInfo.h * 0.5; @@ -337,6 +339,85 @@ export class TextUtils { }; } + /** + * Measure text in SHRINK overflow mode: keep shrinking the font size until the text fits + * within the bounds (both width and height). Mirrors Cocos Creator's `Overflow.SHRINK`. + * + * @param renderer - The text renderer + * @param rendererWidth - The width of the bounds in pixels + * @param rendererHeight - The height of the bounds in pixels + * @param originalFontSize - The font size set on the renderer (the upper bound, never enlarged) + * @param lineSpacing - The line spacing ratio (relative to font size) + * @param characterSpacing - The character spacing ratio (relative to font size) + * @param enableWrapping - Whether wrapping is enabled + * @param applyFontSize - Callback that switches the renderer's sub font to the given font size + * @returns The fitted text metrics and the actual font size used for layout + */ + static measureTextWithShrink( + renderer: ITextRenderer, + rendererWidth: number, + rendererHeight: number, + originalFontSize: number, + lineSpacing: number, + characterSpacing: number, + enableWrapping: boolean, + applyFontSize: (fontSize: number) => void + ): { metrics: TextMetrics; fontSize: number } { + // During the binary search we only need the text dimensions, so pass `uploadCharTexture=false` + // to avoid building GPU font atlases for the intermediate font sizes that won't be used. + // The fitted size is then re-measured once with upload=true to populate its atlas. This trades + // one extra full measure pass (CPU) for skipping ~log2(size) throwaway atlas textures (GPU); + // we can't reuse the search's last measure because its char bitmaps were never cached. + const measureAt = (fontSize: number, uploadCharTexture: boolean): TextMetrics => { + applyFontSize(fontSize); + return enableWrapping + ? TextUtils.measureTextWithWrap( + renderer, + rendererWidth, + rendererHeight, + lineSpacing * fontSize, + characterSpacing * fontSize, + uploadCharTexture + ) + : TextUtils.measureTextWithoutWrap( + renderer, + rendererHeight, + lineSpacing * fontSize, + characterSpacing * fontSize, + uploadCharTexture + ); + }; + // The content height is `lineHeight * lineCount`; `metrics.height` is clamped to the bounds + // unless overflowMode is Overflow, so it can't be used to detect vertical overflow here. + const isFit = (metrics: TextMetrics): boolean => + metrics.width <= rendererWidth && metrics.lineHeight * metrics.lines.length <= rendererHeight; + + // If the text already fits at the original size, keep it (SHRINK only shrinks, never enlarges). + let metrics = measureAt(originalFontSize, false); + if (isFit(metrics)) { + metrics = measureAt(originalFontSize, true); + return { metrics, fontSize: originalFontSize }; + } + + // Binary search for the largest integer font size that fits (measure only, no atlas upload). + let low = 1; + let high = Math.floor(originalFontSize); + let fitFontSize = low; + while (low <= high) { + const mid = (low + high) >> 1; + metrics = measureAt(mid, false); + if (isFit(metrics)) { + fitFontSize = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + // Re-measure the fitted size with upload=true so the sub font / atlas correspond to it. + metrics = measureAt(fitFontSize, true); + return { metrics, fontSize: fitFontSize }; + } + /** * Get native font hash. * @param fontName - The font name @@ -462,12 +543,16 @@ export class TextUtils { /** * @internal */ - static _getCharInfo(char: string, fontString: string, font: SubFont): CharInfo { + static _getCharInfo(char: string, fontString: string, font: SubFont, uploadCharTexture: boolean = true): CharInfo { let charInfo = font._getCharInfo(char); if (!charInfo) { charInfo = TextUtils.measureChar(char, fontString); - font._uploadCharTexture(charInfo); - font._addCharInfo(char, charInfo); + // SHRINK 的二分阶段只需字符尺寸(measureChar 已给出),传 uploadCharTexture=false 跳过 GPU + // 字形图集上传与缓存,避免给用不到的中间字号建字体 atlas 纹理。 + if (uploadCharTexture) { + font._uploadCharTexture(charInfo); + font._addCharInfo(char, charInfo); + } } return charInfo; diff --git a/packages/core/src/Camera.ts b/packages/core/src/Camera.ts index 55d904f6c9..853ff61ad8 100644 --- a/packages/core/src/Camera.ts +++ b/packages/core/src/Camera.ts @@ -9,7 +9,7 @@ import { Transform } from "./Transform"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { VirtualCamera } from "./VirtualCamera"; import { GLCapabilityType, Logger } from "./base"; -import { deepClone, ignoreClone } from "./clone/CloneManager"; +import { ignoreClone } from "./clone/CloneManager"; import { AntiAliasing } from "./enums/AntiAliasing"; import { CameraClearFlags } from "./enums/CameraClearFlags"; import { CameraModifyFlags } from "./enums/CameraModifyFlags"; @@ -116,13 +116,11 @@ export class Camera extends Component { @ignoreClone _globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection(); /** @internal */ - @deepClone _frustum: BoundingFrustum = new BoundingFrustum(); /** @internal */ @ignoreClone _renderPipeline: BasicRenderPipeline; /** @internal */ - @deepClone _virtualCamera: VirtualCamera = new VirtualCamera(); /** @internal */ _replacementShader: Shader = null; @@ -148,25 +146,16 @@ export class Camera extends Component { private _msaaSamples: MSAASamples; private _renderTarget: RenderTarget = null; - @ignoreClone private _updateFlagManager: UpdateFlagManager; - @ignoreClone private _frustumChangeFlag: BoolUpdateFlag; - @ignoreClone private _isViewMatrixDirty: BoolUpdateFlag; - @ignoreClone private _isInvViewProjDirty: BoolUpdateFlag; - @deepClone private _shaderData: ShaderData = new ShaderData(ShaderDataGroup.Camera); @ignoreClone private _depthBufferParams: Vector4 = new Vector4(); - @deepClone private _viewport: Vector4 = new Vector4(0, 0, 1, 1); - @deepClone private _pixelViewport: Rect = new Rect(0, 0, 0, 0); - @deepClone private _inverseProjectionMatrix: Matrix = new Matrix(); - @deepClone private _invViewProjMat: Matrix = new Matrix(); /** @@ -826,13 +815,6 @@ export class Camera extends Component { this._updateFlagManager?.removeListener(onChange); } - /** - * @internal - */ - _cloneTo(target: Camera): void { - this._renderTarget?._addReferCount(1); - } - /** * @internal * @inheritdoc diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts index 2ebafbcd05..c3fd1733dd 100644 --- a/packages/core/src/Component.ts +++ b/packages/core/src/Component.ts @@ -1,7 +1,6 @@ import { IReferable } from "./asset/IReferable"; import { EngineObject } from "./base"; -import { assignmentClone, ignoreClone } from "./clone/CloneManager"; -import { CloneUtils } from "./clone/CloneUtils"; +import { ignoreClone } from "./clone/CloneManager"; import { Entity } from "./Entity"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { Scene } from "./Scene"; @@ -23,7 +22,6 @@ export class Component extends EngineObject { @ignoreClone private _phasedActive: boolean = false; - @assignmentClone private _enabled: boolean = true; /** @@ -154,13 +152,6 @@ export class Component extends EngineObject { } } - /** - * @internal - */ - _remap(srcRoot: Entity, targetRoot: Entity): T { - return CloneUtils.remapComponent(srcRoot, targetRoot, this) as unknown as T; - } - protected _addResourceReferCount(resource: IReferable, count: number): void { this._entity._isTemplate || resource._addReferCount(count); } diff --git a/packages/core/src/ComponentsDependencies.ts b/packages/core/src/ComponentsDependencies.ts index 9af8569b12..b76dec5982 100644 --- a/packages/core/src/ComponentsDependencies.ts +++ b/packages/core/src/ComponentsDependencies.ts @@ -36,10 +36,12 @@ export class ComponentsDependencies { /** * @internal */ - static _removeCheck(entity: Entity, type: ComponentConstructor): void { + static _removeCheck(entity: Entity, type: ComponentConstructor, replace?: ComponentConstructor): void { const components = entity._components; const n = components.length; while (type !== Component) { + // The replacement still satisfies this type and all of its base types. + if (replace && (replace === type || replace.prototype instanceof type)) return; let count = 0; for (let i = 0; i < n; i++) { if (components[i] instanceof type && ++count > 1) return; @@ -64,7 +66,7 @@ export class ComponentsDependencies { dependentComponent: ComponentConstructor, map: Map ): void { - let components = map.get(targetInfo); + const components = map.get(targetInfo); if (!components) { map.set(targetInfo, [dependentComponent]); } else { @@ -77,7 +79,7 @@ export class ComponentsDependencies { */ static _addInvDependency(currentComponent: ComponentConstructor, dependentComponent: ComponentConstructor): void { const map = this._invDependenciesMap; - let components = map.get(currentComponent); + const components = map.get(currentComponent); if (!components) { map.set(currentComponent, [dependentComponent]); } else { diff --git a/packages/core/src/Engine.ts b/packages/core/src/Engine.ts index dc26be9235..0bc043be47 100644 --- a/packages/core/src/Engine.ts +++ b/packages/core/src/Engine.ts @@ -711,6 +711,7 @@ export class Engine extends EventDispatcher { this._renderElementPool.garbageCollection(); this._textRenderElementPool.garbageCollection(); this._renderContext.garbageCollection(); + this.inputManager._gc(); const scenes = this._sceneManager._scenes.getLoopArray(); for (let i = 0, n = scenes.length; i < n; i++) { scenes[i]?.physics?._gc(); diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index eab8ce5aa2..1bbabb92d7 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -9,8 +9,7 @@ import { Script } from "./Script"; import { Transform } from "./Transform"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { ReferResource } from "./asset/ReferResource"; -import { EngineObject } from "./base"; -import { CloneUtils } from "./clone/CloneUtils"; +import { EngineObject, Logger } from "./base"; import { ComponentCloner } from "./clone/ComponentCloner"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { EntityModifyFlags } from "./enums/EntityModifyFlags"; @@ -22,6 +21,11 @@ import { DisorderedArray } from "./utils/DisorderedArray"; export class Entity extends EngineObject { /** @internal */ static _tempComponentConstructors: ComponentConstructor[] = []; + + private static _isTransformType(type: ComponentConstructor): boolean { + return type === Transform || type.prototype instanceof Transform; + } + /** * @internal */ @@ -214,16 +218,14 @@ export class Entity extends EngineObject { } set siblingIndex(value: number) { - if (this._siblingIndex === -1) { - throw `The entity ${this.name} is not in the hierarchy`; - } - if (this._isRoot) { this._setSiblingIndex(this._scene._rootEntities, value); - } else { + } else if (this._parent) { const parent = this._parent; this._setSiblingIndex(parent._children, value); parent._dispatchModify(EntityModifyFlags.Child, parent); + } else { + Logger.warn(`The entity ${this.name} is not in the hierarchy`); } } @@ -236,10 +238,22 @@ export class Entity extends EngineObject { constructor(engine: Engine, name?: string, ...components: ComponentConstructor[]) { super(engine); this.name = name ?? "Entity"; - for (let i = 0, n = components.length; i < n; i++) { - this.addComponent(components[i]); + let transformType: ComponentConstructor = Transform; + const n = components.length; + for (let i = n - 1; i >= 0; i--) { + const componentType = components[i]; + if (Entity._isTransformType(componentType)) { + transformType = componentType; + break; + } + } + this._transform = this.addComponent(transformType); + for (let i = 0; i < n; i++) { + const componentType = components[i]; + if (!Entity._isTransformType(componentType)) { + this.addComponent(componentType); + } } - !this._transform && this.addComponent(Transform); this._inverseWorldMatFlag = this.registerWorldChangeFlag(); } @@ -250,12 +264,17 @@ export class Entity extends EngineObject { * @returns The component which has been added */ addComponent(type: T, ...args: ComponentArguments): InstanceType { + const needReplaceTransform = Entity._isTransformType(type) && this._transform; + if (needReplaceTransform) { + ComponentsDependencies._removeCheck(this, this._transform.constructor, type); + } ComponentsDependencies._addCheck(this, type); const component = new type(this, ...args) as InstanceType; - this._components.push(component); - - // @todo: temporary solution - if (component instanceof Transform) this._setTransform(component); + if (needReplaceTransform) { + this._replaceTransform(component); + } else { + this._components.push(component); + } component._setActive(true, ActiveChangeFlag.All); return component; } @@ -402,18 +421,9 @@ export class Entity extends EngineObject { */ clearChildren(): void { const children = this._children; - for (let i = children.length - 1; i >= 0; i--) { - const child = children[i]; - child._parent = null; - - let activeChangeFlag = ActiveChangeFlag.None; - child._isActiveInHierarchy && (activeChangeFlag |= ActiveChangeFlag.Hierarchy); - child._isActiveInScene && (activeChangeFlag |= ActiveChangeFlag.Scene); - activeChangeFlag && child._processInActive(activeChangeFlag); - - Entity._traverseSetOwnerScene(child, null); // Must after child._processInActive(). + while (children.length > 0) { + children[children.length - 1]._setParent(null); } - children.length = 0; } /** @@ -421,8 +431,9 @@ export class Entity extends EngineObject { * @returns Cloned entity */ clone(): Entity { - const cloneEntity = this._createCloneEntity(); - this._parseCloneEntity(this, cloneEntity, this, cloneEntity, new Map()); + const cloneMap = new Map(); + const cloneEntity = this._createCloneEntity(cloneMap); + this._parseCloneEntity(this, cloneEntity, cloneMap); return cloneEntity; } @@ -434,13 +445,6 @@ export class Entity extends EngineObject { return this._updateFlagManager.createFlag(BoolUpdateFlag); } - /** - * @internal - */ - _remap(srcRoot: Entity, targetRoot: Entity): Entity { - return CloneUtils.remapEntity(srcRoot, targetRoot, this); - } - /** * @internal */ @@ -449,7 +453,7 @@ export class Entity extends EngineObject { this._templateResource = templateResource; } - private _createCloneEntity(): Entity { + private _createCloneEntity(cloneMap: Map): Entity { const componentConstructors = Entity._tempComponentConstructors; const components = this._components; for (let i = 0, n = components.length; i < n; i++) { @@ -457,6 +461,11 @@ export class Entity extends EngineObject { } const cloneEntity = new Entity(this.engine, this.name, ...componentConstructors); componentConstructors.length = 0; + cloneMap.set(this, cloneEntity); + const targetComponents = cloneEntity._components; + for (let i = 0, n = components.length; i < n; i++) { + cloneMap.set(components[i], targetComponents[i]); + } const templateResource = this._templateResource; if (templateResource) { cloneEntity._templateResource = templateResource; @@ -467,27 +476,21 @@ export class Entity extends EngineObject { cloneEntity._isActive = this._isActive; const srcChildren = this._children; for (let i = 0, n = srcChildren.length; i < n; i++) { - cloneEntity.addChild(srcChildren[i]._createCloneEntity()); + cloneEntity.addChild(srcChildren[i]._createCloneEntity(cloneMap)); } return cloneEntity; } - private _parseCloneEntity( - src: Entity, - target: Entity, - srcRoot: Entity, - targetRoot: Entity, - deepInstanceMap: Map - ): void { + private _parseCloneEntity(src: Entity, target: Entity, cloneMap: Map): void { const srcChildren = src._children; const targetChildren = target._children; for (let i = 0, n = srcChildren.length; i < n; i++) { - this._parseCloneEntity(srcChildren[i], targetChildren[i], srcRoot, targetRoot, deepInstanceMap); + this._parseCloneEntity(srcChildren[i], targetChildren[i], cloneMap); } const components = src._components; for (let i = 0, n = components.length; i < n; i++) { - ComponentCloner.cloneComponent(components[i], target._components[i], srcRoot, targetRoot, deepInstanceMap); + ComponentCloner.cloneComponent(components[i], target._components[i], cloneMap); } } @@ -529,9 +532,13 @@ export class Entity extends EngineObject { * @internal */ _removeComponent(component: Component): void { - ComponentsDependencies._removeCheck(this, component.constructor as ComponentConstructor); const components = this._components; - components.splice(components.indexOf(component), 1); + const index = components.indexOf(component); + // A replaced Transform is detached from the component slot immediately but + // can still reach here later because object destruction may be deferred. + if (index < 0) return; + ComponentsDependencies._removeCheck(this, component.constructor as ComponentConstructor); + components.splice(index, 1); } /** @@ -762,9 +769,18 @@ export class Entity extends EngineObject { } } - private _setTransform(value: Transform): void { - this._transform?.destroy(); + private _replaceTransform(value: Transform): void { + const previous = this._transform; + value.position.copyFrom(previous.position); + value.rotationQuaternion.copyFrom(previous.rotationQuaternion); + value.scale.copyFrom(previous.scale); + // Keep the unique Transform in the same component slot. Detach the old + // instance before destroy because destroy can be deferred during a frame. + const components = this._components; + const previousIndex = components.indexOf(previous); + components[previousIndex] = value; this._transform = value; + previous.destroy(); const children = this._children; for (let i = 0, n = children.length; i < n; i++) { children[i].transform?._parentChange(); diff --git a/packages/core/src/RenderPipeline/MaskManager.ts b/packages/core/src/RenderPipeline/MaskManager.ts index b77c37d4d5..69a637b305 100644 --- a/packages/core/src/RenderPipeline/MaskManager.ts +++ b/packages/core/src/RenderPipeline/MaskManager.ts @@ -1,4 +1,6 @@ -import { SpriteMask } from "../2d"; +import { Vector3 } from "@galacean/engine-math"; +import { SpriteMaskInteraction } from "../2d/enums/SpriteMaskInteraction"; +import { IMaskRenderable } from "../2d/sprite/MaskRenderable"; import { CameraClearFlags } from "../enums/CameraClearFlags"; import { SpriteMaskLayer } from "../enums/SpriteMaskLayer"; import { Material } from "../material"; @@ -40,17 +42,49 @@ export class MaskManager { hasStencilWritten = false; private _preMaskLayer = SpriteMaskLayer.Nothing; - private _allSpriteMasks = new DisorderedArray(); + private _allSpriteMasks = new DisorderedArray(); + private _filteredMasksByLayer = new Map(); + private _isFilteredMasksDirty = true; - addSpriteMask(mask: SpriteMask): void { + addSpriteMask(mask: IMaskRenderable): void { mask._maskIndex = this._allSpriteMasks.length; this._allSpriteMasks.add(mask); + this._setFilteredMasksDirty(); } - removeSpriteMask(mask: SpriteMask): void { + removeSpriteMask(mask: IMaskRenderable): void { const replaced = this._allSpriteMasks.deleteByIndex(mask._maskIndex); replaced && (replaced._maskIndex = mask._maskIndex); mask._maskIndex = -1; + this._setFilteredMasksDirty(); + } + + onMaskInfluenceLayersChange(): void { + this._setFilteredMasksDirty(); + } + + isVisibleByMask(maskInteraction: SpriteMaskInteraction, maskLayer: SpriteMaskLayer, worldPoint: Vector3): boolean { + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + + const masks = this._getMasksByLayer(maskLayer); + let insideMask = false; + for (let i = 0, n = masks.length; i < n; i++) { + if (masks[i]._containsWorldPoint(worldPoint)) { + insideMask = true; + break; + } + } + + switch (maskInteraction) { + case SpriteMaskInteraction.VisibleInsideMask: + return insideMask; + case SpriteMaskInteraction.VisibleOutsideMask: + return !insideMask; + default: + return true; + } } drawMask(context: RenderContext, pipelineStageTagValue: string, maskLayer: SpriteMaskLayer): void { @@ -134,6 +168,38 @@ export class MaskManager { const allSpriteMasks = this._allSpriteMasks; allSpriteMasks.length = 0; allSpriteMasks.garbageCollection(); + this._filteredMasksByLayer.clear(); + this._isFilteredMasksDirty = true; + } + + private _setFilteredMasksDirty(): void { + this._isFilteredMasksDirty = true; + } + + private _getMasksByLayer(maskLayer: SpriteMaskLayer): IMaskRenderable[] { + if (maskLayer === SpriteMaskLayer.Nothing) { + return []; + } + + if (this._isFilteredMasksDirty) { + this._filteredMasksByLayer.clear(); + this._isFilteredMasksDirty = false; + } + + let filteredMasks = this._filteredMasksByLayer.get(maskLayer); + if (!filteredMasks) { + filteredMasks = []; + const allMasks = this._allSpriteMasks; + const maskElements = allMasks._elements; + for (let i = 0, n = allMasks.length; i < n; i++) { + const mask = maskElements[i]; + if (mask.influenceLayers & maskLayer) { + filteredMasks.push(mask); + } + } + this._filteredMasksByLayer.set(maskLayer, filteredMasks); + } + return filteredMasks; } private _buildMaskRenderElement( diff --git a/packages/core/src/RenderPipeline/VertexMergeBatcher.ts b/packages/core/src/RenderPipeline/VertexMergeBatcher.ts index 9bf7777b62..369f66ea73 100644 --- a/packages/core/src/RenderPipeline/VertexMergeBatcher.ts +++ b/packages/core/src/RenderPipeline/VertexMergeBatcher.ts @@ -13,6 +13,36 @@ export class VertexMergeBatcher { const renderer = curElement.component; const maskInteraction = preRenderer.maskInteraction; + const preRendererAny = preRenderer as any; + const curRendererAny = renderer as any; + const rectMaskEnabledA = preRendererAny._rectMaskEnabled; + if (rectMaskEnabledA !== curRendererAny._rectMaskEnabled) { + return false; + } + if (rectMaskEnabledA) { + const rectMaskRectA = preRendererAny._rectMaskRect; + const rectMaskRectB = curRendererAny._rectMaskRect; + const rectMaskSoftnessA = preRendererAny._rectMaskSoftness; + const rectMaskSoftnessB = curRendererAny._rectMaskSoftness; + if ( + !rectMaskRectA || + !rectMaskRectB || + !rectMaskSoftnessA || + !rectMaskSoftnessB || + rectMaskRectA.x !== rectMaskRectB.x || + rectMaskRectA.y !== rectMaskRectB.y || + rectMaskRectA.z !== rectMaskRectB.z || + rectMaskRectA.w !== rectMaskRectB.w || + rectMaskSoftnessA.x !== rectMaskSoftnessB.x || + rectMaskSoftnessA.y !== rectMaskSoftnessB.y || + rectMaskSoftnessA.z !== rectMaskSoftnessB.z || + rectMaskSoftnessA.w !== rectMaskSoftnessB.w || + preRendererAny._rectMaskHardClip !== curRendererAny._rectMaskHardClip + ) { + return false; + } + } + // Order: cheap reference checks → mask state → tag lookup (rare opt-out) return ( preElement.subChunk.chunk === curElement.subChunk.chunk && @@ -24,6 +54,30 @@ export class VertexMergeBatcher { ); } + /** + * Text-specific batch check: extends sprite check with outline parity. + * Different outlineWidth or outlineColor must split into separate draw calls, + * because outline uniforms are shared per draw call. + */ + static canBatchText(preElement: RenderElement, curElement: RenderElement): boolean { + if (!VertexMergeBatcher.canBatchSprite(preElement, curElement)) { + return false; + } + const preRendererAny = preElement.component as any; + const curRendererAny = curElement.component as any; + if (preRendererAny._outlineWidth !== curRendererAny._outlineWidth) { + return false; + } + if (preRendererAny._outlineWidth > 0) { + const a = preRendererAny._outlineColor; + const b = curRendererAny._outlineColor; + if (a.r !== b.r || a.g !== b.g || a.b !== b.b || a.a !== b.a) { + return false; + } + } + return true; + } + static canBatchSpriteMask(preElement: RenderElement, curElement: RenderElement): boolean { if (preElement.subChunk.chunk !== curElement.subChunk.chunk) { return false; diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts index 5af1e71750..64a84953cd 100644 --- a/packages/core/src/Renderer.ts +++ b/packages/core/src/Renderer.ts @@ -7,7 +7,7 @@ import { Entity } from "./Entity"; import { RenderContext } from "./RenderPipeline/RenderContext"; import { RenderElement } from "./RenderPipeline/RenderElement"; import { Transform, TransformModifyFlags } from "./Transform"; -import { assignmentClone, deepClone, ignoreClone } from "./clone/CloneManager"; +import { ignoreClone } from "./clone/CloneManager"; import { SpriteMaskLayer } from "./enums/SpriteMaskLayer"; import { Material } from "./material"; import { ShaderMacro, ShaderProperty } from "./shader"; @@ -47,10 +47,7 @@ export class Renderer extends Component { _globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection(); @ignoreClone _renderFrameCount: number; - /** @internal */ - @assignmentClone _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; - @assignmentClone _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; @ignoreClone @@ -65,7 +62,6 @@ export class Renderer extends Component { protected _bounds: BoundingBox = new BoundingBox(); protected _transformEntity: Entity; - @deepClone private _shaderData: ShaderData = new ShaderData(ShaderDataGroup.Renderer); @ignoreClone private _mvMatrix: Matrix = new Matrix(); @@ -75,9 +71,7 @@ export class Renderer extends Component { private _normalMatrix: Matrix = new Matrix(); @ignoreClone private _materialsInstanced: boolean[] = []; - @assignmentClone private _priority: number = 0; - @assignmentClone private _receiveShadows: boolean = true; /** diff --git a/packages/core/src/SceneManager.ts b/packages/core/src/SceneManager.ts index 92de60d66b..ab27c57061 100644 --- a/packages/core/src/SceneManager.ts +++ b/packages/core/src/SceneManager.ts @@ -94,9 +94,12 @@ export class SceneManager { const scenePromise = this.engine.resourceManager.load({ url, type: AssetType.Scene }); scenePromise.then((scene: Scene) => { if (destroyOldScene) { - const scenes = this._scenes.getArray(); + // Use loop array because `destroy` removes the scene from `_scenes` during iteration, + // and skip `scene` itself because concurrent loads of the same url resolve to the same instance + const scenes = this._scenes.getLoopArray(); for (let i = 0, n = scenes.length; i < n; i++) { - scenes[i].destroy(); + const oldScene = scenes[i]; + oldScene !== scene && oldScene.destroy(); } } this.addScene(scene); diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index 997901573d..5d96703d53 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -229,6 +229,9 @@ export class Script extends Component { } this._entity._addScript(this); + if (this._hasCollisionEventCallbacks()) { + this.scene.physics._markCollisionEventConsumersDirty(); + } } /** @@ -252,6 +255,18 @@ export class Script extends Component { } this._entity._removeScript(this); + if (this._hasCollisionEventCallbacks()) { + this.scene.physics._markCollisionEventConsumersDirty(); + } + } + + private _hasCollisionEventCallbacks(): boolean { + const { prototype } = Script; + return ( + this.onCollisionEnter !== prototype.onCollisionEnter || + this.onCollisionExit !== prototype.onCollisionExit || + this.onCollisionStay !== prototype.onCollisionStay + ); } /** diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index e337e03d9d..6d34f69eb5 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,14 +1,15 @@ +import { DataObject } from "./base/DataObject"; +import { ignoreClone } from "./clone/CloneManager"; import { Component } from "./Component"; import { Entity } from "./Entity"; -import { CloneUtils } from "./clone/CloneUtils"; -import { ignoreClone } from "./clone/CloneManager"; import { SafeLoopArray } from "./utils/SafeLoopArray"; /** * Signal is a typed event mechanism for Galacean Engine. * @typeParam T - Tuple type of the signal arguments */ -export class Signal { +export class Signal extends DataObject { + // Rebuilt by `_cloneTo`; must survive even a propagated @deepClone. @ignoreClone private _listeners: SafeLoopArray> = new SafeLoopArray>(); @@ -126,35 +127,30 @@ export class Signal { /** * @internal - * Clone listeners to target signal, remapping entity/component references. */ - _cloneTo(target: Signal, srcRoot: Entity, targetRoot: Entity): void { + _cloneTo(target: Signal, cloneMap: Map): void { const listeners = this._listeners.getLoopArray(); for (let i = 0, n = listeners.length; i < n; i++) { const listener = listeners[i]; if (listener.destroyed || !listener.methodName) continue; - const clonedTarget = CloneUtils.remapComponent(srcRoot, targetRoot, listener.target); - if (clonedTarget) { - const clonedArgs = this._cloneArguments(listener.arguments, srcRoot, targetRoot); - if (listener.once) { - target.once(clonedTarget, listener.methodName, ...clonedArgs); - } else { - target.on(clonedTarget, listener.methodName, ...clonedArgs); - } + const clonedTarget = (cloneMap.get(listener.target) ?? listener.target); + const clonedArgs = this._cloneArguments(listener.arguments, cloneMap); + if (listener.once) { + target.once(clonedTarget, listener.methodName, ...clonedArgs); + } else { + target.on(clonedTarget, listener.methodName, ...clonedArgs); } } } - private _cloneArguments(args: any[], srcRoot: Entity, targetRoot: Entity): any[] { + private _cloneArguments(args: any[], cloneMap: Map): any[] { if (!args || args.length === 0) return []; const len = args.length; const clonedArgs = new Array(len); for (let i = 0; i < len; i++) { const arg = args[i]; - if (arg instanceof Entity) { - clonedArgs[i] = CloneUtils.remapEntity(srcRoot, targetRoot, arg); - } else if (arg instanceof Component) { - clonedArgs[i] = CloneUtils.remapComponent(srcRoot, targetRoot, arg); + if (arg instanceof Entity || arg instanceof Component) { + clonedArgs[i] = cloneMap.get(arg) ?? arg; } else { clonedArgs[i] = arg; } diff --git a/packages/core/src/Transform.ts b/packages/core/src/Transform.ts index 5f1235caf0..d409cb69c9 100644 --- a/packages/core/src/Transform.ts +++ b/packages/core/src/Transform.ts @@ -2,7 +2,7 @@ import { MathUtil, Matrix, Matrix3x3, Quaternion, Vector3 } from "@galacean/engi import { BoolUpdateFlag } from "./BoolUpdateFlag"; import { Component } from "./Component"; import { Entity } from "./Entity"; -import { assignmentClone, ignoreClone } from "./clone/CloneManager"; +import { ignoreClone } from "./clone/CloneManager"; /** * Used to implement transformation related functions. @@ -26,7 +26,6 @@ export class Transform extends Component { private _rotationQuaternion: Quaternion = new Quaternion(); @ignoreClone private _scale: Vector3 = new Vector3(1, 1, 1); - @assignmentClone private _localUniformScaling: boolean = true; @ignoreClone private _worldPosition: Vector3 = new Vector3(); @@ -48,6 +47,8 @@ export class Transform extends Component { private _worldRight: Vector3 = null; @ignoreClone private _worldUp: Vector3 = null; + @ignoreClone + private _frontFaceInvert: boolean = false; @ignoreClone protected _isParentDirty: boolean = true; @@ -571,11 +572,16 @@ export class Transform extends Component { * @internal */ _isFrontFaceInvert(): boolean { - const scale = this.lossyWorldScale; - let isInvert = scale.x < 0; - scale.y < 0 && (isInvert = !isInvert); - scale.z < 0 && (isInvert = !isInvert); - return isInvert; + if (this._isContainDirtyFlag(TransformModifyFlags.WorldFrontFaceInvert)) { + const s = this._scale; + let invert = s.x < 0; + s.y < 0 && (invert = !invert); + s.z < 0 && (invert = !invert); + const parent = this._getParentTransform(); + this._frontFaceInvert = parent ? invert !== parent._isFrontFaceInvert() : invert; + this._setDirtyFlagFalse(TransformModifyFlags.WorldFrontFaceInvert); + } + return this._frontFaceInvert; } /** @@ -910,24 +916,27 @@ export enum TransformModifyFlags { */ IsWorldUniformScaling = 0x100, + // Note: 0x200 (UITransformModifyFlags.Size) and 0x400 (Pivot) are reserved by UITransform — do not reuse. + WorldFrontFaceInvert = 0x800, + /** WorldMatrix | WorldPosition */ WmWp = 0x84, /** WorldMatrix | WorldEuler | WorldQuat */ WmWeWq = 0x98, - /** WorldMatrix | WorldEuler | WorldQuat | WorldScale*/ - WmWeWqWs = 0xb8, + /** WorldMatrix | WorldEuler | WorldQuat | WorldScale | WorldFrontFaceInvert */ + WmWeWqWs = 0x8b8, /** WorldMatrix | WorldPosition | WorldEuler | WorldQuat */ WmWpWeWq = 0x9c, - /** WorldMatrix | WorldScale */ - WmWs = 0xa0, - /** WorldMatrix | WorldScale | WorldUniformScaling */ - WmWsWus = 0x1a0, - /** WorldMatrix | WorldPosition | WorldScale */ - WmWpWs = 0xa4, - /** WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale */ - WmWpWeWqWs = 0xbc, - /** WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale | WorldUniformScaling */ - WmWpWeWqWsWus = 0x1bc, - /** LocalQuat | LocalMatrix | WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale | WorldUniformScaling */ - LqLmWmWpWeWqWsWus = 0x1fe + /** WorldMatrix | WorldScale | WorldFrontFaceInvert */ + WmWs = 0x8a0, + /** WorldMatrix | WorldScale | WorldUniformScaling | WorldFrontFaceInvert */ + WmWsWus = 0x9a0, + /** WorldMatrix | WorldPosition | WorldScale | WorldFrontFaceInvert */ + WmWpWs = 0x8a4, + /** WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale | WorldFrontFaceInvert */ + WmWpWeWqWs = 0x8bc, + /** WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale | WorldUniformScaling | WorldFrontFaceInvert */ + WmWpWeWqWsWus = 0x9bc, + /** LocalQuat | LocalMatrix | WorldMatrix | WorldPosition | WorldEuler | WorldQuat | WorldScale | WorldUniformScaling | WorldFrontFaceInvert */ + LqLmWmWpWeWqWsWus = 0x9fe } diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts index d926b47b8c..207a9cf1a6 100644 --- a/packages/core/src/UpdateFlag.ts +++ b/packages/core/src/UpdateFlag.ts @@ -13,7 +13,7 @@ export abstract class UpdateFlag { * @param bit - Bit * @param param - Parameter */ - abstract dispatch(bit?: number, param?: Object): void; + abstract dispatch(bit?: number, param?: unknown): void; /** * Clear. diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts index 0e1e98ec90..f93c10700a 100644 --- a/packages/core/src/UpdateFlagManager.ts +++ b/packages/core/src/UpdateFlagManager.ts @@ -10,7 +10,7 @@ export class UpdateFlagManager { _updateFlags: UpdateFlag[] = []; - private _listeners: ((type?: number, param?: Object) => void)[] = []; + private _listeners: ((type?: number, param?: unknown) => void)[] = []; /** * Create a UpdateFlag. @@ -46,7 +46,7 @@ export class UpdateFlagManager { * Add a listener. * @param listener - The listener */ - addListener(listener: (type?: number, param?: Object) => void): void { + addListener(listener: (type?: number, param?: unknown) => void): void { this._listeners.push(listener); } @@ -54,7 +54,7 @@ export class UpdateFlagManager { * Remove a listener. * @param listener - The listener */ - removeListener(listener: (type?: number, param?: Object) => void): void { + removeListener(listener: (type?: number, param?: unknown) => void): void { Utils.removeFromArray(this._listeners, listener); } @@ -63,7 +63,7 @@ export class UpdateFlagManager { * @param type - Event type, usually in the form of enumeration * @param param - Event param */ - dispatch(type?: number, param?: Object): void { + dispatch(type?: number, param?: unknown): void { this.version++; const updateFlags = this._updateFlags; diff --git a/packages/core/src/VirtualCamera.ts b/packages/core/src/VirtualCamera.ts index 4c8598888e..2fc88b3c8f 100644 --- a/packages/core/src/VirtualCamera.ts +++ b/packages/core/src/VirtualCamera.ts @@ -1,10 +1,11 @@ +import { DataObject } from "./base/DataObject"; import { Matrix, Vector3 } from "@galacean/engine-math"; import { ignoreClone } from "./clone/CloneManager"; /** * @internal */ -export class VirtualCamera { +export class VirtualCamera extends DataObject { isOrthographic: boolean = false; nearClipPlane: number = 0.1; farClipPlane: number = 100; diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts index ef51647577..9238ce782c 100644 --- a/packages/core/src/animation/AnimationClip.ts +++ b/packages/core/src/animation/AnimationClip.ts @@ -55,7 +55,7 @@ export class AnimationClip extends EngineObject { * @param time - The time when the event be triggered * @param parameter - The parameter that is stored in the event and will be sent to the function */ - addEvent(functionName: string, time: number, parameter: Object): void; + addEvent(functionName: string, time: number, parameter: object): void; /** * Adds an animation event to the clip. @@ -63,7 +63,7 @@ export class AnimationClip extends EngineObject { */ addEvent(event: AnimationEvent): void; - addEvent(param: AnimationEvent | string, time?: number, parameter?: Object): void { + addEvent(param: AnimationEvent | string, time?: number, parameter?: object): void { let newEvent: AnimationEvent; if (typeof param === "string") { const event = new AnimationEvent(); diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index f58589955c..451384da8d 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -5,7 +5,7 @@ import { Entity } from "../Entity"; import { Renderer } from "../Renderer"; import { Script } from "../Script"; import { Logger } from "../base/Logger"; -import { assignmentClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { AnimatorController } from "./AnimatorController"; import { AnimatorControllerLayer } from "./AnimatorControllerLayer"; import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; @@ -37,10 +37,8 @@ export class Animator extends Component { /** Culling mode of this Animator. */ cullingMode: AnimatorCullingMode = AnimatorCullingMode.None; /** The playback speed of the Animator, 1.0 is normal playback speed. */ - @assignmentClone speed = 1.0; /** Whether the Animator sends AnimationEvent callbacks. */ - @assignmentClone fireEvents = true; /** @internal */ @@ -48,9 +46,7 @@ export class Animator extends Component { /** @internal */ _onUpdateIndex = -1; - @assignmentClone protected _animatorController: AnimatorController; - @ignoreClone protected _controllerUpdateFlag: BoolUpdateFlag; @ignoreClone protected _updateMark = 0; @@ -353,7 +349,6 @@ export class Animator extends Component { _cloneTo(target: Animator): void { const animatorController = target._animatorController; if (animatorController) { - target._addResourceReferCount(animatorController, 1); target._controllerUpdateFlag = animatorController._registerChangeFlag(); } } @@ -442,7 +437,7 @@ export class Animator extends Component { layerIndex: number ): void { const { entity, _curveOwnerPool: curveOwnerPool } = this; - let { mask } = this._animatorController.layers[layerIndex]; + const { mask } = this._animatorController.layers[layerIndex]; const { curveLayerOwner } = animatorStateData; const { _curveBindings: curves } = animatorState.clip; diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index 1f8e91a01c..cda119f6d0 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -85,7 +85,7 @@ export class AnimatorController extends ReferResource { */ clearParameters(): void { this._parameters.length = 0; - for (let name in this._parametersMap) { + for (const name in this._parametersMap) { delete this._parametersMap[name]; } } @@ -140,7 +140,7 @@ export class AnimatorController extends ReferResource { } layers.length = 0; - for (let name in this._layersMap) { + for (const name in this._layersMap) { delete this._layersMap[name]; } this._updateFlagManager.dispatch(); diff --git a/packages/core/src/asset/AssetPromise.ts b/packages/core/src/asset/AssetPromise.ts index 99f00e86f2..1720a64c9f 100644 --- a/packages/core/src/asset/AssetPromise.ts +++ b/packages/core/src/asset/AssetPromise.ts @@ -164,17 +164,17 @@ export class AssetPromise implements PromiseLike { * @returns AssetPromise */ onProgress( - onTaskComplete: (loaded: number, total: number) => void, + onTaskComplete?: (loaded: number, total: number) => void, onTaskDetail?: (identifier: string, loaded: number, total: number) => void ): AssetPromise { const completeProgress = this._taskCompleteProgress; const detailProgress = this._taskDetailProgress; - if (completeProgress) { + if (completeProgress && onTaskComplete) { onTaskComplete(completeProgress.loaded, completeProgress.total); } - if (detailProgress) { - for (let url in detailProgress) { + if (detailProgress && onTaskDetail) { + for (const url in detailProgress) { const { loaded, total } = detailProgress[url]; onTaskDetail(url, loaded, total); } diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index 54b8309589..0c61fb9cb4 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -44,7 +44,7 @@ export class ResourceManager { /** Asset path pool, key is the `instanceID` of resource, value is asset path. */ private _assetPool: Record = Object.create(null); /** Asset url pool, key is the asset path and the value is the asset. */ - private _assetUrlPool: Record = Object.create(null); + private _assetUrlPool: Record = Object.create(null); /** Referable resource pool, key is the `instanceID` of resource. */ private _referResourcePool: Record = Object.create(null); @@ -88,14 +88,14 @@ export class ResourceManager { */ load(path: string): AssetPromise; - load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { + load(assetInfo: string | LoadItem | (LoadItem | string)[]): AssetPromise { // single item if (!Array.isArray(assetInfo)) { return this._loadSingleItem(assetInfo); } // multi items const promises = assetInfo.map((item) => this._loadSingleItem(item)); - return AssetPromise.all(promises); + return AssetPromise.all(promises) as AssetPromise; } /** @@ -104,7 +104,7 @@ export class ResourceManager { * @returns Resource object */ getFromCache(url: string): T { - return (this._assetUrlPool[url] as T) ?? null; + return (this._assetUrlPool[this._getRemoteUrl(url)] as T) ?? null; } /** diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index b149e5609e..c0f5efd980 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -1,4 +1,4 @@ -import { assignmentClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { Component } from "../Component"; import { Entity } from "../Entity"; import { AudioClip } from "./AudioClip"; @@ -16,7 +16,6 @@ export class AudioSource extends Component { @ignoreClone private _pendingPlay = false; - @assignmentClone private _clip: AudioClip; @ignoreClone private _gainNode: GainNode; @@ -28,13 +27,9 @@ export class AudioSource extends Component { @ignoreClone private _playTime = -1; - @assignmentClone private _volume = 1; - @assignmentClone private _lastVolume = 1; - @assignmentClone private _playbackRate = 1; - @assignmentClone private _loop = false; /** @@ -220,14 +215,6 @@ export class AudioSource extends Component { } } - /** - * @internal - */ - _cloneTo(target: AudioSource): void { - target._clip?._addReferCount(1); - // _volume is field-cloned; its gain node is applied lazily on first play - } - /** * @internal */ diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts new file mode 100644 index 0000000000..01d2343692 --- /dev/null +++ b/packages/core/src/base/DataObject.ts @@ -0,0 +1,6 @@ +/** + * Base class of data objects: wherever an instance is held — a component field, an array, a map — + * cloning produces an independent deep copy instead of a shared reference. A subclass must be + * constructible without arguments; a preset-less copy is created bare, then populated. + */ +export abstract class DataObject {} diff --git a/packages/core/src/base/index.ts b/packages/core/src/base/index.ts index 74cd4ac148..2870931b65 100644 --- a/packages/core/src/base/index.ts +++ b/packages/core/src/base/index.ts @@ -1,6 +1,7 @@ export { EventDispatcher } from "./EventDispatcher"; export { Logger } from "./Logger"; export { Time } from "./Time"; +export { DataObject } from "./DataObject"; export { EngineObject } from "./EngineObject"; export * from "./Constant"; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index be46462982..0165be9914 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,198 +1,50 @@ -import { Entity } from "../Entity"; -import { TypedArray } from "../base/Constant"; -import { ICustomClone } from "./ComponentCloner"; import { CloneMode } from "./enums/CloneMode"; /** - * Property decorator, ignore the property when cloning. + * Property decorator — deep clone this field's whole subtree, overriding the value type's default + * clone mode (field-level decorators have the highest priority). The deep intent carries into the + * field's members; engine-bound members keep their defaults (assets share, entity refs remap). + * A decorator is an explicit intent: if the decorated value itself can't be deep cloned (an + * entity reference, an asset, or a function), cloning throws rather than silently falling back. */ -export function ignoreClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Ignore); +export function deepClone(target: object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); } /** - * Property decorator, assign value to the property when cloning. - * - * @remarks - * If it's a primitive type, the value will be copied. - * If it's a class type, the reference will be copied. + * Property decorator — assign (share the reference) this field, overriding the value type's default clone mode. */ -export function assignmentClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Assignment); +export function assignmentClone(target: object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Assignment); } /** - * Property decorator, shallow clone the property when cloning. - * After cloning, it will keep its own reference independent, and use the method of assignment to clone all its internal properties. - * if the internal property is a primitive type, the value will be copied, if the internal property is a reference type, its reference address will be copied.。 - * - * @remarks - * Applicable to Object, Array, TypedArray and Class types. + * Property decorator — ignore this field when cloning; keep the clone's own constructor-built value. */ -export function shallowClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Shallow); -} - -/** - * Property decorator, deep clone the property when cloning. - * After cloning, it will maintain its own reference independence, and all its internal deep properties will remain completely independent. - * - * @remarks - * Applicable to Object, Array, TypedArray and Class types. - * If Class is encountered during the deep cloning process, the custom cloning function of the object will be called first. - * Custom cloning requires the object to implement the IClone interface. - */ -export function deepClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Deep); +export function ignoreClone(target: object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } /** * @internal - * Clone manager. + * Field-level clone mode registry. Must import no engine class, directly or transitively: every + * class carrying a clone decorator imports this module while still being defined, and pulling a + * class in here would reorder module evaluation and break `extends` chains. Cloning itself lives + * in `CloneUtil`. */ export class CloneManager { - /** @internal */ - static _subCloneModeMap = new Map(); - /** @internal */ - static _cloneModeMap = new Map(); - - private static _objectType = Object.getPrototypeOf(Object); - /** - * Register clone mode. - * @param target - Clone target - * @param propertyKey - Clone property name - * @param mode - Clone mode + * @internal */ - static registerCloneMode(target: Object, propertyKey: string, mode: CloneMode): void { - let targetMap = CloneManager._subCloneModeMap.get(target.constructor); - if (!targetMap) { - targetMap = Object.create(null); - CloneManager._subCloneModeMap.set(target.constructor, targetMap); - } - targetMap[propertyKey] = mode; - } - - /** - * Get the clone mode according to the prototype chain. - */ - static getCloneMode(type: Function): Object { - let cloneModes = CloneManager._cloneModeMap.get(type); - if (!cloneModes) { - cloneModes = Object.create(null); - CloneManager._cloneModeMap.set(type, cloneModes); - const objectType = CloneManager._objectType; - const cloneModeMap = CloneManager._subCloneModeMap; - while (type !== objectType) { - const subCloneModes = cloneModeMap.get(type); - if (subCloneModes) { - Object.assign(cloneModes, subCloneModes); - } - type = Object.getPrototypeOf(type); - } - } - return cloneModes; - } - - static cloneProperty( - source: Object, - target: Object, - k: string | number, - cloneMode: CloneMode, - srcRoot: Entity, - targetRoot: Entity, - deepInstanceMap: Map - ): void { - const sourceProperty = source[k]; - - // Remappable references (Entity/Component) are always remapped, regardless of clone decorator - if (sourceProperty instanceof Object && (sourceProperty)._remap) { - target[k] = (sourceProperty)._remap(srcRoot, targetRoot); - return; - } - - if (cloneMode === CloneMode.Ignore) return; - - // Primitives, undecorated, or @assignmentClone: direct assign - if (!(sourceProperty instanceof Object) || cloneMode === undefined || cloneMode === CloneMode.Assignment) { - target[k] = sourceProperty; - return; - } - - // @shallowClone / @deepClone: deep copy complex objects - const type = sourceProperty.constructor; - switch (type) { - case Uint8Array: - case Uint16Array: - case Uint32Array: - case Int8Array: - case Int16Array: - case Int32Array: - case Float32Array: - case Float64Array: - let targetPropertyT = target[k]; - if (targetPropertyT == null || targetPropertyT.length !== (sourceProperty).length) { - target[k] = (sourceProperty).slice(); - } else { - targetPropertyT.set(sourceProperty); - } - break; - case Array: - let targetPropertyA = >target[k]; - const length = (>sourceProperty).length; - if (targetPropertyA == null) { - target[k] = targetPropertyA = new Array(length); - } else { - targetPropertyA.length = length; - } - for (let i = 0; i < length; i++) { - CloneManager.cloneProperty( - >sourceProperty, - targetPropertyA, - i, - cloneMode, - srcRoot, - targetRoot, - deepInstanceMap - ); - } - break; - default: - let targetProperty = target[k]; - // If the target property is undefined, create new instance and keep reference sharing like the source - if (!targetProperty) { - targetProperty = deepInstanceMap.get(sourceProperty); - if (!targetProperty) { - targetProperty = new sourceProperty.constructor(); - deepInstanceMap.set(sourceProperty, targetProperty); - } - target[k] = targetProperty; - } - - if ((sourceProperty).copyFrom) { - (targetProperty).copyFrom(sourceProperty); - } else { - const cloneModes = CloneManager.getCloneMode(sourceProperty.constructor); - for (let k in sourceProperty) { - CloneManager.cloneProperty( - sourceProperty, - targetProperty, - k, - cloneModes[k], - srcRoot, - targetRoot, - deepInstanceMap - ); - } - (sourceProperty)._cloneTo?.(targetProperty, srcRoot, targetRoot); - } - break; - } - } - - static deepCloneObject(source: Object, target: Object, deepInstanceMap: Map): void { - for (let k in source) { - CloneManager.cloneProperty(source, target, k, CloneMode.Deep, null, null, deepInstanceMap); + static _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void { + // Each class gets its own `_fieldModes`, prototypally chained to its parent's, so property + // lookup resolves inheritance: a subclass re-decorating a field shadows the ancestor's. + if (!Object.prototype.hasOwnProperty.call(target, "_fieldModes")) { + Object.defineProperty(target, "_fieldModes", { + value: Object.create(target._fieldModes ?? null), + configurable: true + }); } + target._fieldModes[propertyKey] = mode; } } diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts new file mode 100644 index 0000000000..cd06d6b451 --- /dev/null +++ b/packages/core/src/clone/CloneUtil.ts @@ -0,0 +1,277 @@ +import { IReferable } from "../asset/IReferable"; +import { ReferResource } from "../asset/ReferResource"; +import { TypedArray } from "../base/Constant"; +import { DataObject } from "../base/DataObject"; +import { Logger } from "../base/Logger"; +import { Component } from "../Component"; +import { Entity } from "../Entity"; +import { UpdateFlag } from "../UpdateFlag"; +import { UpdateFlagManager } from "../UpdateFlagManager"; +import { DisorderedArray } from "../utils/DisorderedArray"; +import { SafeLoopArray } from "../utils/SafeLoopArray"; +import { ICustomClone } from "./ComponentCloner"; +import { CloneMode } from "./enums/CloneMode"; + +/** + * @internal + * Split from `CloneManager`, which must stay free of engine imports. + */ +export class CloneUtil { + /** + * @internal + */ + static _deepCloneObject(source: any, target: object, cloneMap: Map, forceDeepClone = false): void { + const fieldModes = source._fieldModes; + const keys = Object.keys(source); + for (let i = 0, n = keys.length; i < n; i++) { + const k = keys[i]; + const fieldMode = fieldModes?.[k]; + if (fieldMode === CloneMode.Ignore) continue; + target[k] = CloneUtil._cloneValue(source[k], target[k], cloneMap, fieldMode, forceDeepClone); + } + } + + /** + * @internal + */ + static _cloneValue( + source: any, + preset: any, + cloneMap: Map, + fieldMode?: CloneMode, + forceDeepClone = false + ): any { + if (fieldMode === CloneMode.Assignment) return source; + if (fieldMode === CloneMode.Deep) { + CloneUtil._assertDeepCloneable(source); + forceDeepClone = true; + } + return CloneUtil._cloneByDefault(source, preset, cloneMap, forceDeepClone); + } + + /** + * @internal + */ + static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { + if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source; + if (source === null || typeof source !== "object") return source; + if (source instanceof Entity || source instanceof Component) return cloneMap.get(source) ?? source; + if (source instanceof ReferResource) return source; + if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) return preset; + if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); + if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap, forceDeepClone); + if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap, forceDeepClone); + if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap, forceDeepClone); + if (source instanceof DisorderedArray || source instanceof SafeLoopArray) { + if (!forceDeepClone) return preset; + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap, true); + return dst; + } + + const ctor = (source).constructor; + if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); + (dst).copyFrom(source); + (source)._cloneTo?.(dst, cloneMap); + return dst; + } + + if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap, forceDeepClone); + (source)._cloneTo?.(dst, cloneMap); + return dst; + } + return source; + } + + /** + * @internal + */ + static _assertDeepCloneable(source: any): void { + if (typeof source === "function") { + throw new Error( + `CloneUtil: @deepClone cannot deep clone a function — code is not a cloneable graph. ` + + `Remove @deepClone to keep the clone's own binding.` + ); + } + if (source instanceof Entity || source instanceof Component) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — Entity / Component ` + + `references are engine-bound. Remove @deepClone to remap the reference by default.` + ); + } + if (source instanceof ReferResource) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — assets are engine-bound ` + + `and shared by reference. Remove @deepClone to share it, or copy it via the asset's own clone() API.` + ); + } + if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — a flag and its manager hold ` + + `each other, and a field copy resolves neither side, leaving the pair inconsistent. Remove ` + + `@deepClone to keep the clone's own.` + ); + } + } + + /** + * @internal + */ + static _createCloneTarget(source: any, preset: any, cloneMap: Map): any { + const ctor = (source).constructor; + const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; + let dst: any; + if (reusable) { + dst = reusable; + } else { + if (ctor) { + try { + dst = new ctor(); + } catch (e) { + throw new Error( + `CloneUtil: failed to bare-construct "${ctor.name}" — a type cloned deep must support ` + + `argument-less construction (the gate creates preset-less instances bare, then populates fields). ` + + `Cause: ${e}` + ); + } + } else { + dst = Object.create(null); + } + } + cloneMap.set(source, dst); + return dst; + } + + /** + * @internal + */ + static _transferSlotOwnership(cloned: any, source: any, preset: any): void { + if (cloned === preset) return; + if (preset instanceof ReferResource) { + const presetRefCount = (<{ refCount?: number }>preset).refCount; + presetRefCount !== undefined && + presetRefCount <= 0 && + Logger.error( + `CloneUtil: the clone's preset ${preset.constructor.name} holds no owned reference; ` + + `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).` + ); + (preset)._addReferCount(-1); + } + if (cloned === source && cloned instanceof ReferResource) { + (cloned)._addReferCount(1); + } + } + + /** + * @internal + */ + static _deepCloneArrayBuffer(source: ArrayBufferView, preset: any, cloneMap: Map): ArrayBufferView { + const existing = cloneMap.get(source); + if (existing) return existing; + + let dst: ArrayBufferView; + if (source instanceof DataView) { + if (preset instanceof DataView && preset !== source && preset.byteLength === source.byteLength) { + new Uint8Array(preset.buffer, preset.byteOffset, preset.byteLength).set( + new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + ); + dst = preset; + } else { + dst = new DataView(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)); + } + } else { + const src = source; + if ( + preset && + preset !== source && + preset.constructor === src.constructor && + (preset).length === src.length + ) { + (preset).set(src); + dst = preset; + } else { + dst = src.slice(); + } + } + cloneMap.set(source, dst); + return dst; + } + + /** + * @internal + */ + static _deepCloneArray(source: any[], preset: any, cloneMap: Map, forceDeepClone = false): any[] { + const existing = cloneMap.get(source); + if (existing) return existing; + + const dst = + preset !== source && + Array.isArray(preset) && + preset.constructor === source.constructor && + preset.length === source.length + ? preset + : new Array(source.length); + cloneMap.set(source, dst); + for (let i = 0, n = source.length; i < n; i++) { + dst[i] = CloneUtil._cloneByDefault(source[i], undefined, cloneMap, forceDeepClone); + } + return dst; + } + + /** + * @internal + */ + static _deepCloneMap( + source: Map, + preset: any, + cloneMap: Map, + forceDeepClone = false + ): Map { + const existing = cloneMap.get(source); + if (existing) return >existing; + + let dst: Map; + if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) { + preset.clear(); + dst = preset; + } else { + dst = new Map(); + } + cloneMap.set(source, dst); + for (const entry of source) { + dst.set( + CloneUtil._cloneByDefault(entry[0], undefined, cloneMap, forceDeepClone), + CloneUtil._cloneByDefault(entry[1], undefined, cloneMap, forceDeepClone) + ); + } + return dst; + } + + /** + * @internal + */ + static _deepCloneSet(source: Set, preset: any, cloneMap: Map, forceDeepClone = false): Set { + const existing = cloneMap.get(source); + if (existing) return >existing; + + let dst: Set; + if (preset instanceof Set && preset !== source && preset.constructor === source.constructor) { + preset.clear(); + dst = preset; + } else { + dst = new Set(); + } + cloneMap.set(source, dst); + for (const v of source) dst.add(CloneUtil._cloneByDefault(v, undefined, cloneMap, forceDeepClone)); + return dst; + } +} diff --git a/packages/core/src/clone/CloneUtils.ts b/packages/core/src/clone/CloneUtils.ts deleted file mode 100644 index bded5ed474..0000000000 --- a/packages/core/src/clone/CloneUtils.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Component } from "../Component"; -import { Entity } from "../Entity"; - -/** - * @internal - * Utility functions for remapping Entity/Component references during cloning. - */ -export class CloneUtils { - private static _tempRemapPath: number[] = []; - - static remapEntity(srcRoot: Entity, targetRoot: Entity, entity: Entity): Entity { - const path = CloneUtils._tempRemapPath; - if (!CloneUtils._getEntityHierarchyPath(srcRoot, entity, path)) return entity; - return CloneUtils._getEntityByHierarchyPath(targetRoot, path); - } - - static remapComponent(srcRoot: Entity, targetRoot: Entity, component: T): T { - const path = CloneUtils._tempRemapPath; - const srcEntity = component.entity; - if (!CloneUtils._getEntityHierarchyPath(srcRoot, srcEntity, path)) return component; - return CloneUtils._getEntityByHierarchyPath(targetRoot, path)._components[ - srcEntity._components.indexOf(component) - ] as T; - } - - private static _getEntityHierarchyPath(rootEntity: Entity, searchEntity: Entity, inversePath: number[]): boolean { - inversePath.length = 0; - while (searchEntity !== rootEntity) { - const parent = searchEntity.parent; - if (!parent) { - return false; - } - inversePath.push(searchEntity.siblingIndex); - searchEntity = parent; - } - return true; - } - - private static _getEntityByHierarchyPath(rootEntity: Entity, inversePath: number[]): Entity { - let entity = rootEntity; - for (let i = inversePath.length - 1; i >= 0; i--) { - entity = entity.children[inversePath[i]]; - } - return entity; - } -} diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index f4e1b11651..b871e4c799 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,42 +1,42 @@ import { Component } from "../Component"; -import { Entity } from "../Entity"; -import { CloneManager } from "./CloneManager"; +import { CloneUtil } from "./CloneUtil"; +import { CloneMode } from "./enums/CloneMode"; /** - * Custom clone interface. + * Clone protocol read by the clone system; every member is optional. */ export interface ICustomClone { /** * @internal + * Post-clone hook; `cloneMap` maps every source object in the cloned subtree to its clone. */ - _remap?(srcRoot: Entity, targetRoot: Entity): Object; - /** - * @internal - */ - _cloneTo?(target: ICustomClone, srcRoot?: Entity, targetRoot?: Entity): void; + _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal + * Value-type marker — the gate copies via this instead of walking fields. */ copyFrom?(source: ICustomClone): void; } export class ComponentCloner { /** - * Clone component. + * Clone component (opt-out: all fields cloned except @ignoreClone), then run its `_cloneTo` hook. * @param source - Clone source * @param target - Clone target + * @param cloneMap - Identity map of the cloned subtree (source object → clone) */ - static cloneComponent( - source: Component, - target: Component, - srcRoot: Entity, - targetRoot: Entity, - deepInstanceMap: Map - ): void { - const cloneModes = CloneManager.getCloneMode(source.constructor); - for (let k in source) { - CloneManager.cloneProperty(source, target, k, cloneModes[k], srcRoot, targetRoot, deepInstanceMap); + static cloneComponent(source: Component, target: Component, cloneMap: Map): void { + const fieldModes = (source)._fieldModes; + const keys = Object.keys(source); + for (let i = 0, n = keys.length; i < n; i++) { + const k = keys[i]; + const fieldMode = fieldModes?.[k]; + if (fieldMode === CloneMode.Ignore) continue; + const sourceValue = source[k]; + const preset = target[k]; + const cloned = (target[k] = CloneUtil._cloneValue(sourceValue, preset, cloneMap, fieldMode)); + CloneUtil._transferSlotOwnership(cloned, sourceValue, preset); } - ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot); + ((source as unknown))._cloneTo?.(target, cloneMap); } } diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 77158ce3ce..b4e41ad31a 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -1,13 +1,15 @@ /** - * Clone mode. + * How a field is cloned when a clone decorator overrides the built-in default for its type. */ export enum CloneMode { - /** Ignore clone. */ + /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ Ignore, - /** Assignment clone. */ + /** Share the reference; a counted resource shared at a component's top-level slot is kept alive by the clone. */ Assignment, - /** Shallow clone. */ - Shallow, - /** Deep clone. */ + /** + * Deep clone the whole subtree (fresh containers/instances, the intent carries into members); + * engine-bound members keep their defaults — assets stay shared, entity refs remap, runtime + * state keeps the clone's own. + */ Deep } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be483eccbd..5e83b9259b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,8 +69,7 @@ export * from "./trail/index"; export * from "./env-probe/index"; export * from "./shader/index"; export * from "./Layer"; -export * from "./clone/CloneManager"; -export { CloneUtils } from "./clone/CloneUtils"; +export { deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; diff --git a/packages/core/src/input/InputManager.ts b/packages/core/src/input/InputManager.ts index 617fc4c366..e82d056fb7 100644 --- a/packages/core/src/input/InputManager.ts +++ b/packages/core/src/input/InputManager.ts @@ -181,6 +181,13 @@ export class InputManager { this._initialized && this._pointerManager._firePointerScript(scenes); } + /** + * @internal + */ + _gc(): void { + this._initialized && this._pointerManager._gc(); + } + /** * @internal */ diff --git a/packages/core/src/input/pointer/Pointer.ts b/packages/core/src/input/pointer/Pointer.ts index a7412bc2ed..298db706a3 100644 --- a/packages/core/src/input/pointer/Pointer.ts +++ b/packages/core/src/input/pointer/Pointer.ts @@ -1,9 +1,7 @@ import { Vector2 } from "@galacean/engine-math"; -import { ClearableObjectPool } from "../../utils/ClearableObjectPool"; import { DisorderedArray } from "../../utils/DisorderedArray"; import { PointerButton } from "../enums/PointerButton"; import { PointerPhase } from "../enums/PointerPhase"; -import { PointerEventData } from "./PointerEventData"; import { PointerEventEmitter } from "./emitter/PointerEventEmitter"; /** @@ -23,6 +21,8 @@ export class Pointer { pressedButtons: PointerButton; /** The position of the pointer in screen space pixel coordinates. */ position: Vector2 = new Vector2(); + /** The position of the pointer when it was last pressed down (in screen space pixel coordinates). */ + pressedPosition: Vector2 = new Vector2(); /** The change of the pointer. */ deltaPosition: Vector2 = new Vector2(); /** @internal */ @@ -49,16 +49,6 @@ export class Pointer { this.id = id; } - /** - * @internal - */ - _addEmitters) => PointerEventEmitter>( - type: T, - pool: ClearableObjectPool - ) { - this._emitters.push(new type(pool)); - } - /** * @internal */ diff --git a/packages/core/src/input/pointer/PointerEventData.ts b/packages/core/src/input/pointer/PointerEventData.ts index 89f9ae09b6..33dc41b001 100644 --- a/packages/core/src/input/pointer/PointerEventData.ts +++ b/packages/core/src/input/pointer/PointerEventData.ts @@ -1,4 +1,5 @@ import { Vector3 } from "@galacean/engine-math"; +import { Entity } from "../../Entity"; import { IPoolElement } from "../../utils/ObjectPool"; import { Pointer } from "./Pointer"; @@ -10,11 +11,17 @@ export class PointerEventData implements IPoolElement { pointer: Pointer; /** The position of the event trigger (in world space). */ worldPosition: Vector3 = new Vector3(); + /** The hit-tested target entity (deepest entity on the bubble path). */ + target: Entity = null; + /** The entity currently handling the event (same as target on non-bubbling fire, varies on bubble). */ + currentTarget: Entity = null; /** * @internal */ dispose() { this.pointer = null; + this.target = null; + this.currentTarget = null; } } diff --git a/packages/core/src/input/pointer/PointerManager.ts b/packages/core/src/input/pointer/PointerManager.ts index fc6d8a2010..71ad2178cf 100644 --- a/packages/core/src/input/pointer/PointerManager.ts +++ b/packages/core/src/input/pointer/PointerManager.ts @@ -116,10 +116,7 @@ export class PointerManager implements IInput { pointer = pointerPool[j]; if (!pointer) { pointer = pointerPool[j] = new Pointer(j); - engine._physicsInitialized && pointer._addEmitters(PhysicsPointerEventEmitter, eventPool); - PointerManager._pointerEventEmitters.forEach((emitter) => { - pointer._addEmitters(emitter, eventPool); - }); + this._addEmitters(pointer); } pointer._uniqueID = pointerId; pointer._events.push(evt); @@ -193,6 +190,13 @@ export class PointerManager implements IInput { } } + /** + * @internal + */ + _gc(): void { + this._eventPool.garbageCollection(); + } + /** * @internal */ @@ -201,6 +205,16 @@ export class PointerManager implements IInput { this._pointerPool.length = 0; } + private _addEmitters(pointer: Pointer): void { + const { _eventPool: eventPool, _engine: engine } = this; + const emitters = pointer._emitters; + engine._physicsInitialized && emitters.push(new PhysicsPointerEventEmitter(eventPool)); + const emitterTypes = PointerManager._pointerEventEmitters; + for (let i = 0, n = emitterTypes.length; i < n; i++) { + emitters.push(new emitterTypes[i](eventPool)); + } + } + private _onPointerEvent(evt: PointerEvent) { this._nativeEvents.push(evt); } @@ -245,6 +259,10 @@ export class PointerManager implements IInput { pointer._downMap[button] = frameCount; pointer._frameEvents |= PointerEventType.Down; pointer.phase = PointerPhase.Down; + pointer.pressedPosition.set( + (event.clientX - left) * widthPixelRatio, + (event.clientY - top) * heightPixelRatio + ); break; } case "pointerup": { diff --git a/packages/core/src/input/pointer/emitter/PhysicsPointerEventEmitter.ts b/packages/core/src/input/pointer/emitter/PhysicsPointerEventEmitter.ts index 5778f924a9..0a4ef5493e 100644 --- a/packages/core/src/input/pointer/emitter/PhysicsPointerEventEmitter.ts +++ b/packages/core/src/input/pointer/emitter/PhysicsPointerEventEmitter.ts @@ -24,7 +24,7 @@ export class PhysicsPointerEventEmitter extends PointerEventEmitter { continue; } const cameras = scene._componentsManager._activeCameras; - let scenePhysics = scene.physics; + const scenePhysics = scene.physics; for (let j = cameras.length - 1; j >= 0; j--) { const camera = cameras.get(j); if (camera.renderTarget) continue; @@ -53,13 +53,13 @@ export class PhysicsPointerEventEmitter extends PointerEventEmitter { override processDrag(pointer: Pointer): void { const entity = this._draggedEntity; - entity && this._fireDrag(entity, this._createEventData(pointer)); + entity && this._fireDrag(entity, this._createEventData(pointer, entity)); } override processDown(pointer: Pointer): void { const entity = (this._pressedEntity = this._draggedEntity = this._enteredEntity); if (entity) { - const eventData = this._createEventData(pointer); + const eventData = this._createEventData(pointer, entity); this._fireDown(entity, eventData); this._fireBeginDrag(entity, eventData); } @@ -69,14 +69,14 @@ export class PhysicsPointerEventEmitter extends PointerEventEmitter { const { _enteredEntity: enteredEntity, _draggedEntity: draggedEntity } = this; if (enteredEntity) { const sameTarget = this._pressedEntity === enteredEntity; - const eventData = this._createEventData(pointer); + const eventData = this._createEventData(pointer, enteredEntity); this._fireUp(enteredEntity, eventData); sameTarget && this._fireClick(enteredEntity, eventData); this._fireDrop(enteredEntity, eventData); } this._pressedEntity = null; if (draggedEntity) { - this._fireEndDrag(draggedEntity, this._createEventData(pointer)); + this._fireEndDrag(draggedEntity, this._createEventData(pointer, draggedEntity)); this._draggedEntity = null; } } @@ -84,13 +84,13 @@ export class PhysicsPointerEventEmitter extends PointerEventEmitter { override processLeave(pointer: Pointer): void { const enteredEntity = this._enteredEntity; if (enteredEntity) { - this._fireExit(enteredEntity, this._createEventData(pointer)); + this._fireExit(enteredEntity, this._createEventData(pointer, enteredEntity)); this._enteredEntity = null; } const draggedEntity = this._draggedEntity; if (draggedEntity) { - this._fireEndDrag(draggedEntity, this._createEventData(pointer)); + this._fireEndDrag(draggedEntity, this._createEventData(pointer, draggedEntity)); this._draggedEntity = null; } this._pressedEntity = null; @@ -108,10 +108,10 @@ export class PhysicsPointerEventEmitter extends PointerEventEmitter { const enteredEntity = this._enteredEntity; if (entity !== enteredEntity) { if (enteredEntity) { - this._fireExit(enteredEntity, this._createEventData(pointer)); + this._fireExit(enteredEntity, this._createEventData(pointer, enteredEntity)); } if (entity) { - this._fireEnter(entity, this._createEventData(pointer)); + this._fireEnter(entity, this._createEventData(pointer, entity)); } this._enteredEntity = entity; } diff --git a/packages/core/src/input/pointer/emitter/PointerEventEmitter.ts b/packages/core/src/input/pointer/emitter/PointerEventEmitter.ts index 6f6c62cdfb..98188891e6 100644 --- a/packages/core/src/input/pointer/emitter/PointerEventEmitter.ts +++ b/packages/core/src/input/pointer/emitter/PointerEventEmitter.ts @@ -32,10 +32,12 @@ export abstract class PointerEventEmitter { protected abstract _init(): void; - protected _createEventData(pointer: Pointer): PointerEventData { + protected _createEventData(pointer: Pointer, target: Entity, currentTarget: Entity = target): PointerEventData { const data = this._pool.get(); data.pointer = pointer; data.worldPosition.copyFrom(this._hitResult.point); + data.target = target; + data.currentTarget = currentTarget; return data; } diff --git a/packages/core/src/lighting/Light.ts b/packages/core/src/lighting/Light.ts index 5406de6243..319a489d95 100644 --- a/packages/core/src/lighting/Light.ts +++ b/packages/core/src/lighting/Light.ts @@ -1,7 +1,7 @@ import { Color, MathUtil, Matrix } from "@galacean/engine-math"; import { Component } from "../Component"; import { Layer } from "../Layer"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { ShadowType } from "../shadow"; /** @@ -32,7 +32,6 @@ export abstract class Light extends Component { _lightIndex = -1; private _shadowStrength = 1.0; - @deepClone private _color = new Color(1, 1, 1, 1); @ignoreClone private _viewMat: Matrix; diff --git a/packages/core/src/mesh/ModelMesh.ts b/packages/core/src/mesh/ModelMesh.ts index 27b92ced49..8ee92c4825 100644 --- a/packages/core/src/mesh/ModelMesh.ts +++ b/packages/core/src/mesh/ModelMesh.ts @@ -925,7 +925,7 @@ export class ModelMesh extends Mesh { return null; } if (!buffer.readable) { - throw "Not allowed to access data while vertex buffer readable is false."; + throw new Error("Not allowed to access data while vertex buffer readable is false."); } const vertexCount = this.vertexCount; diff --git a/packages/core/src/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts index 18489805c5..673632acf4 100644 --- a/packages/core/src/mesh/Skin.ts +++ b/packages/core/src/mesh/Skin.ts @@ -2,27 +2,23 @@ import { Matrix } from "@galacean/engine-math"; import { Entity } from "../Entity"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { Utils } from "../Utils"; -import { EngineObject } from "../base/EngineObject"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { DataObject } from "../base/DataObject"; +import { ignoreClone } from "../clone/CloneManager"; import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer"; /** * Skin used for skinned mesh renderer. */ -export class Skin extends EngineObject { +export class Skin extends DataObject { /** Inverse bind matrices. */ - @deepClone inverseBindMatrices = new Array(); /** @internal */ - @deepClone _skinMatrices: Float32Array; /** @internal */ - @ignoreClone _updatedManager = new UpdateFlagManager(); private _rootBone: Entity; - @deepClone private _bones = new Array(); @ignoreClone private _updateMark = -1; @@ -65,7 +61,7 @@ export class Skin extends EngineObject { } constructor(public name: string) { - super(null); + super(); } /** diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 4d7f726184..7059cb20f3 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -4,7 +4,7 @@ import { RenderContext } from "../RenderPipeline/RenderContext"; import { RenderElement } from "../RenderPipeline/RenderElement"; import { RendererUpdateFlags } from "../Renderer"; import { Logger } from "../base/Logger"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { ShaderProperty } from "../shader"; import { Texture2D } from "../texture/Texture2D"; import { TextureFilterMode } from "../texture/enums/TextureFilterMode"; @@ -29,12 +29,10 @@ export class SkinnedMeshRenderer extends MeshRenderer { @ignoreClone _condensedBlendShapeWeights: Float32Array; - @deepClone private _localBounds: BoundingBox = new BoundingBox(); @ignoreClone private _jointDataCreateCache: Vector2 = new Vector2(-1, -1); - @ignoreClone private _blendShapeWeights: Float32Array; @ignoreClone private _maxVertexUniformVectors: number; @@ -42,7 +40,6 @@ export class SkinnedMeshRenderer extends MeshRenderer { @ignoreClone private _jointTexture: Texture2D; - @deepClone private _skin: Skin; /** @@ -146,12 +143,12 @@ export class SkinnedMeshRenderer extends MeshRenderer { */ override _cloneTo(target: SkinnedMeshRenderer): void { super._cloneTo(target); - + if (this._jointTexture) { + target.shaderData.setTexture(SkinnedMeshRenderer._jointSamplerProperty, null); + } if (this.skin) { target._applySkin(null, target.skin); } - - this._blendShapeWeights && (target._blendShapeWeights = this._blendShapeWeights.slice()); } protected override _update(context: RenderContext): void { @@ -256,7 +253,7 @@ export class SkinnedMeshRenderer extends MeshRenderer { } @ignoreClone - private _onSkinUpdated(type: SkinUpdateFlag, value: Object): void { + private _onSkinUpdated(type: SkinUpdateFlag, value: number | Entity): void { switch (type) { case SkinUpdateFlag.BoneCountChanged: const shaderData = this.shaderData; diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index e96070f56c..1a279f6e84 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1,6 +1,7 @@ +import { DataObject } from "../base/DataObject"; import { BoundingBox, Color, MathUtil, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; import { Transform } from "../Transform"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { Primitive } from "../graphic/Primitive"; import { SubMesh } from "../graphic/SubMesh"; import { SubPrimitive } from "../graphic/SubPrimitive"; @@ -42,7 +43,7 @@ import { SubEmittersModule } from "./modules/SubEmittersModule"; /** * Particle Generator. */ -export class ParticleGenerator { +export class ParticleGenerator extends DataObject { private static _tempVector20 = new Vector2(); private static _tempVector21 = new Vector2(); private static _tempVector22 = new Vector2(); @@ -63,40 +64,28 @@ export class ParticleGenerator { useAutoRandomSeed = true; /** Main module. */ - @deepClone readonly main: MainModule; /** Emission module. */ - @deepClone readonly emission = new EmissionModule(this); /** Velocity over lifetime module. */ - @deepClone readonly velocityOverLifetime: VelocityOverLifetimeModule; /** Force over lifetime module. */ - @deepClone readonly forceOverLifetime: ForceOverLifetimeModule; /** Limit velocity over lifetime module. */ - @deepClone readonly limitVelocityOverLifetime: LimitVelocityOverLifetimeModule; /** Size over lifetime module. */ - @deepClone readonly sizeOverLifetime: SizeOverLifetimeModule; /** Rotation over lifetime module. */ - @deepClone readonly rotationOverLifetime = new RotationOverLifetimeModule(this); /** Color over lifetime module. */ - @deepClone readonly colorOverLifetime = new ColorOverLifetimeModule(this); /** Texture sheet animation module. */ - @deepClone readonly textureSheetAnimation = new TextureSheetAnimationModule(this); /** Noise module. */ - @deepClone readonly noise: NoiseModule; /** Sub emitters module. */ - @deepClone readonly subEmitters: SubEmittersModule; /** Custom data module. */ - @deepClone readonly customData: CustomDataModule; /** @internal */ @@ -210,6 +199,7 @@ export class ParticleGenerator { * @internal */ constructor(renderer: ParticleRenderer) { + super(); this._renderer = renderer; const subPrimitive = new SubPrimitive(); subPrimitive.start = 0; diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 47324c6e75..e73d8365f4 100644 --- a/packages/core/src/particle/ParticleRenderer.ts +++ b/packages/core/src/particle/ParticleRenderer.ts @@ -5,7 +5,7 @@ import { Renderer, RendererUpdateFlags } from "../Renderer"; import { TransformModifyFlags } from "../Transform"; import { GLCapabilityType } from "../base/Constant"; import { Logger } from "../base/Logger"; -import { deepClone, ignoreClone, shallowClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { ModelMesh } from "../mesh/ModelMesh"; import { ShaderMacro } from "../shader/ShaderMacro"; import { ShaderProperty } from "../shader/ShaderProperty"; @@ -30,14 +30,12 @@ export class ParticleRenderer extends Renderer { private static readonly _currentTime = ShaderProperty.getByName("renderer_CurrentTime"); /** Particle generator. */ - @deepClone readonly generator: ParticleGenerator; /** Specifies how much particles stretch depending on their velocity. */ velocityScale = 0; /** How much are the particles stretched in their direction of motion, defined as the length of the particle compared to its width. */ lengthScale = 2; /** The pivot of particle. */ - @shallowClone pivot = new Vector3(); /** @internal */ diff --git a/packages/core/src/particle/modules/Burst.ts b/packages/core/src/particle/modules/Burst.ts index a444381dc2..b885ca12c4 100644 --- a/packages/core/src/particle/modules/Burst.ts +++ b/packages/core/src/particle/modules/Burst.ts @@ -1,12 +1,11 @@ -import { deepClone } from "../../clone/CloneManager"; +import { DataObject } from "../../base/DataObject"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; /** * A burst is a particle emission event, where a number of particles are all emitted at the same time */ -export class Burst { +export class Burst extends DataObject { public time: number; - @deepClone public count: ParticleCompositeCurve; private _cycles: number; @@ -49,6 +48,7 @@ export class Burst { */ constructor(time: number, count: ParticleCompositeCurve, cycles: number, repeatInterval: number); constructor(time: number, count: ParticleCompositeCurve, cycles?: number, repeatInterval?: number) { + super(); this.time = time; this.count = count; this._cycles = Math.max(cycles ?? 1, 1); diff --git a/packages/core/src/particle/modules/ColorOverLifetimeModule.ts b/packages/core/src/particle/modules/ColorOverLifetimeModule.ts index c920fb3fb4..53a9da55b1 100644 --- a/packages/core/src/particle/modules/ColorOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/ColorOverLifetimeModule.ts @@ -1,5 +1,5 @@ import { Color, Rand, Vector4 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderMacro } from "../../shader/ShaderMacro"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -23,7 +23,6 @@ export class ColorOverLifetimeModule extends ParticleGeneratorModule { static readonly _gradientKeysCount = ShaderProperty.getByName("renderer_COLGradientKeysMaxTime"); /** Color gradient over lifetime. */ - @deepClone color = new ParticleCompositeGradient( new ParticleGradient( [new GradientColorKey(0.0, new Color(1, 1, 1)), new GradientColorKey(1.0, new Color(1, 1, 1))], diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index 4ad2fa6fc9..3b9ecb91ea 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -1,5 +1,4 @@ import { Color, Vector4 } from "@galacean/engine-math"; -import { CloneManager, ignoreClone } from "../../clone/CloneManager"; import { Logger } from "../../base/Logger"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -53,14 +52,10 @@ export class CustomDataModule extends ParticleGeneratorModule { private static readonly _zeroColor = new Color(0, 0, 0, 0); private static readonly _zeroVector4 = new Vector4(0, 0, 0, 0); - @ignoreClone private _curves: Map = new Map(); - @ignoreClone private _gradients: Map = new Map(); - @ignoreClone private _curveStreams: CurveStream[] = []; - @ignoreClone private _gradientStreams: GradientStream[] = []; /** @@ -186,24 +181,6 @@ export class CustomDataModule extends ParticleGeneratorModule { this._gradients.delete(name); } - /** - * @internal - */ - _cloneTo(target: CustomDataModule): void { - // Shared across both loops so cross-entry sub-object references stay shared in the clone. - const deepInstanceMap = new Map(); - for (const [name, curve] of this._curves) { - const clonedCurve = new ParticleCompositeCurve(0); - CloneManager.deepCloneObject(curve, clonedCurve, deepInstanceMap); - target.addCurve(name, clonedCurve); - } - for (const [name, gradient] of this._gradients) { - const clonedGradient = new ParticleCompositeGradient(new Color()); - CloneManager.deepCloneObject(gradient, clonedGradient, deepInstanceMap); - target.addGradient(name, clonedGradient); - } - } - /** * @internal */ diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index a2d303a7d3..9e0cad5086 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -1,5 +1,5 @@ import { MathUtil, Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; @@ -19,13 +19,10 @@ export class EmissionModule extends ParticleGeneratorModule { private static _tempEmitPosition = new Vector3(); /** The rate of particle emission. */ - @deepClone rateOverTime: ParticleCompositeCurve = new ParticleCompositeCurve(10); /** The rate at which the emitter spawns new particles over distance. */ - @deepClone rateOverDistance: ParticleCompositeCurve = new ParticleCompositeCurve(0); - @deepClone _shape: BaseShape; /** @internal */ @ignoreClone @@ -46,7 +43,6 @@ export class EmissionModule extends ParticleGeneratorModule { @ignoreClone private _hasLastEmitPosition = false; - @deepClone private _bursts: Burst[] = []; private _currentBurstIndex = 0; @@ -172,7 +168,11 @@ export class EmissionModule extends ParticleGeneratorModule { * @internal */ _destroy(): void { - this._shape?._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged); + const shape = this._shape; + if (shape) { + shape._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged); + shape._destroy(); + } } private _emitByRateOverTime(playTime: number): void { diff --git a/packages/core/src/particle/modules/ForceOverLifetimeModule.ts b/packages/core/src/particle/modules/ForceOverLifetimeModule.ts index f96ce211c7..ce7c533c0f 100644 --- a/packages/core/src/particle/modules/ForceOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/ForceOverLifetimeModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; @@ -39,11 +39,8 @@ export class ForceOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _randomModeMacro: ShaderMacro; - @deepClone private _forceX: ParticleCompositeCurve; - @deepClone private _forceY: ParticleCompositeCurve; - @deepClone private _forceZ: ParticleCompositeCurve; private _space = ParticleSimulationSpace.Local; diff --git a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts index 9d6a91bc54..5019273b03 100644 --- a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector2, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro } from "../../shader"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; @@ -70,14 +70,10 @@ export class LimitVelocityOverLifetimeModule extends ParticleGeneratorModule { private _dragVelocityMacro: ShaderMacro; private _separateAxes = false; - @deepClone private _speedX: ParticleCompositeCurve; - @deepClone private _speedY: ParticleCompositeCurve; - @deepClone private _speedZ: ParticleCompositeCurve; private _dampen: number = 0; - @deepClone private _drag: ParticleCompositeCurve; private _multiplyDragByParticleSize = false; private _multiplyDragByParticleVelocity = false; diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index d17c156d9a..c736dd046a 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -1,6 +1,7 @@ +import { DataObject } from "../../base/DataObject"; import { Color, Rand, Vector3, Vector4 } from "@galacean/engine-math"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -11,7 +12,7 @@ import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleCompositeGradient } from "./ParticleCompositeGradient"; -export class MainModule implements ICustomClone { +export class MainModule extends DataObject implements ICustomClone { private _tempVector40 = new Vector4(); private static _vector3One = new Vector3(1, 1, 1); @@ -30,25 +31,20 @@ export class MainModule implements ICustomClone { isLoop = true; /** Start delay in seconds. */ - @deepClone startDelay = new ParticleCompositeCurve(0); /** A flag to enable 3D particle rotation, when disabled, only `startRotationZ` is used. */ startRotation3D = false; /** The initial rotation of particles around the x-axis when emitted, in degrees. */ - @deepClone startRotationX = new ParticleCompositeCurve(0); /** The initial rotation of particles around the y-axis when emitted, in degrees. */ - @deepClone startRotationY = new ParticleCompositeCurve(0); /** The initial rotation of particles around the z-axis when emitted, in degrees. */ - @deepClone startRotationZ = new ParticleCompositeCurve(0); /** Makes some particles spin in the opposite direction. */ flipRotation = 0; /** The mode of start color */ - @deepClone startColor = new ParticleCompositeGradient(new Color(1, 1, 1, 1)); /** A scale that this Particle Generator applies to gravity, defined by Physics.gravity. */ /** Override the default playback speed of the Particle Generator. */ @@ -59,7 +55,6 @@ export class MainModule implements ICustomClone { playOnEnabled = true; /** @internal */ - @ignoreClone _maxParticleBuffer = 1000; /** @internal */ @ignoreClone @@ -83,18 +78,12 @@ export class MainModule implements ICustomClone { @ignoreClone readonly _gravityModifierRand = new Rand(0, ParticleRandomSubSeeds.GravityModifier); - @deepClone private _startLifetime: ParticleCompositeCurve; - @deepClone private _startSpeed: ParticleCompositeCurve; private _startSize3D = false; - @deepClone private _startSizeX: ParticleCompositeCurve; - @deepClone private _startSizeY: ParticleCompositeCurve; - @deepClone private _startSizeZ: ParticleCompositeCurve; - @deepClone private _gravityModifier: ParticleCompositeCurve; private _simulationSpace = ParticleSimulationSpace.Local; @ignoreClone @@ -250,6 +239,7 @@ export class MainModule implements ICustomClone { * @internal */ constructor(generator: ParticleGenerator) { + super(); this._generator = generator; this.startLifetime = new ParticleCompositeCurve(5); @@ -332,8 +322,6 @@ export class MainModule implements ICustomClone { * @internal */ _cloneTo(target: MainModule): void { - target.maxParticles = this.maxParticles; - if (target._simulationSpace === ParticleSimulationSpace.World) { target._generator._generateTransformedBounds(); } diff --git a/packages/core/src/particle/modules/NoiseModule.ts b/packages/core/src/particle/modules/NoiseModule.ts index 153a40b5b9..46d2a7b5ca 100644 --- a/packages/core/src/particle/modules/NoiseModule.ts +++ b/packages/core/src/particle/modules/NoiseModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector3, Vector4 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; @@ -47,11 +47,8 @@ export class NoiseModule extends ParticleGeneratorModule { @ignoreClone private _strengthMinConst = new Vector3(); - @deepClone private _strengthX: ParticleCompositeCurve; - @deepClone private _strengthY: ParticleCompositeCurve; - @deepClone private _strengthZ: ParticleCompositeCurve; private _scrollSpeed = 0; private _separateAxes = false; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 00de9f0291..0e761f6ff0 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -1,5 +1,6 @@ +import { DataObject } from "../../base/DataObject"; import { Vector2 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { UpdateFlagManager } from "../../UpdateFlagManager"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { CurveKey, ParticleCurve } from "./ParticleCurve"; @@ -7,17 +8,14 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; /** * Particle composite curve. */ -export class ParticleCompositeCurve { +export class ParticleCompositeCurve extends DataObject { private static _minMaxRange = new Vector2(); - @ignoreClone private _updateManager = new UpdateFlagManager(); private _mode = ParticleCurveMode.Constant; private _constantMin = 0; private _constantMax = 0; - @deepClone private _curveMin: ParticleCurve; - @deepClone private _curveMax: ParticleCurve; @ignoreClone private _updateDispatch: () => void; @@ -142,6 +140,7 @@ export class ParticleCompositeCurve { constructor(curveMin: ParticleCurve, curveMax: ParticleCurve); constructor(constantOrCurve: number | ParticleCurve, constantMaxOrCurveMax?: number | ParticleCurve) { + super(); this._updateDispatch = this._updateManager.dispatch.bind(this._updateManager); if (typeof constantOrCurve === "number") { if (constantMaxOrCurveMax) { diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index e2777bb86b..d2deb3789a 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -1,27 +1,23 @@ +import { DataObject } from "../../base/DataObject"; import { Color } from "@galacean/engine-math"; -import { deepClone } from "../../clone/CloneManager"; import { ParticleGradientMode } from "../enums/ParticleGradientMode"; import { ParticleGradient } from "./ParticleGradient"; /** * Particle composite gradient. */ -export class ParticleCompositeGradient { +export class ParticleCompositeGradient extends DataObject { private static _tempColor = new Color(); /** The gradient mode. */ mode: ParticleGradientMode = ParticleGradientMode.Constant; /* The min constant color used by the gradient if mode is set to `TwoConstants`. */ - @deepClone constantMin: Color = new Color(); /* The max constant color used by the gradient if mode is set to `TwoConstants`. */ - @deepClone constantMax: Color = new Color(); /** The min gradient used by the gradient if mode is set to `Gradient`. */ - @deepClone gradientMin: ParticleGradient = new ParticleGradient(); /** The max gradient used by the gradient if mode is set to `Gradient`. */ - @deepClone gradientMax: ParticleGradient = new ParticleGradient(); /** @@ -46,6 +42,11 @@ export class ParticleCompositeGradient { this.gradientMax = value; } + /** + * Create a particle gradient in constant mode with the default color. + */ + constructor(); + /** * Create a particle gradient that generates a constant color. * @param constant - The constant color @@ -72,7 +73,9 @@ export class ParticleCompositeGradient { */ constructor(gradientMin: ParticleGradient, gradientMax: ParticleGradient); - constructor(constantOrGradient: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { + constructor(constantOrGradient?: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { + super(); + if (!constantOrGradient) return; if (constantOrGradient.constructor === Color) { if (constantMaxOrGradientMax) { this.constantMin.copyFrom(constantOrGradient); diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index fdba01cb62..a84de05a57 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -1,13 +1,12 @@ +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; /** * Particle curve. */ -export class ParticleCurve { - @ignoreClone +export class ParticleCurve extends DataObject { private _updateManager = new UpdateFlagManager(); - @deepClone private _keys = new Array(); @ignoreClone private _typeArray: Float32Array; @@ -27,6 +26,7 @@ export class ParticleCurve { * @param keys - The keys of the curve */ constructor(...keys: CurveKey[]) { + super(); this._updateDispatch = this._updateManager.dispatch.bind(this._updateManager); for (let i = 0, n = keys.length; i < n; i++) { @@ -195,8 +195,7 @@ export class ParticleCurve { /** * The key of the curve. */ -export class CurveKey { - @ignoreClone +export class CurveKey extends DataObject { private _updateManager = new UpdateFlagManager(); private _time: number; private _value: number; @@ -233,6 +232,7 @@ export class CurveKey { * Create a new key. */ constructor(time: number, value: number) { + super(); this._time = time; this._value = value; } diff --git a/packages/core/src/particle/modules/ParticleGeneratorModule.ts b/packages/core/src/particle/modules/ParticleGeneratorModule.ts index 87f0e7ebfa..4353b4bf8d 100644 --- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts +++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleGenerator } from "../ParticleGenerator"; @@ -6,7 +7,7 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; /** * Particle generator module. */ -export abstract class ParticleGeneratorModule { +export abstract class ParticleGeneratorModule extends DataObject { /** @internal */ @ignoreClone _generator: ParticleGenerator; @@ -28,6 +29,7 @@ export abstract class ParticleGeneratorModule { * @internal */ constructor(generator: ParticleGenerator) { + super(); this._generator = generator; } diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index 732f879f5c..6daf6dba6b 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -1,13 +1,12 @@ +import { DataObject } from "../../base/DataObject"; import { Color } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; /** * Particle gradient. */ -export class ParticleGradient { - @deepClone +export class ParticleGradient extends DataObject { private _colorKeys: GradientColorKey[] = []; - @deepClone private _alphaKeys: GradientAlphaKey[] = []; @ignoreClone private _colorTypeArray: Float32Array; @@ -37,6 +36,7 @@ export class ParticleGradient { * @param alphaKeys - The alpha keys of the gradient */ constructor(colorKeys: GradientColorKey[] = null, alphaKeys: GradientAlphaKey[] = null) { + super(); if (colorKeys) { for (let i = 0, n = colorKeys.length; i < n; i++) { const key = colorKeys[i]; @@ -286,7 +286,7 @@ export class ParticleGradient { /** * The color key of the particle gradient. */ -export class GradientColorKey { +export class GradientColorKey extends DataObject { /** @internal */ _onValueChanged: () => void = null; @@ -324,6 +324,7 @@ export class GradientColorKey { * @param color - The alpha component of the gradient colorKey */ constructor(time: number, color: Color) { + super(); this._time = time; color && this._color.copyFrom(color); // @ts-ignore @@ -334,7 +335,7 @@ export class GradientColorKey { /** * The alpha key of the particle gradient. */ -export class GradientAlphaKey { +export class GradientAlphaKey extends DataObject { /** @internal */ _onValueChanged: () => void = null; @@ -371,6 +372,7 @@ export class GradientAlphaKey { * @param alpha - The alpha component of the gradient alpha key */ constructor(time: number, alpha: number) { + super(); this._time = time; this._alpha = alpha; } diff --git a/packages/core/src/particle/modules/RotationOverLifetimeModule.ts b/packages/core/src/particle/modules/RotationOverLifetimeModule.ts index 557f5b983b..b9753b6b6e 100644 --- a/packages/core/src/particle/modules/RotationOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/RotationOverLifetimeModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderMacro } from "../../shader/ShaderMacro"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -29,13 +29,10 @@ export class RotationOverLifetimeModule extends ParticleGeneratorModule { /** Specifies whether the rotation is separate on each axis, when disabled, only `rotationZ` is used. */ separateAxes: boolean = false; /** Rotation over lifetime for x axis, in degrees. */ - @deepClone rotationX = new ParticleCompositeCurve(0); /** Rotation over lifetime for y axis, in degrees. */ - @deepClone rotationY = new ParticleCompositeCurve(0); /** Rotation over lifetime for z axis, in degrees. */ - @deepClone rotationZ = new ParticleCompositeCurve(45); /** @internal */ diff --git a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts index b8c9c6fb18..0242d2b5ec 100644 --- a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts @@ -1,4 +1,4 @@ -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderMacro } from "../../shader/ShaderMacro"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -24,11 +24,8 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule { static readonly _maxCurveZProperty = ShaderProperty.getByName("renderer_SOLMaxCurveZ"); private _separateAxes = false; - @deepClone private _sizeX: ParticleCompositeCurve; - @deepClone private _sizeY: ParticleCompositeCurve; - @deepClone private _sizeZ: ParticleCompositeCurve; @ignoreClone diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index c9c740207e..a05a0a98c5 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; @@ -8,7 +9,7 @@ import type { SubEmittersModule } from "./SubEmittersModule"; * One slot in `SubEmittersModule.subEmitters`. Configures which sub-emitter * fires, on which parent event, with what inheritance, probability, and count. */ -export class SubEmitter { +export class SubEmitter extends DataObject { /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; @@ -19,7 +20,6 @@ export class SubEmitter { emitCount: number = 1; /** @internal */ - @ignoreClone _module: SubEmittersModule = null; private _emitter: ParticleRenderer = null; diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts index 90d19e5feb..de22030aaa 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -1,5 +1,5 @@ import { Color, Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; @@ -44,7 +44,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { return found; } - @deepClone private _subEmitters: SubEmitter[] = []; /** @@ -168,17 +167,6 @@ export class SubEmittersModule extends ParticleGeneratorModule { return false; } - /** - * @internal - */ - _cloneTo(target: SubEmittersModule): void { - // _module is @ignoreClone, so re-link each cloned slot back to its new module - const subEmitters = target._subEmitters; - for (let i = 0, n = subEmitters.length; i < n; i++) { - subEmitters[i]._module = target; - } - } - /** * @internal */ diff --git a/packages/core/src/particle/modules/TextureSheetAnimationModule.ts b/packages/core/src/particle/modules/TextureSheetAnimationModule.ts index c75601c339..089b9191e2 100644 --- a/packages/core/src/particle/modules/TextureSheetAnimationModule.ts +++ b/packages/core/src/particle/modules/TextureSheetAnimationModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector2, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone, shallowClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderMacro } from "../../shader/ShaderMacro"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -24,7 +24,6 @@ export class TextureSheetAnimationModule extends ParticleGeneratorModule { private static readonly _tillingParamsProperty = ShaderProperty.getByName("renderer_TSATillingParams"); /** Frame over time curve of the texture sheet. */ - @deepClone readonly frameOverTime = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1))); /** Texture sheet animation type. */ type = TextureSheetAnimationType.WholeSheet; @@ -32,13 +31,11 @@ export class TextureSheetAnimationModule extends ParticleGeneratorModule { cycleCount = 1; /** @internal */ - @shallowClone _tillingInfo = new Vector3(1, 1, 1); // x:subU, y:subV, z:tileCount /** @internal */ @ignoreClone _frameOverTimeRand = new Rand(0, ParticleRandomSubSeeds.TextureSheetAnimation); - @deepClone private _tiling = new Vector2(1, 1); @ignoreClone private _frameCurveMacro: ShaderMacro; diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts index 67cb113ba5..8e6abe3336 100644 --- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts +++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts @@ -1,5 +1,5 @@ import { Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderMacro } from "../../shader"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -74,21 +74,13 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _radialRandomModeMacro: ShaderMacro; - @deepClone private _velocityX: ParticleCompositeCurve; - @deepClone private _velocityY: ParticleCompositeCurve; - @deepClone private _velocityZ: ParticleCompositeCurve; - @deepClone private _orbitalX: ParticleCompositeCurve; - @deepClone private _orbitalY: ParticleCompositeCurve; - @deepClone private _orbitalZ: ParticleCompositeCurve; - @deepClone private _radial: ParticleCompositeCurve; - @deepClone private _offset = new Vector3(); private _space = ParticleSimulationSpace.Local; diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index 9e00891b59..c9184b66ee 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -1,12 +1,13 @@ import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } from "@galacean/engine-math"; -import { ParticleShapeType } from "./enums/ParticleShapeType"; import { UpdateFlagManager } from "../../../UpdateFlagManager"; -import { deepClone, ignoreClone } from "../../../clone/CloneManager"; +import { DataObject } from "../../../base/DataObject"; +import { ignoreClone } from "../../../clone/CloneManager"; +import { ParticleShapeType } from "./enums/ParticleShapeType"; /** * Base class for all particle shapes. */ -export abstract class BaseShape { +export abstract class BaseShape extends DataObject { /** @internal */ static _tempVector20 = new Vector2(); /** @internal */ @@ -18,18 +19,13 @@ export abstract class BaseShape { private static _tempQuaternion = new Quaternion(); /** The type of shape to emit particles from. */ abstract readonly shapeType: ParticleShapeType; - - @ignoreClone protected _updateManager = new UpdateFlagManager(); private _enabled = true; private _randomDirectionAmount = 0; - @deepClone private _position = new Vector3(0, 0, 0); - @deepClone private _rotation = new Vector3(0, 0, 0); - @deepClone private _scale = new Vector3(1, 1, 1); @ignoreClone private _matrix = new Matrix(); @@ -106,6 +102,7 @@ export abstract class BaseShape { } constructor() { + super(); // @ts-ignore this._position._onValueChanged = this._onTransformChanged; // @ts-ignore @@ -128,6 +125,11 @@ export abstract class BaseShape { this._updateManager.removeListener(listener); } + /** + * @internal + */ + _destroy(): void {} + /** * @internal */ diff --git a/packages/core/src/particle/modules/shape/BoxShape.ts b/packages/core/src/particle/modules/shape/BoxShape.ts index 7ad762e2e8..e1a0796979 100644 --- a/packages/core/src/particle/modules/shape/BoxShape.ts +++ b/packages/core/src/particle/modules/shape/BoxShape.ts @@ -1,5 +1,4 @@ import { Rand, Vector3 } from "@galacean/engine-math"; -import { deepClone } from "../../../clone/CloneManager"; import { BaseShape } from "./BaseShape"; import { ShapeUtils } from "./ShapeUtils"; import { ParticleShapeType } from "./enums/ParticleShapeType"; @@ -10,7 +9,6 @@ import { ParticleShapeType } from "./enums/ParticleShapeType"; export class BoxShape extends BaseShape { readonly shapeType = ParticleShapeType.Box; - @deepClone private _size = new Vector3(1, 1, 1); /** diff --git a/packages/core/src/particle/modules/shape/MeshShape.ts b/packages/core/src/particle/modules/shape/MeshShape.ts index e425003704..2f4f6d6e82 100644 --- a/packages/core/src/particle/modules/shape/MeshShape.ts +++ b/packages/core/src/particle/modules/shape/MeshShape.ts @@ -1,7 +1,6 @@ import { Rand, Vector3, Vector4 } from "@galacean/engine-math"; import { TypedArray } from "../../../base"; import { ignoreClone } from "../../../clone/CloneManager"; -import { Entity } from "../../../Entity"; import { VertexElement } from "../../../graphic"; import { MeshModifyFlags } from "../../../graphic/Mesh"; import { ModelMesh, VertexAttribute } from "../../../mesh"; @@ -51,6 +50,13 @@ export class MeshShape extends BaseShape { } } + /** + * @internal + */ + override _destroy(): void { + this.mesh = null; + } + /** * @internal */ diff --git a/packages/core/src/physics/CharacterController.ts b/packages/core/src/physics/CharacterController.ts index f3c9e6f048..a77811dedc 100644 --- a/packages/core/src/physics/CharacterController.ts +++ b/packages/core/src/physics/CharacterController.ts @@ -1,11 +1,11 @@ import { ICharacterController } from "@galacean/engine-design"; -import { Vector3 } from "@galacean/engine-math"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { Engine } from "../Engine"; import { Entity } from "../Entity"; import { Collider } from "./Collider"; import { ControllerNonWalkableMode } from "./enums/ControllerNonWalkableMode"; import { ColliderShape } from "./shape"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; /** * The character controllers. @@ -13,7 +13,6 @@ import { deepClone, ignoreClone } from "../clone/CloneManager"; export class CharacterController extends Collider { private _stepOffset = 0.5; private _nonWalkableMode: ControllerNonWalkableMode = ControllerNonWalkableMode.PreventClimbing; - @deepClone private _upDirection = new Vector3(0, 1, 0); private _slopeLimit = 45; @@ -162,6 +161,10 @@ export class CharacterController extends Collider { (this._nativeCollider).setSlopeLimit(this._slopeLimit); } + protected override _teleportToEntityTransform(worldPosition: Vector3, _worldRotation: Quaternion): void { + (this._nativeCollider).setWorldPosition(worldPosition); + } + private _syncWorldPositionFromPhysicalSpace(): void { (this._nativeCollider).getWorldPosition(this.entity.transform.worldPosition); } diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index a8a591fe56..502c8fb035 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -1,6 +1,7 @@ import { ICollider, IStaticCollider } from "@galacean/engine-design"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { BoolUpdateFlag } from "../BoolUpdateFlag"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { ICustomClone } from "../clone/ComponentCloner"; import { Component } from "../Component"; import { DependentMode, dependentComponents } from "../ComponentsDependencies"; @@ -22,12 +23,21 @@ export class Collider extends Component implements ICustomClone { /** @internal */ @ignoreClone _nativeCollider: ICollider; - @ignoreClone protected _updateFlag: BoolUpdateFlag; - @deepClone protected _shapes: ColliderShape[] = []; protected _collisionLayerIndex: number = 0; + /** + * A collider must teleport on the next transform sync when its native actor + * already exists at a stale pose, such as after re-entering the scene or after + * clone-time native reconstruction. Ordinary first entry can use subclass sync + * semantics so kinematic actors still use setKinematicTarget. + */ + @ignoreClone + private _pendingReenterTeleport: boolean = false; + @ignoreClone + private _enteredScene: boolean = false; + /** * The shapes of this collider. */ @@ -108,15 +118,17 @@ export class Collider extends Component implements ICustomClone { * @internal */ _onUpdate(): void { - if (this._updateFlag.flag) { + const shapes = this._shapes; + if (this._pendingReenterTeleport || this._updateFlag.flag) { const { transform } = this.entity; - (this._nativeCollider).setWorldTransform( - transform.worldPosition, - transform.worldRotationQuaternion - ); + if (this._pendingReenterTeleport) { + this._teleportToEntityTransform(transform.worldPosition, transform.worldRotationQuaternion); + this._pendingReenterTeleport = false; + } else { + this._syncEntityTransformToNative(transform.worldPosition, transform.worldRotationQuaternion); + } const worldScale = transform.lossyWorldScale; - const shapes = this._shapes; for (let i = 0, n = shapes.length; i < n; i++) { shapes[i]._nativeShape?.setWorldScale(worldScale); } @@ -134,6 +146,10 @@ export class Collider extends Component implements ICustomClone { */ override _onEnableInScene(): void { this.scene.physics._addCollider(this); + if (this._enteredScene) { + this._pendingReenterTeleport = true; + } + this._enteredScene = true; } /** @@ -148,6 +164,7 @@ export class Collider extends Component implements ICustomClone { */ _cloneTo(target: Collider): void { target._syncNative(); + target._pendingReenterTeleport = true; } /** @@ -164,6 +181,32 @@ export class Collider extends Component implements ICustomClone { this._addNativeShape(this.shapes[i]); } this._setCollisionLayer(); + // Teleport native actor to entity's current world pose. + // The native actor was created in constructor() with the entity's then-current + // worldPosition/Rotation. On clone, the entity's transform fields are deep-cloned + // AFTER the Component (and its native actor) are constructed, so the native actor's + // pose lags behind the cloned entity transform until this sync. + const { transform } = this.entity; + this._teleportToEntityTransform(transform.worldPosition, transform.worldRotationQuaternion); + } + + /** + * Teleport native actor to a world pose (instant, no implied velocity). + * Used during initialization paths (clone) where the native actor must be re-aligned + * with the entity transform after construction-time pose was based on stale defaults. + */ + protected _teleportToEntityTransform(worldPosition: Vector3, worldRotation: Quaternion): void { + (this._nativeCollider).setWorldTransform(worldPosition, worldRotation); + } + + /** + * Sync entity world transform to native actor for per-frame updates. + * Default semantics: teleport (setGlobalPose). Subclasses override to express + * physics-aware movement (e.g. DynamicCollider routes kinematic actors through + * setKinematicTarget to generate contact events on swept motion). + */ + protected _syncEntityTransformToNative(worldPosition: Vector3, worldRotation: Quaternion): void { + (this._nativeCollider).setWorldTransform(worldPosition, worldRotation); } /** @@ -183,16 +226,26 @@ export class Collider extends Component implements ICustomClone { protected _addNativeShape(shape: ColliderShape): void { shape._collider = this; - if (shape._nativeShape) { - shape._nativeShape.setWorldScale(this.entity.transform.lossyWorldScale); - this._nativeCollider.addShape(shape._nativeShape); - } + this._attachNativeShape(shape); } protected _removeNativeShape(shape: ColliderShape): void { + this._detachNativeShape(shape); shape._collider = null; - if (shape._nativeShape) { - this._nativeCollider.removeShape(shape._nativeShape); + } + + /** @internal */ + _attachNativeShape(shape: ColliderShape): void { + if (shape._nativeShape && !shape._isShapeAttached) { + shape._nativeShape.setWorldScale(this.entity.transform.lossyWorldScale); + shape._attachToCollider(); + } + } + + /** @internal */ + _detachNativeShape(shape: ColliderShape): void { + if (shape._nativeShape && shape._isShapeAttached) { + shape._detachFromCollider(); } } diff --git a/packages/core/src/physics/Collision.ts b/packages/core/src/physics/Collision.ts index 16edfd5bb3..440864e4fe 100644 --- a/packages/core/src/physics/Collision.ts +++ b/packages/core/src/physics/Collision.ts @@ -29,8 +29,7 @@ export class Collision { */ getContacts(outContacts: ContactPoint[]): number { const nativeCollision = this._nativeCollision; - const smallerShapeId = Math.min(nativeCollision.shape0Id, nativeCollision.shape1Id); - const factor = this.shape.id === smallerShapeId ? 1 : -1; + const factor = this.shape.id === nativeCollision.shape1Id ? 1 : -1; const nativeContactPoints = nativeCollision.getContacts(); const length = nativeContactPoints.size(); for (let i = 0; i < length; i++) { diff --git a/packages/core/src/physics/DynamicCollider.ts b/packages/core/src/physics/DynamicCollider.ts index 5c8725c604..15c12dbd2d 100644 --- a/packages/core/src/physics/DynamicCollider.ts +++ b/packages/core/src/physics/DynamicCollider.ts @@ -33,7 +33,9 @@ export class DynamicCollider extends Collider { private _isKinematic = false; private _constraints: DynamicColliderConstraints = 0; private _collisionDetectionMode: CollisionDetectionMode = CollisionDetectionMode.Discrete; - private _sleepThreshold = 5e-3; + private _kinematicTransformSyncMode: DynamicColliderKinematicTransformSyncMode = + DynamicColliderKinematicTransformSyncMode.Target; + private _sleepThreshold: number | undefined; private _automaticCenterOfMass = true; private _automaticInertiaTensor = true; @@ -223,7 +225,7 @@ export class DynamicCollider extends Collider { * The mass-normalized energy threshold, below which objects start going to sleep. */ get sleepThreshold(): number { - return this._sleepThreshold; + return this._sleepThreshold ?? Engine._nativePhysics?.getDefaultSleepThreshold?.() ?? 5e-3; } set sleepThreshold(value: number) { @@ -325,6 +327,22 @@ export class DynamicCollider extends Collider { } } + /** + * Controls how entity transform changes are synchronized to a kinematic native actor. + * + * @remarks + * `Target` routes transform changes through {@link move}, so PhysX treats the + * actor as moving between frames and can generate swept contacts. `Teleport` + * writes the native pose directly and does not imply velocity. + */ + get kinematicTransformSyncMode(): DynamicColliderKinematicTransformSyncMode { + return this._kinematicTransformSyncMode; + } + + set kinematicTransformSyncMode(value: DynamicColliderKinematicTransformSyncMode) { + this._kinematicTransformSyncMode = value; + } + /** * @internal */ @@ -442,6 +460,30 @@ export class DynamicCollider extends Collider { super.addShape(shape); } + /** + * Route per-frame entity → native transform sync to the correct physics API based + * on kinematic state. + * + * PhysX 4.x docs (PxRigidDynamic): + * "If you intend to move a kinematic actor with [setGlobalPose] and want + * collision detection, use setKinematicTarget() instead." + * + * setGlobalPose is a teleport: PhysX skips contact detection between the old + * and new pose. setKinematicTarget tells PhysX the actor is animating to the + * target during the next simulate(), enabling swept contacts. Some compatibility + * layers need transform writes to stay teleport-like, so the sync mode is + * explicit while {@link move} always keeps target semantics. + * + * @internal + */ + protected override _syncEntityTransformToNative(worldPosition: Vector3, worldRotation: Quaternion): void { + if (this._isKinematic && this._kinematicTransformSyncMode === DynamicColliderKinematicTransformSyncMode.Target) { + (this._nativeCollider).move(worldPosition, worldRotation); + } else { + super._syncEntityTransformToNative(worldPosition, worldRotation); + } + } + /** * @internal */ @@ -467,8 +509,13 @@ export class DynamicCollider extends Collider { override _cloneTo(target: DynamicCollider): void { target._linearVelocity.copyFrom(this.linearVelocity); target._angularVelocity.copyFrom(this.angularVelocity); - target._centerOfMass.copyFrom(this.centerOfMass); - target._inertiaTensor.copyFrom(this.inertiaTensor); + if (!this._automaticCenterOfMass) { + target._centerOfMass.copyFrom(this.centerOfMass); + } + if (!this._automaticInertiaTensor) { + target._inertiaTensor.copyFrom(this.inertiaTensor); + } + target._kinematicTransformSyncMode = this._kinematicTransformSyncMode; super._cloneTo(target); } @@ -498,7 +545,9 @@ export class DynamicCollider extends Collider { } (this._nativeCollider).setMaxAngularVelocity(this._maxAngularVelocity); (this._nativeCollider).setMaxDepenetrationVelocity(this._maxDepenetrationVelocity); - (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + if (this._sleepThreshold !== undefined) { + (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + } (this._nativeCollider).setSolverIterations(this._solverIterations); (this._nativeCollider).setUseGravity(this._useGravity); (this._nativeCollider).setIsKinematic(this._isKinematic); @@ -564,6 +613,16 @@ export enum CollisionDetectionMode { ContinuousSpeculative } +/** + * Kinematic transform synchronization mode. + */ +export enum DynamicColliderKinematicTransformSyncMode { + /** Synchronize transform changes through PhysX setKinematicTarget. */ + Target, + /** Synchronize transform changes by directly teleporting the native actor. */ + Teleport +} + /** * Use these flags to constrain motion of dynamic collider. */ diff --git a/packages/core/src/physics/PhysicsMaterial.ts b/packages/core/src/physics/PhysicsMaterial.ts index 3ac16e66d8..aa22bc8c24 100644 --- a/packages/core/src/physics/PhysicsMaterial.ts +++ b/packages/core/src/physics/PhysicsMaterial.ts @@ -1,6 +1,7 @@ import { IPhysicsMaterial } from "@galacean/engine-design"; import { Engine } from "../Engine"; import { PhysicsMaterialCombineMode } from "./enums/PhysicsMaterialCombineMode"; +import { ignoreClone } from "../clone/CloneManager"; /** * Material class to represent a set of surface properties. @@ -14,6 +15,7 @@ export class PhysicsMaterial { private _destroyed: boolean; /** @internal */ + @ignoreClone _nativeMaterial: IPhysicsMaterial; constructor() { @@ -21,8 +23,8 @@ export class PhysicsMaterial { this._staticFriction, this._dynamicFriction, this._bounciness, - this._bounceCombine, - this._frictionCombine + this._frictionCombine, + this._bounceCombine ); } @@ -103,4 +105,23 @@ export class PhysicsMaterial { !this._destroyed && this._nativeMaterial.destroy(); this._destroyed = true; } + + /** + * @internal + */ + _cloneTo(target: PhysicsMaterial): void { + target._syncNative(); + } + + /** + * @internal + */ + _syncNative(): void { + const nativeMaterial = this._nativeMaterial; + nativeMaterial.setStaticFriction(this._staticFriction); + nativeMaterial.setDynamicFriction(this._dynamicFriction); + nativeMaterial.setBounciness(this._bounciness); + nativeMaterial.setFrictionCombine(this._frictionCombine); + nativeMaterial.setBounceCombine(this._bounceCombine); + } } diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index ce6bf2e4e8..806146d55b 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -24,11 +24,15 @@ export class PhysicsScene { private _scene: Scene; private _restTime: number = 0; private _fixedTimeStep: number = 1 / 60; + private _maximumDeltaTime: number = Infinity; private _colliders: DisorderedArray = new DisorderedArray(); private _gravity: Vector3 = new Vector3(0, -9.81, 0); private _nativePhysicsScene: IPhysicsScene; + private _collisionEventConsumersDirty = true; + private _hasCollisionEventConsumersCache = false; + private _contactEventEnabled: boolean | undefined; /** * The gravity of physics scene. @@ -55,6 +59,17 @@ export class PhysicsScene { this._fixedTimeStep = Math.max(value, MathUtil.zeroTolerance); } + /** + * Maximum delta time in seconds allowed per frame for physics simulation. + */ + get maximumDeltaTime(): number { + return this._maximumDeltaTime; + } + + set maximumDeltaTime(value: number) { + this._maximumDeltaTime = Math.max(value, MathUtil.zeroTolerance); + } + constructor(scene: Scene) { this._scene = scene; @@ -640,12 +655,13 @@ export class PhysicsScene { const { _fixedTimeStep: fixedTimeStep, _nativePhysicsScene: nativePhysicsManager } = this; const componentsManager = this._scene._componentsManager; - const simulateTime = this._restTime + deltaTime; + const simulateTime = this._restTime + Math.min(deltaTime, this._maximumDeltaTime); const step = Math.floor(simulateTime / fixedTimeStep); this._restTime = simulateTime - step * fixedTimeStep; for (let i = 0; i < step; i++) { componentsManager.callScriptOnPhysicsUpdate(); this._callColliderOnUpdate(); + this._syncContactEventDemand(); nativePhysicsManager.update(fixedTimeStep); this._callColliderOnLateUpdate(); this._dispatchEvents(nativePhysicsManager.updateEvents()); @@ -661,6 +677,7 @@ export class PhysicsScene { if (collider._index === -1) { collider._index = this._colliders.length; this._colliders.add(collider); + this._markCollisionEventConsumersDirty(); } this._nativePhysicsScene.addCollider(collider._nativeCollider); } @@ -674,6 +691,7 @@ export class PhysicsScene { if (controller._index === -1) { controller._index = this._colliders.length; this._colliders.add(controller); + this._markCollisionEventConsumersDirty(); } this._nativePhysicsScene.addCharacterController(controller._nativeCollider); } @@ -687,6 +705,7 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(collider._index); replaced && (replaced._index = collider._index); collider._index = -1; + this._markCollisionEventConsumersDirty(); this._nativePhysicsScene.removeCollider(collider._nativeCollider); } @@ -699,9 +718,17 @@ export class PhysicsScene { const replaced = this._colliders.deleteByIndex(controller._index); replaced && (replaced._index = controller._index); controller._index = -1; + this._markCollisionEventConsumersDirty(); this._nativePhysicsScene.removeCharacterController(controller._nativeCollider); } + /** + * @internal + */ + _markCollisionEventConsumersDirty(): void { + this._collisionEventConsumersDirty = true; + } + /** * @internal */ @@ -825,6 +852,44 @@ export class PhysicsScene { } } + private _hasCollisionEventConsumers(): boolean { + if (!this._collisionEventConsumersDirty) { + return this._hasCollisionEventConsumersCache; + } + + const { _elements: colliders } = this._colliders; + const { onCollisionEnter, onCollisionExit, onCollisionStay } = Script.prototype; + + for (let i = this._colliders.length - 1; i >= 0; --i) { + const scripts = colliders[i].entity._scripts; + const scriptElements = scripts._elements; + for (let j = scripts.length - 1; j >= 0; --j) { + const script = scriptElements[j]; + if ( + script.onCollisionEnter !== onCollisionEnter || + script.onCollisionExit !== onCollisionExit || + script.onCollisionStay !== onCollisionStay + ) { + this._collisionEventConsumersDirty = false; + this._hasCollisionEventConsumersCache = true; + return true; + } + } + } + + this._collisionEventConsumersDirty = false; + this._hasCollisionEventConsumersCache = false; + return this._hasCollisionEventConsumersCache; + } + + private _syncContactEventDemand(): void { + const enabled = this._hasCollisionEventConsumers(); + if (this._contactEventEnabled !== enabled) { + this._nativePhysicsScene.setContactEventEnabled?.(enabled); + this._contactEventEnabled = enabled; + } + } + private _setGravity(): void { this._nativePhysicsScene.setGravity(this._gravity); } diff --git a/packages/core/src/physics/index.ts b/packages/core/src/physics/index.ts index 537bd367d6..f2d4cdf576 100644 --- a/packages/core/src/physics/index.ts +++ b/packages/core/src/physics/index.ts @@ -1,6 +1,11 @@ export { CharacterController } from "./CharacterController"; export { Collider } from "./Collider"; -export { CollisionDetectionMode, DynamicCollider, DynamicColliderConstraints } from "./DynamicCollider"; +export { + CollisionDetectionMode, + DynamicCollider, + DynamicColliderConstraints, + DynamicColliderKinematicTransformSyncMode +} from "./DynamicCollider"; export { HitResult } from "./HitResult"; export { PhysicsMaterial } from "./PhysicsMaterial"; export { PhysicsScene } from "./PhysicsScene"; diff --git a/packages/core/src/physics/joint/HingeJoint.ts b/packages/core/src/physics/joint/HingeJoint.ts index acffbae4b6..1212343e2f 100644 --- a/packages/core/src/physics/joint/HingeJoint.ts +++ b/packages/core/src/physics/joint/HingeJoint.ts @@ -6,20 +6,17 @@ import { HingeJointFlag } from "../enums/HingeJointFlag"; import { Joint } from "./Joint"; import { JointLimits } from "./JointLimits"; import { JointMotor } from "./JointMotor"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { Entity } from "../../Entity"; /** * A joint which behaves in a similar way to a hinge or axle. */ export class HingeJoint extends Joint { - @deepClone private _axis = new Vector3(1, 0, 0); private _hingeFlags = HingeJointFlag.None; private _useSpring = false; - @deepClone private _jointMotor: JointMotor; - @deepClone private _limits: JointLimits; private _angle = 0; private _velocity = 0; diff --git a/packages/core/src/physics/joint/Joint.ts b/packages/core/src/physics/joint/Joint.ts index 8d9f8af178..1f721d93a8 100644 --- a/packages/core/src/physics/joint/Joint.ts +++ b/packages/core/src/physics/joint/Joint.ts @@ -1,10 +1,11 @@ +import { DataObject } from "../../base/DataObject"; import { IJoint } from "@galacean/engine-design"; import { Matrix, Quaternion, Vector3 } from "@galacean/engine-math"; import { Component } from "../../Component"; import { DependentMode, dependentComponents } from "../../ComponentsDependencies"; import { Entity } from "../../Entity"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { Collider } from "../Collider"; import { DynamicCollider } from "../DynamicCollider"; @@ -18,9 +19,7 @@ export abstract class Joint extends Component { private static _tempQuat = new Quaternion(); private static _tempMatrix = new Matrix(); - @deepClone protected _colliderInfo = new JointColliderInfo(); - @deepClone protected _connectedColliderInfo = new JointColliderInfo(); @ignoreClone protected _nativeJoint: IJoint; @@ -321,11 +320,9 @@ enum AnchorOwner { /** * @internal */ -class JointColliderInfo { +class JointColliderInfo extends DataObject { collider: Collider = null; - @deepClone anchor = new Vector3(); - @deepClone actualAnchor = new Vector3(); massScale: number = 1; inertiaScale: number = 1; diff --git a/packages/core/src/physics/joint/JointLimits.ts b/packages/core/src/physics/joint/JointLimits.ts index 3d7ff811be..4369abb48d 100644 --- a/packages/core/src/physics/joint/JointLimits.ts +++ b/packages/core/src/physics/joint/JointLimits.ts @@ -1,11 +1,10 @@ -import { deepClone } from "../../clone/CloneManager"; +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * JointLimits is used to limit the joints angle. */ -export class JointLimits { - @deepClone +export class JointLimits extends DataObject { /** @internal */ _updateFlagManager = new UpdateFlagManager(); diff --git a/packages/core/src/physics/joint/JointMotor.ts b/packages/core/src/physics/joint/JointMotor.ts index 0f381c1a6c..73024929fa 100644 --- a/packages/core/src/physics/joint/JointMotor.ts +++ b/packages/core/src/physics/joint/JointMotor.ts @@ -1,11 +1,10 @@ -import { deepClone } from "../../clone/CloneManager"; +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * The JointMotor is used to motorize a joint. */ -export class JointMotor { - @deepClone +export class JointMotor extends DataObject { /** @internal */ _updateFlagManager = new UpdateFlagManager(); diff --git a/packages/core/src/physics/shape/BoxColliderShape.ts b/packages/core/src/physics/shape/BoxColliderShape.ts index 12c32a0a4b..2d9383aee1 100644 --- a/packages/core/src/physics/shape/BoxColliderShape.ts +++ b/packages/core/src/physics/shape/BoxColliderShape.ts @@ -2,13 +2,12 @@ import { ColliderShape } from "./ColliderShape"; import { IBoxColliderShape } from "@galacean/engine-design"; import { Vector3 } from "@galacean/engine-math"; import { Engine } from "../../Engine"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; /** * Physical collider shape for box. */ export class BoxColliderShape extends ColliderShape { - @deepClone private _size: Vector3 = new Vector3(1, 1, 1); /** diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 2ac82319fd..bc569565d9 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -1,8 +1,9 @@ +import { DataObject } from "../../base/DataObject"; import { IColliderShape } from "@galacean/engine-design"; import { PhysicsMaterial } from "../PhysicsMaterial"; import { Vector3 } from "@galacean/engine-math"; import { Collider } from "../Collider"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; import { Engine } from "../../Engine"; import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag"; @@ -10,24 +11,25 @@ import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag"; /** * Abstract class for collider shapes. */ -export abstract class ColliderShape implements ICustomClone { +export abstract class ColliderShape extends DataObject implements ICustomClone { private static _idGenerator: number = 0; /** @internal */ _collider: Collider; /** @internal */ @ignoreClone + _isShapeAttached: boolean = false; + /** @internal */ + @ignoreClone _nativeShape: IColliderShape; @ignoreClone protected _id: number; protected _material: PhysicsMaterial; private _isTrigger: boolean = false; - @deepClone private _rotation: Vector3 = new Vector3(); - @deepClone private _position: Vector3 = new Vector3(); - private _contactOffset: number = 0.02; + private _contactOffset: number | undefined; /** * @internal @@ -55,7 +57,7 @@ export abstract class ColliderShape implements ICustomClone { * @defaultValue 0.02 */ get contactOffset(): number { - return this._contactOffset; + return this._contactOffset ?? Engine._nativePhysics?.getDefaultContactOffset?.() ?? 0.02; } set contactOffset(value: number) { @@ -124,6 +126,7 @@ export abstract class ColliderShape implements ICustomClone { } protected constructor() { + super(); this._material = new PhysicsMaterial(); this._id = ColliderShape._idGenerator++; @@ -179,11 +182,29 @@ export abstract class ColliderShape implements ICustomClone { delete Engine._physicalObjectsMap[this._id]; } + /** + * @internal + */ + _attachToCollider(): void { + this._collider._nativeCollider.addShape(this._nativeShape); + this._isShapeAttached = true; + } + + /** + * @internal + */ + _detachFromCollider(): void { + this._collider._nativeCollider.removeShape(this._nativeShape); + this._isShapeAttached = false; + } + protected _syncNative(): void { if (!this._nativeShape) return; this._nativeShape.setPosition(this._position); this._nativeShape.setRotation(this._rotation); - this._nativeShape.setContactOffset(this._contactOffset); + if (this._contactOffset !== undefined) { + this._nativeShape.setContactOffset(this._contactOffset); + } this._nativeShape.setIsTrigger(this._isTrigger); this._nativeShape.setMaterial(this._material._nativeMaterial); diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index 51a46ab0e0..9f4f72d8ee 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -2,6 +2,7 @@ import { IMeshColliderShape } from "@galacean/engine-design"; import { Engine } from "../../Engine"; import { ModelMesh } from "../../mesh/ModelMesh"; import { Vector3 } from "@galacean/engine-math"; +import { ignoreClone } from "../../clone/CloneManager"; import { DynamicCollider } from "../DynamicCollider"; import { MeshColliderShapeCookingFlag } from "../enums/MeshColliderShapeCookingFlag"; import { ColliderShape } from "./ColliderShape"; @@ -10,12 +11,14 @@ import { ColliderShape } from "./ColliderShape"; * Collider shape based on mesh geometry, supporting both convex hull and triangle mesh modes. */ export class MeshColliderShape extends ColliderShape { + @ignoreClone private _mesh: ModelMesh = null; private _isConvex = false; + @ignoreClone private _positions: Vector3[] = null; + @ignoreClone private _indices: Uint8Array | Uint16Array | Uint32Array | null = null; private _cookingFlags = MeshColliderShapeCookingFlag.Cleaning | MeshColliderShapeCookingFlag.VertexWelding; - private _isShapeAttached = false; /** * Cooking flags for this mesh collider shape. @@ -26,9 +29,17 @@ export class MeshColliderShape extends ColliderShape { set cookingFlags(value: MeshColliderShapeCookingFlag) { if (this._cookingFlags !== value) { + const previousValue = this._cookingFlags; this._cookingFlags = value; if (this._nativeShape) { - this._updateNativeShapeData(); + let updated = false; + try { + updated = this._updateNativeShapeData(); + } finally { + if (!updated) { + this._cookingFlags = previousValue; + } + } } else if (this._mesh && this._extractMeshData(this._mesh)) { this._createNativeShape(); } @@ -71,14 +82,34 @@ export class MeshColliderShape extends ColliderShape { set mesh(value: ModelMesh) { if (this._mesh !== value) { + const previousMesh = this._mesh; + const previousPositions = this._positions; + const previousIndices = this._indices; this._mesh?._addReferCount(-1); value?._addReferCount(1); this._mesh = value; - if (value && this._extractMeshData(value)) { - if (this._nativeShape) { - this._updateNativeShapeData(); + if (value) { + if (this._extractMeshData(value)) { + if (this._nativeShape) { + let updated = false; + try { + updated = this._updateNativeShapeData(); + } finally { + if (!updated) { + value?._addReferCount(-1); + previousMesh?._addReferCount(1); + this._mesh = previousMesh; + this._positions = previousPositions; + this._indices = previousIndices; + } + } + } else { + this._createNativeShape(); + } } else { - this._createNativeShape(); + // Mesh data extraction cannot recover without assigning a new mesh. + this._destroyNativeShape(); + this._clearMeshData(); } } else { this._destroyNativeShape(); @@ -98,6 +129,14 @@ export class MeshColliderShape extends ColliderShape { return super.getClosestPoint(point, outClosestPoint); } + /** + * @internal + */ + override _cloneTo(target: MeshColliderShape): void { + target.mesh = this._mesh; + super._cloneTo(target); + } + /** * @internal */ @@ -117,9 +156,7 @@ export class MeshColliderShape extends ColliderShape { private _destroyNativeShape(): void { if (this._nativeShape) { - if (this._isShapeAttached) { - this._detachFromCollider(); - } + this._collider?._detachNativeShape(this); this._nativeShape.destroy(); this._nativeShape = null; } @@ -152,7 +189,7 @@ export class MeshColliderShape extends ColliderShape { return true; } - private _updateNativeShapeData(): void { + private _updateNativeShapeData(): boolean { if ( (this._nativeShape).setMeshData( this._positions, @@ -161,30 +198,22 @@ export class MeshColliderShape extends ColliderShape { this._cookingFlags ) ) { - // Re-add to collider if previously removed due to cooking failure - if (this._collider && !this._isShapeAttached) { - this._attachToCollider(); - } - } else if (this._isShapeAttached) { - this._detachFromCollider(); + this._collider?._attachNativeShape(this); + return true; } + return false; } - private _detachFromCollider(): void { - this._collider._nativeCollider.removeShape(this._nativeShape); - this._isShapeAttached = false; - } - - private _attachToCollider(): void { - this._collider._nativeCollider.addShape(this._nativeShape); - this._isShapeAttached = true; - } - - private _createNativeShape(): void { + private _createNativeShape(attachToCollider = true): boolean { // Non-convex MeshColliderShape is only supported on StaticCollider or kinematic DynamicCollider - if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) { + if ( + attachToCollider && + !this._isConvex && + this._collider instanceof DynamicCollider && + !this._collider.isKinematic + ) { console.error("MeshColliderShape: Non-convex mesh is not supported on non-kinematic DynamicCollider."); - return; + return false; } const nativeShape = Engine._nativePhysics.createMeshColliderShape( @@ -197,7 +226,7 @@ export class MeshColliderShape extends ColliderShape { ); if (!nativeShape) { - return; + return false; } this._nativeShape = nativeShape; @@ -206,9 +235,9 @@ export class MeshColliderShape extends ColliderShape { super._syncNative(); // If already attached to a collider, add the newly created native shape to it - if (this._collider) { - nativeShape.setWorldScale(this._collider.entity.transform.lossyWorldScale); - this._attachToCollider(); + if (attachToCollider && this._collider) { + this._collider._attachNativeShape(this); } + return true; } } diff --git a/packages/core/src/postProcess/PostProcess.ts b/packages/core/src/postProcess/PostProcess.ts index 66521a02f1..0a926f49ee 100644 --- a/packages/core/src/postProcess/PostProcess.ts +++ b/packages/core/src/postProcess/PostProcess.ts @@ -1,5 +1,4 @@ import { Logger } from "../base"; -import { deepClone } from "../clone/CloneManager"; import { Component } from "../Component"; import { Layer } from "../Layer"; import { PostProcessEffect } from "./PostProcessEffect"; @@ -19,7 +18,6 @@ export class PostProcess extends Component { blendDistance = 0; /** @internal */ - @deepClone _effects: PostProcessEffect[] = []; private _priority = 0; diff --git a/packages/core/src/postProcess/PostProcessEffect.ts b/packages/core/src/postProcess/PostProcessEffect.ts index a2a29d382a..d976861d04 100644 --- a/packages/core/src/postProcess/PostProcessEffect.ts +++ b/packages/core/src/postProcess/PostProcessEffect.ts @@ -1,9 +1,10 @@ +import { DataObject } from "../base/DataObject"; import { PostProcessEffectParameter } from "./PostProcessEffectParameter"; /** * The base class for post process effect. */ -export class PostProcessEffect { +export class PostProcessEffect extends DataObject { private _enabled = true; private _parameters: PostProcessEffectParameter[] = []; private _parameterInitialized = false; @@ -56,7 +57,7 @@ export class PostProcessEffect { private _getParameters(): PostProcessEffectParameter[] { if (!this._parameterInitialized) { this._parameterInitialized = true; - for (let key in this) { + for (const key in this) { const value = this[key]; if (value instanceof PostProcessEffectParameter) { this._parameters.push(value); diff --git a/packages/core/src/postProcess/PostProcessEffectParameter.ts b/packages/core/src/postProcess/PostProcessEffectParameter.ts index 899860ba76..15e16ed442 100644 --- a/packages/core/src/postProcess/PostProcessEffectParameter.ts +++ b/packages/core/src/postProcess/PostProcessEffectParameter.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../base/DataObject"; import { Color, MathUtil, Vector2, Vector3, Vector4 } from "@galacean/engine-math"; import { Texture } from "../texture"; @@ -6,7 +7,7 @@ import { Texture } from "../texture"; * @remarks * The parameter will be mixed to a final value and be used in post process manager. */ -export abstract class PostProcessEffectParameter { +export abstract class PostProcessEffectParameter extends DataObject { /** * Whether the parameter is enabled. */ @@ -27,6 +28,7 @@ export abstract class PostProcessEffectParameter { } constructor(value: T, needLerp = false) { + super(); this._needLerp = needLerp; this._value = value; } diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index a3bb7992b8..e338cfdd21 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -1,7 +1,9 @@ +import { DataObject } from "../base/DataObject"; import { IClone } from "@galacean/engine-design"; import { Color, Matrix, Vector2, Vector3, Vector4 } from "@galacean/engine-math"; import { IReferable } from "../asset/IReferable"; -import { CloneManager, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; +import { CloneUtil } from "../clone/CloneUtil"; import { Texture } from "../texture/Texture"; import { ShaderMacro } from "./ShaderMacro"; import { ShaderMacroCollection } from "./ShaderMacroCollection"; @@ -12,7 +14,7 @@ import { ShaderPropertyType } from "./enums/ShaderPropertyType"; /** * Shader data collection,Correspondence includes shader properties data and macros data. */ -export class ShaderData implements IReferable, IClone { +export class ShaderData extends DataObject implements IReferable, IClone { /** @internal */ @ignoreClone _group: ShaderDataGroup; @@ -34,6 +36,7 @@ export class ShaderData implements IReferable, IClone { * @internal */ constructor(group: ShaderDataGroup) { + super(); this._group = group; } @@ -561,7 +564,7 @@ export class ShaderData implements IReferable, IClone { if (out) { const macroMap = this._macroMap; out.length = 0; - for (var key in macroMap) { + for (const key in macroMap) { out.push(macroMap[key]); } } else { @@ -592,7 +595,7 @@ export class ShaderData implements IReferable, IClone { const propertyValueMap = this._propertyValueMap; const propertyIdMap = ShaderProperty._propertyIdMap; - for (let key in propertyValueMap) { + for (const key in propertyValueMap) { properties.push(propertyIdMap[key]); } @@ -608,8 +611,12 @@ export class ShaderData implements IReferable, IClone { } cloneTo(target: ShaderData): void { - CloneManager.deepCloneObject(this._macroCollection, target._macroCollection, new Map()); - Object.assign(target._macroMap, this._macroMap); + CloneUtil._deepCloneObject(this._macroCollection, target._macroCollection, new Map()); + const targetMacroMap = target._macroMap; + for (const key in targetMacroMap) { + delete targetMacroMap[key]; + } + Object.assign(targetMacroMap, this._macroMap); const referCount = target._getReferCount(); const propertyValueMap = this._propertyValueMap; const targetPropertyValueMap = target._propertyValueMap; @@ -624,7 +631,9 @@ export class ShaderData implements IReferable, IClone { targetPropertyValueMap[k] = property; referCount > 0 && property._addReferCount(referCount); } else if (property instanceof Array || property instanceof Float32Array || property instanceof Int32Array) { - targetPropertyValueMap[k] = property.slice(); + const cloned = property.slice(); + targetPropertyValueMap[k] = cloned; + referCount > 0 && this._addTexturesReferCount(cloned, referCount); } else { const targetProperty = targetPropertyValueMap[k]; if (targetProperty) { @@ -639,6 +648,13 @@ export class ShaderData implements IReferable, IClone { } } + /** + * @internal + */ + _cloneTo(target: ShaderData): void { + this.cloneTo(target); + } + /** * @internal */ @@ -709,8 +725,17 @@ export class ShaderData implements IReferable, IClone { for (const k in properties) { const property = properties[k]; // @todo: Separate array to speed performance. - if (property && property instanceof Texture) { - property._addReferCount(value); + property && this._addTexturesReferCount(property, value); + } + } + + private _addTexturesReferCount(property: ShaderPropertyValueType, count: number): void { + if (property instanceof Texture) { + property._addReferCount(count); + } else if (property instanceof Array) { + for (let i = 0, n = property.length; i < n; i++) { + const element = property[i]; + element instanceof Texture && element._addReferCount(count); } } } diff --git a/packages/core/src/shader/ShaderFactory.ts b/packages/core/src/shader/ShaderFactory.ts index d8380e0846..5f9d8efe87 100644 --- a/packages/core/src/shader/ShaderFactory.ts +++ b/packages/core/src/shader/ShaderFactory.ts @@ -210,11 +210,17 @@ mat3 _normalMatFromModel(mat3 m) { static injectInstanceUBO( engine: Engine, vertexSource: string, - fragmentSource: string + fragmentSource: string, + activeMacros?: ReadonlySet ): { vertexSource: string; fragmentSource: string; instanceLayout: InstanceBufferLayout | null } { const fieldMap: Record = Object.create(null); - vertexSource = ShaderFactory._scanInstanceUniforms(vertexSource, fieldMap); - fragmentSource = ShaderFactory._scanInstanceUniforms(fragmentSource, fieldMap); + if (activeMacros) { + vertexSource = ShaderFactory._scanInstanceUniformsWithMacros(vertexSource, fieldMap, activeMacros); + fragmentSource = ShaderFactory._scanInstanceUniformsWithMacros(fragmentSource, fieldMap, activeMacros); + } else { + vertexSource = ShaderFactory._scanInstanceUniforms(vertexSource, fieldMap); + fragmentSource = ShaderFactory._scanInstanceUniforms(fragmentSource, fieldMap); + } // Even when fieldMap is empty, derived built-ins (e.g. `renderer_MVPMat`) may have // had their declarations stripped by scan and still need a `#define` to compile @@ -265,6 +271,62 @@ mat3 _normalMatFromModel(mat3 m) { }); } + private static readonly _ifdefRegex = /^[ \t]*#ifdef\s+(\w+)/; + private static readonly _ifndefRegex = /^[ \t]*#ifndef\s+(\w+)/; + private static readonly _elseRegex = /^[ \t]*#else\b/; + private static readonly _endifRegex = /^[ \t]*#endif\b/; + + /** + * Scan raw GLSL while respecting simple conditional-compilation branches so renderer uniforms + * in inactive branches are not moved into the instance UBO. + */ + private static _scanInstanceUniformsWithMacros( + source: string, + fieldMap: Record, + activeMacros: ReadonlySet + ): string { + const branchStack: boolean[] = [true]; + const lines = source.split("\n"); + + for (let i = 0, n = lines.length; i < n; i++) { + const line = lines[i]; + let match = line.match(ShaderFactory._ifdefRegex); + if (match) { + const parentActive = branchStack[branchStack.length - 1]; + branchStack.push(parentActive && activeMacros.has(match[1])); + continue; + } + + match = line.match(ShaderFactory._ifndefRegex); + if (match) { + const parentActive = branchStack[branchStack.length - 1]; + branchStack.push(parentActive && !activeMacros.has(match[1])); + continue; + } + + if (ShaderFactory._elseRegex.test(line)) { + if (branchStack.length > 1) { + const parentActive = branchStack[branchStack.length - 2]; + branchStack[branchStack.length - 1] = parentActive && !branchStack[branchStack.length - 1]; + } + continue; + } + + if (ShaderFactory._endifRegex.test(line)) { + if (branchStack.length > 1) { + branchStack.pop(); + } + continue; + } + + if (branchStack[branchStack.length - 1]) { + lines[i] = ShaderFactory._scanInstanceUniforms(line, fieldMap); + } + } + + return lines.join("\n"); + } + private static _buildLayout(engine: Engine, fieldMap: Record): InstanceBufferLayout { const maxUBOSize = engine._hardwareRenderer.maxUniformBlockSize; const std140Map = ShaderFactory._std140TypeInfoMap; diff --git a/packages/core/src/shader/ShaderPass.ts b/packages/core/src/shader/ShaderPass.ts index d0e9c1aa0b..c020bdaebf 100644 --- a/packages/core/src/shader/ShaderPass.ts +++ b/packages/core/src/shader/ShaderPass.ts @@ -55,6 +55,7 @@ export class ShaderPass extends ShaderPart { private static _shaderMacroList: ShaderMacro[] = []; private static _macroMap: Map = new Map(); + private static _activeMacroSet: Set = new Set(); /** * Create a shader pass from precompiled instructions. @@ -147,17 +148,20 @@ export class ShaderPass extends ShaderPart { } const macroMap = ShaderPass._macroMap; + const activeMacroSet = ShaderPass._activeMacroSet; macroMap.clear(); + activeMacroSet.clear(); for (let i = 0, n = shaderMacroList.length; i < n; i++) { const macro = shaderMacroList[i]; macroMap.set(macro.name, macro.value ?? ""); + activeMacroSet.add(macro.name); } let vertexSource = ShaderMacroProcessor.evaluate(this._vertexShaderInstructions, macroMap); let fragmentSource = ShaderMacroProcessor.evaluate(this._fragmentShaderInstructions, macroMap); let instanceLayout: InstanceBufferLayout | null = null; if (isGPUInstance) { - const injected = ShaderFactory.injectInstanceUBO(engine, vertexSource, fragmentSource); + const injected = ShaderFactory.injectInstanceUBO(engine, vertexSource, fragmentSource, activeMacroSet); vertexSource = injected.vertexSource; fragmentSource = injected.fragmentSource; instanceLayout = injected.instanceLayout; diff --git a/packages/core/src/shader/state/BlendState.ts b/packages/core/src/shader/state/BlendState.ts index 870526d0ab..443cd1acbe 100644 --- a/packages/core/src/shader/state/BlendState.ts +++ b/packages/core/src/shader/state/BlendState.ts @@ -1,8 +1,8 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { Color } from "@galacean/engine-math"; import { RenderStateElementMap } from "../../BasicResources"; import { GLCapabilityType } from "../../base/Constant"; -import { deepClone } from "../../clone/CloneManager"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { BlendFactor } from "../enums/BlendFactor"; @@ -15,7 +15,7 @@ import { RenderTargetBlendState } from "./RenderTargetBlendState"; /** * Blend state. */ -export class BlendState { +export class BlendState extends DataObject { private static _getGLBlendFactor(rhi: IHardwareRenderer, blendFactor: BlendFactor): number { const gl = rhi.gl; @@ -73,10 +73,8 @@ export class BlendState { } /** The blend state of the render target. */ - @deepClone readonly targetBlendState: RenderTargetBlendState = new RenderTargetBlendState(); /** Constant blend color. */ - @deepClone readonly blendColor: Color = new Color(0, 0, 0, 0); /** Whether to use (Alpha-to-Coverage) technology. */ alphaToCoverage: boolean = false; diff --git a/packages/core/src/shader/state/DepthState.ts b/packages/core/src/shader/state/DepthState.ts index 9757e22fda..15b221efb9 100644 --- a/packages/core/src/shader/state/DepthState.ts +++ b/packages/core/src/shader/state/DepthState.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; import { ShaderData } from "../ShaderData"; @@ -9,7 +10,7 @@ import { RenderState } from "./RenderState"; /** * Depth state. */ -export class DepthState { +export class DepthState extends DataObject { private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number { const gl = rhi.gl; diff --git a/packages/core/src/shader/state/RasterState.ts b/packages/core/src/shader/state/RasterState.ts index 660abff343..ddebc3afa8 100644 --- a/packages/core/src/shader/state/RasterState.ts +++ b/packages/core/src/shader/state/RasterState.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; import { ShaderData } from "../ShaderData"; @@ -9,7 +10,7 @@ import { RenderState } from "./RenderState"; /** * Raster state. */ -export class RasterState { +export class RasterState extends DataObject { /** Specifies whether or not front- and/or back-facing polygons can be culled. */ cullMode: CullMode = CullMode.Back; /** The multiplier by which an implementation-specific value is multiplied with to create a constant depth offset. */ diff --git a/packages/core/src/shader/state/RenderState.ts b/packages/core/src/shader/state/RenderState.ts index 502bfd7d83..9f67e4b37a 100644 --- a/packages/core/src/shader/state/RenderState.ts +++ b/packages/core/src/shader/state/RenderState.ts @@ -1,7 +1,7 @@ +import { DataObject } from "../../base/DataObject"; import { ShaderData, ShaderProperty } from ".."; import { RenderStateElementMap } from "../../BasicResources"; import { Engine } from "../../Engine"; -import { deepClone } from "../../clone/CloneManager"; import { RenderQueueType } from "../enums/RenderQueueType"; import { RenderStateElementKey } from "../enums/RenderStateElementKey"; import { BlendState } from "./BlendState"; @@ -12,18 +12,14 @@ import { StencilState } from "./StencilState"; /** * Render state. */ -export class RenderState { +export class RenderState extends DataObject { /** Blend state. */ - @deepClone readonly blendState: BlendState = new BlendState(); /** Depth state. */ - @deepClone readonly depthState: DepthState = new DepthState(); /** Stencil state. */ - @deepClone readonly stencilState: StencilState = new StencilState(); /** Raster state. */ - @deepClone readonly rasterState: RasterState = new RasterState(); /** Render queue type. */ diff --git a/packages/core/src/shader/state/RenderTargetBlendState.ts b/packages/core/src/shader/state/RenderTargetBlendState.ts index 88e3d3f62e..cdb32b6c17 100644 --- a/packages/core/src/shader/state/RenderTargetBlendState.ts +++ b/packages/core/src/shader/state/RenderTargetBlendState.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { BlendOperation } from "../enums/BlendOperation"; import { BlendFactor } from "../enums/BlendFactor"; import { ColorWriteMask } from "../enums/ColorWriteMask"; @@ -5,7 +6,7 @@ import { ColorWriteMask } from "../enums/ColorWriteMask"; /** * The blend state of the render target. */ -export class RenderTargetBlendState { +export class RenderTargetBlendState extends DataObject { /** Whether to enable blend. */ enabled: boolean = false; /** color (RGB) blend operation. */ diff --git a/packages/core/src/shader/state/StencilState.ts b/packages/core/src/shader/state/StencilState.ts index a479c2fcde..9a77ec38cf 100644 --- a/packages/core/src/shader/state/StencilState.ts +++ b/packages/core/src/shader/state/StencilState.ts @@ -1,3 +1,4 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; import { ShaderData } from "../ShaderData"; @@ -10,7 +11,7 @@ import { RenderState } from "./RenderState"; /** * Stencil state. */ -export class StencilState { +export class StencilState extends DataObject { private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number { const gl = rhi.gl; diff --git a/packages/core/src/trail/TrailRenderer.ts b/packages/core/src/trail/TrailRenderer.ts index 6485d658ca..a1fc73e28a 100644 --- a/packages/core/src/trail/TrailRenderer.ts +++ b/packages/core/src/trail/TrailRenderer.ts @@ -2,7 +2,7 @@ import { BoundingBox, Color, Vector2, Vector3, Vector4 } from "@galacean/engine- import { Entity } from "../Entity"; import { RenderContext } from "../RenderPipeline/RenderContext"; import { Renderer, RendererUpdateFlags } from "../Renderer"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { Buffer } from "../graphic/Buffer"; import { Primitive } from "../graphic/Primitive"; import { SubPrimitive } from "../graphic/SubPrimitive"; @@ -47,20 +47,16 @@ export class TrailRenderer extends Renderer { minVertexDistance = 0.1; /** The curve describing the trail width from start to end. */ - @deepClone widthCurve = new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 1)); /** The gradient describing the trail color from start to end. */ - @deepClone colorGradient = new ParticleGradient( [new GradientColorKey(0, new Color(1, 1, 1, 1)), new GradientColorKey(1, new Color(1, 1, 1, 1))], [new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)] ); // Shader parameters - @deepClone private _trailParams = new Vector4(TrailTextureMode.Stretch, 1.0, 1.0, 0); // x: textureMode, y: textureScaleX, z: textureScaleY - @deepClone private _textureScale = new Vector2(1.0, 1.0); @ignoreClone private _distanceParams = new Vector2(); // x: headDistance, y: tailDistance diff --git a/packages/core/src/ui/UIUtils.ts b/packages/core/src/ui/UIUtils.ts index a61a7d54d3..e62a75b464 100644 --- a/packages/core/src/ui/UIUtils.ts +++ b/packages/core/src/ui/UIUtils.ts @@ -1,14 +1,20 @@ -import { Matrix, Vector4 } from "@galacean/engine-math"; +import { Color, Matrix, Vector4 } from "@galacean/engine-math"; import { Camera } from "../Camera"; import { Engine } from "../Engine"; import { Layer } from "../Layer"; +import { Blitter } from "../RenderPipeline/Blitter"; import { RenderQueue } from "../RenderPipeline"; import { ContextRendererUpdateFlag } from "../RenderPipeline/RenderContext"; import { Scene } from "../Scene"; import { VirtualCamera } from "../VirtualCamera"; import { EngineObject } from "../base"; -import { RenderQueueType, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; +import { CameraClearFlags } from "../enums/CameraClearFlags"; +import { Material } from "../material"; +import { RenderQueueType, Shader, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; import { ShaderMacroCollection } from "../shader/ShaderMacroCollection"; +import { RenderTarget } from "../texture/RenderTarget"; +import { Texture2D } from "../texture/Texture2D"; +import { TextureFormat } from "../texture/enums/TextureFormat"; import { DisorderedArray } from "../utils/DisorderedArray"; import { IUICanvas } from "./IUICanvas"; @@ -19,6 +25,11 @@ export class UIUtils { private static _virtualCamera: VirtualCamera; private static _viewport: Vector4; private static _overlayCamera: OverlayCamera; + private static _overlayRT: RenderTarget; + private static _overlayBlitMaterial: Material; + private static _clearColor = new Color(0, 0, 0, 0); + /** Flip V so that Y-up RT content maps correctly onto the default framebuffer. */ + private static _flipYScaleOffset = new Vector4(1, -1, 0, 1); static renderOverlay(engine: Engine, scene: Scene, uiCanvases: DisorderedArray): void { engine._macroCollection.enable(UIUtils._shouldSRGBCorrect); @@ -31,10 +42,19 @@ export class UIUtils { camera.engine = engine; camera.scene = scene; renderContext.camera = camera as unknown as Camera; + + const { width, height } = canvas; const { elements: projectE } = virtualCamera.projectionMatrix; const { elements: viewE } = virtualCamera.viewMatrix; - (projectE[0] = 2 / canvas.width), (projectE[5] = 2 / canvas.height), (projectE[10] = 0); - renderContext.setRenderTarget(null, viewport, 0); + (projectE[0] = 2 / width), (projectE[5] = 2 / height), (projectE[10] = 0); + + // Render to an intermediate RT with Depth24Stencil8 so that stencil-based UI Mask works. + // The default canvas framebuffer is created without a stencil buffer + // (see WebGLGraphicDevice._webGLOptions.stencil = false). + const overlayRT = UIUtils._getOverlayRT(engine, width, height); + renderContext.setRenderTarget(overlayRT, viewport, 0); + rhi.clearRenderTarget(engine, CameraClearFlags.All, UIUtils._clearColor); + for (let i = 0, n = uiCanvases.length; i < n; i++) { const uiCanvas = uiCanvases.get(i); if (uiCanvas) { @@ -55,9 +75,51 @@ export class UIUtils { engine._renderCount++; } } + + // Blit overlay RT to default framebuffer with premultiplied alpha blending. + // Blitter.blitTexture picks the non-flipping `blitMesh` when destination is null, + // but the RT contents are written in standard Y-up NDC, so we flip V here via + // sourceScaleOffset to match the default framebuffer orientation. + Blitter.blitTexture( + engine, + overlayRT.getColorTexture(0) as Texture2D, + null, + 0, + viewport, + UIUtils._getOverlayBlitMaterial(engine), + 0, + UIUtils._flipYScaleOffset + ); + renderContext.camera = null; engine._macroCollection.disable(UIUtils._shouldSRGBCorrect); } + + private static _getOverlayRT(engine: Engine, width: number, height: number): RenderTarget { + let rt = UIUtils._overlayRT; + if (!rt || rt.width !== width || rt.height !== height) { + if (rt) { + rt.getColorTexture(0).destroy(); + rt.destroy(); + } + const colorTexture = new Texture2D(engine, width, height, TextureFormat.R8G8B8A8, false); + colorTexture.isGCIgnored = true; + rt = new RenderTarget(engine, width, height, colorTexture, TextureFormat.Depth24Stencil8); + rt.isGCIgnored = true; + UIUtils._overlayRT = rt; + } + return rt; + } + + private static _getOverlayBlitMaterial(engine: Engine): Material { + let material = UIUtils._overlayBlitMaterial; + if (!material) { + material = new Material(engine, Shader.find("2D/UIOverlayBlit")); + material.isGCIgnored = true; + UIUtils._overlayBlitMaterial = material; + } + return material; + } } class OverlayCamera { diff --git a/packages/design/package.json b/packages/design/package.json index ca24c7f69b..647c14da4f 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-design", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/design/src/physics/IPhysics.ts b/packages/design/src/physics/IPhysics.ts index 17c9b6b466..91a6c1c7c6 100644 --- a/packages/design/src/physics/IPhysics.ts +++ b/packages/design/src/physics/IPhysics.ts @@ -36,6 +36,16 @@ export interface IPhysics { */ createPhysicsScene(physicsManager: IPhysicsManager): IPhysicsScene; + /** + * Get the default contact offset for collider shapes. + */ + getDefaultContactOffset?(): number; + + /** + * Get the default sleep threshold for dynamic colliders. + */ + getDefaultSleepThreshold?(): number; + /** * Create dynamic collider. * @param position - The global position diff --git a/packages/design/src/physics/IPhysicsScene.ts b/packages/design/src/physics/IPhysicsScene.ts index 5520114104..51bba67a57 100644 --- a/packages/design/src/physics/IPhysicsScene.ts +++ b/packages/design/src/physics/IPhysicsScene.ts @@ -43,6 +43,12 @@ export interface IPhysicsScene { */ update(elapsedTime: number): void; + /** + * Enable contact event buffering. + * @param enabled - Whether collision contact events should be buffered for dispatch. + */ + setContactEventEnabled?(enabled: boolean): void; + /** * Collect buffered collision and trigger events. * Must be called after update() and after syncing transforms back from physics. diff --git a/packages/galacean/package.json b/packages/galacean/package.json index 127e4d0466..581e6ad043 100644 --- a/packages/galacean/package.json +++ b/packages/galacean/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/galacean/src/ShaderPool.ts b/packages/galacean/src/ShaderPool.ts index 52878d4d3f..8567f9ac03 100644 --- a/packages/galacean/src/ShaderPool.ts +++ b/packages/galacean/src/ShaderPool.ts @@ -8,7 +8,9 @@ import { SpriteMaskSource, TextSource, TrailSource, + UIOverlayBlitSource, UIDefaultSource, + SpineSource, SkyboxSource, BackgroundTextureSource, SkyProceduralSource, @@ -65,7 +67,9 @@ export class ShaderPool { SpriteMaskSource, TextSource, TrailSource, + UIOverlayBlitSource, UIDefaultSource, + SpineSource, // Particle shaders ParticleSource, ParticleFeedbackSource, diff --git a/packages/loader/package.json b/packages/loader/package.json index 87532cb158..bcec44a97a 100644 --- a/packages/loader/package.json +++ b/packages/loader/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-loader", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/loader/src/SceneLoader.ts b/packages/loader/src/SceneLoader.ts index 7c94a82109..5f1975cec6 100644 --- a/packages/loader/src/SceneLoader.ts +++ b/packages/loader/src/SceneLoader.ts @@ -143,6 +143,15 @@ export function applySceneData( if (fog.fogColor) scene.fogColor.set(fog.fogColor[0], fog.fogColor[1], fog.fogColor[2], fog.fogColor[3]); } + // Parse physics only when the engine has a native physics backend. Render-only + // engines should remain able to load scene files that carry physics settings. + const physics = sceneData.physics; + if (physics && (scene.engine as any)._physicsInitialized) { + const gravity = physics.gravity; + if (gravity) scene.physics.gravity.set(gravity[0], gravity[1], gravity[2]); + if (physics.fixedTimeStep != undefined) scene.physics.fixedTimeStep = physics.fixedTimeStep; + } + // Post Process if (sceneData.postProcess) { Logger.warn("Post Process is not supported in scene yet, please add PostProcess component in entity instead."); @@ -167,7 +176,7 @@ export function applySceneData( return Promise.all(promises).then(() => {}); } -@resourceLoader(AssetType.Scene, ["scene"], true) +@resourceLoader(AssetType.Scene, ["scene"], false) class SceneLoader extends Loader { load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { const { engine } = resourceManager; diff --git a/packages/loader/src/gltf/GLTFResource.ts b/packages/loader/src/gltf/GLTFResource.ts index 26c7768708..7edffea503 100644 --- a/packages/loader/src/gltf/GLTFResource.ts +++ b/packages/loader/src/gltf/GLTFResource.ts @@ -59,10 +59,29 @@ export class GLTFResource extends ReferResource { * @returns Root entity */ instantiateSceneRoot(sceneIndex?: number): Entity { + if ( + sceneIndex !== undefined && + (!Number.isSafeInteger(sceneIndex) || sceneIndex < 0 || sceneIndex >= this._sceneRoots.length) + ) { + throw new RangeError( + `GLTFResource: scene index ${sceneIndex} is out of range for "${this.url}" (${this._sceneRoots.length} scenes).` + ); + } const sceneRoot = sceneIndex === undefined ? this._defaultSceneRoot : this._sceneRoots[sceneIndex]; + if (!sceneRoot) { + throw new Error(`GLTFResource: no default scene is available for "${this.url}".`); + } return sceneRoot.clone(); } + /** + * Scene root templates indexed by the glTF `scenes` array. + * @remarks This is also the canonical sub-asset query surface, for example `?q=scenes[0]`. + */ + get scenes(): ReadonlyArray { + return this._sceneRoots; + } + protected override _onDestroy(): void { super._onDestroy(); diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index b549da5ca2..bf99dcf09f 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -194,16 +194,22 @@ export class GLTFParserContext { this.resourceManager._onSubAssetSuccess(url, `${glTFResourceKey}[${index}][${i}]`, mesh); } } else { + const subAssetResourceKey = type === GLTFParserType.Scene ? "scenes" : glTFResourceKey; // @ts-ignore this.resourceManager._onSubAssetSuccess( url, - `${glTFResourceKey}${index === undefined ? "" : `[${index}]`}`, + `${subAssetResourceKey}${index === undefined ? "" : `[${index}]`}`, item ); - if (type === GLTFParserType.Scene && (this.glTF.scene ?? 0) === index) { + if (type === GLTFParserType.Scene) { + // Keep the historical internal query key while exposing the canonical glTF schema key above. // @ts-ignore - this.resourceManager._onSubAssetSuccess(url, `defaultSceneRoot`, item as Entity); + this.resourceManager._onSubAssetSuccess(url, `_sceneRoots[${index}]`, item as Entity); + if ((this.glTF.scene ?? 0) === index) { + // @ts-ignore + this.resourceManager._onSubAssetSuccess(url, `defaultSceneRoot`, item as Entity); + } } } }) diff --git a/packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts b/packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts index 2216ea3b42..3512ca3813 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts @@ -17,6 +17,31 @@ import type { HierarchyFile } from "../../../schema/HierarchySchema"; import { ParserContext, ParserType } from "./ParserContext"; import { ReflectionParser } from "./ReflectionParser"; +interface GLTFSceneSelection { + sceneIndex?: number; +} + +function parseGLTFSceneSelection(key: string | undefined): GLTFSceneSelection | undefined { + if (key === "defaultSceneRoot") { + return {}; + } + + const prefix = "scenes["; + if (!key?.startsWith(prefix)) { + return undefined; + } + if (!key.endsWith("]")) { + throw new Error(`HierarchyParser: invalid glTF scene key "${key}". Expected "scenes[n]".`); + } + + const indexText = key.slice(prefix.length, -1); + const sceneIndex = Number(indexText); + if (!Number.isSafeInteger(sceneIndex) || sceneIndex < 0 || String(sceneIndex) !== indexText) { + throw new Error(`HierarchyParser: invalid glTF scene key "${key}". Expected "scenes[n]".`); + } + return { sceneIndex }; +} + /** @Internal */ export abstract class HierarchyParser { readonly promise: Promise; @@ -265,21 +290,31 @@ export abstract class HierarchyParser { const instance = entityConfig.instance; let refItem: RefItem; + let glTFSceneSelection: GLTFSceneSelection | undefined; try { refItem = resolveRefItem(this.data.refs, instance.asset, "HierarchyParser", "instance.asset"); + glTFSceneSelection = parseGLTFSceneSelection(refItem.key); } catch (error) { return Promise.reject(error); } + const resourceRef = glTFSceneSelection ? { url: refItem.url } : refItem; return ( engine.resourceManager // @ts-ignore - .getResourceByRef(refItem) - .then((prefabResource: PrefabResource | GLTFResource) => { - const entity = - prefabResource instanceof PrefabResource - ? prefabResource.instantiate() - : prefabResource.instantiateSceneRoot(); + .getResourceByRef(resourceRef) + .then((resource: PrefabResource | GLTFResource) => { + let entity: Entity; + if (resource instanceof PrefabResource) { + if (glTFSceneSelection) { + throw new Error(`HierarchyParser: glTF scene key "${refItem.key}" resolved to a prefab resource.`); + } + entity = resource.instantiate(); + } else if (resource instanceof GLTFResource) { + entity = resource.instantiateSceneRoot(glTFSceneSelection?.sceneIndex); + } else { + throw new Error(`HierarchyParser: instance asset "${refItem.url}" is not a prefab or glTF resource.`); + } this._onEntityCreated(entity); return entity; }) diff --git a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts index 2e038238ce..47591cf90d 100644 --- a/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts +++ b/packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts @@ -192,7 +192,12 @@ export class ReflectionParser { const buffer = ReflectionParser._componentBuffer; buffer.length = 0; entity.getComponents(type, buffer); - const result = buffer[comp.index] ?? null; + let result = buffer[comp.index] ?? null; + if (!result) { + buffer.length = 0; + entity.getComponentsIncludeChildren(type, buffer); + result = buffer[comp.index] ?? null; + } buffer.length = 0; return result; } diff --git a/packages/loader/src/schema/SceneSchema.ts b/packages/loader/src/schema/SceneSchema.ts index 659f4b6186..c63fbb57fb 100644 --- a/packages/loader/src/schema/SceneSchema.ts +++ b/packages/loader/src/schema/SceneSchema.ts @@ -52,6 +52,10 @@ export interface SceneFile extends HierarchyFile { fogDensity?: number; fogColor?: Vec4Tuple; }; + physics?: { + gravity?: Vec3Tuple; + fixedTimeStep?: number; + }; ambientOcclusion?: { enabledAmbientOcclusion?: boolean; quality?: number; diff --git a/packages/math/package.json b/packages/math/package.json index 96855d8329..5d56418130 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-math", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/math/src/Ray.ts b/packages/math/src/Ray.ts index 80ba2e2f64..075a55bf6a 100644 --- a/packages/math/src/Ray.ts +++ b/packages/math/src/Ray.ts @@ -1,13 +1,15 @@ import { BoundingBox } from "./BoundingBox"; import { BoundingSphere } from "./BoundingSphere"; import { CollisionUtil } from "./CollisionUtil"; +import { IClone } from "./IClone"; +import { ICopy } from "./ICopy"; import { Plane } from "./Plane"; import { Vector3 } from "./Vector3"; /** * Represents a ray with an origin and a direction in 3D space. */ -export class Ray { +export class Ray implements IClone, ICopy { /** The origin of the ray. */ readonly origin: Vector3 = new Vector3(); /** The normalized direction of the ray. */ @@ -60,4 +62,25 @@ export class Ray { Vector3.scale(this.direction, distance, out); return out.add(this.origin); } + + /** + * Creates a clone of this ray. + * @returns A clone of this ray + */ + clone(): Ray { + const out = new Ray(); + out.copyFrom(this); + return out; + } + + /** + * Copy this ray from the specified ray. + * @param source - The specified ray + * @returns This ray + */ + copyFrom(source: Ray): Ray { + this.origin.copyFrom(source.origin); + this.direction.copyFrom(source.direction); + return this; + } } diff --git a/packages/physics-physx/package.json b/packages/physics-physx/package.json index 9ef5fe570e..ddc062e737 100644 --- a/packages/physics-physx/package.json +++ b/packages/physics-physx/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-physics-physx", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/physics-physx/src/PhysXCharacterController.ts b/packages/physics-physx/src/PhysXCharacterController.ts index 38b5228062..a56077b596 100644 --- a/packages/physics-physx/src/PhysXCharacterController.ts +++ b/packages/physics-physx/src/PhysXCharacterController.ts @@ -94,7 +94,7 @@ export class PhysXCharacterController implements ICharacterController { this._pxManager && this._createPXController(this._pxManager, shape); this._shape = shape; shape._controllers.add(this); - this._pxController?.setContactOffset(shape._contractOffset); + this._pxController?.setContactOffset(shape._contactOffset); this._scene?._addColliderShape(shape._id); } diff --git a/packages/physics-physx/src/PhysXDynamicCollider.ts b/packages/physics-physx/src/PhysXDynamicCollider.ts index 207795ec32..0d9364723b 100644 --- a/packages/physics-physx/src/PhysXDynamicCollider.ts +++ b/packages/physics-physx/src/PhysXDynamicCollider.ts @@ -24,6 +24,20 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli private static _tempTranslation = new Vector3(); private static _tempRotation = new Quaternion(); + /** + * Whether actor is currently kinematic. + * PhysX 拒绝在 kinematic actor 上启用 CCD(会打印警告并忽略), + * 所以 setCollisionDetectionMode 在 kinematic 状态下只缓存目标值, + * 等切回 dynamic 时再真正写到 PhysX。 + */ + private _isKinematic: boolean = false; + + /** + * Cached collision detection mode. Always reflects user's intent. + * 实际 PhysX CCD flag 可能跟这个不一致(kinematic 时强制 Discrete)。 + */ + private _collisionDetectionMode: number = CollisionDetectionMode.Discrete; + constructor(physXPhysics: PhysXPhysics, position: Vector3, rotation: Quaternion) { super(physXPhysics); const transform = this._transform(position, rotation); @@ -143,7 +157,7 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.setSleepThreshold } - * @default 1e-5f * PxTolerancesScale::speed * PxTolerancesScale::speed + * @default 5e-5f * PxTolerancesScale::speed * PxTolerancesScale::speed */ setSleepThreshold(value: number): void { this._pxActor.setSleepThreshold(value); @@ -158,10 +172,52 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.setCollisionDetectionMode } + * + * PhysX 在 kinematic actor 上调用 setRigidBodyFlag(eENABLE_CCD, true) 会触发警告: + * "kinematic bodies with CCD enabled are not supported! CCD will be ignored" + * 虽然 PhysX 会忽略这次调用而非真的拒绝(切回 dynamic 时 flag 不会自动恢复), + * 但每次 setIsKinematic 切换都会让这个 warning 重复打印,污染日志, + * 同时让 actor 在 dynamic 状态下 CCD flag 状态不确定。 + * + * 解决: 只在 dynamic 状态时立即 apply CCD flags。kinematic 时仅缓存到 + * `_collisionDetectionMode`,等切回 dynamic 时由 setIsKinematic 重新 apply。 */ setCollisionDetectionMode(value: number): void { + this._collisionDetectionMode = value; + if (!this._isKinematic) { + this._applyCollisionDetectionFlags(value); + } + } + + /** + * {@inheritDoc IDynamicCollider.setUseGravity } + */ + setUseGravity(value: boolean): void { + this._pxActor.setActorFlag(this._physXPhysics._physX.PxActorFlag.eDISABLE_GRAVITY, !value); + } + + /** + * {@inheritDoc IDynamicCollider.setIsKinematic } + * + * 切换 kinematic 状态时同步处理 CCD flag: + * - 切到 kinematic 前先关 CCD(避免 PhysX 警告 + 让状态显式) + * - 切回 dynamic 后恢复用户期望的 CCD mode(来自 `_collisionDetectionMode` 缓存) + */ + setIsKinematic(value: boolean): void { + if (this._isKinematic === value) return; const physX = this._physXPhysics._physX; + if (value) { + this._applyCollisionDetectionFlags(CollisionDetectionMode.Discrete); + this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eKINEMATIC, true); + } else { + this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eKINEMATIC, false); + this._applyCollisionDetectionFlags(this._collisionDetectionMode); + } + this._isKinematic = value; + } + private _applyCollisionDetectionFlags(value: number): void { + const physX = this._physXPhysics._physX; switch (value) { case CollisionDetectionMode.Continuous: this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eENABLE_CCD, true); @@ -186,24 +242,6 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli } } - /** - * {@inheritDoc IDynamicCollider.setUseGravity } - */ - setUseGravity(value: boolean): void { - this._pxActor.setActorFlag(this._physXPhysics._physX.PxActorFlag.eDISABLE_GRAVITY, !value); - } - - /** - * {@inheritDoc IDynamicCollider.setIsKinematic } - */ - setIsKinematic(value: boolean): void { - if (value) { - this._pxActor.setRigidBodyFlag(this._physXPhysics._physX.PxRigidBodyFlag.eKINEMATIC, true); - } else { - this._pxActor.setRigidBodyFlag(this._physXPhysics._physX.PxRigidBodyFlag.eKINEMATIC, false); - } - } - /** * {@inheritDoc IDynamicCollider.setConstraints } */ @@ -213,8 +251,15 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.addForce } + * + * PhysX 在 kinematic actor 上调 addForce 是 no-op(doc: "kinematic bodies don't + * respond to forces")。提前 return 避免无意义的 wasm boundary cross。 + * + * Sleeping actor 不需要显式 wakeUp — wasm binding 调用 `addForce(force, eFORCE, + * autowake=true)`,PhysX 自动唤醒(已通过 `applyForce on sleeping actor` 测试验证)。 */ addForce(force: Vector3) { + if (this._isKinematic) return; this._pxActor.addForce(force); } @@ -227,27 +272,38 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.addTorque } + * + * 同 addForce — kinematic 提前 return,sleeping 由 PhysX autowake 自动处理。 */ addTorque(torque: Vector3) { + if (this._isKinematic) return; this._pxActor.addTorque(torque); } /** * {@inheritDoc IDynamicCollider.move } + * + * PhysX 要求 setKinematicTarget 的 rotation 是 normalized quaternion,否则会触发 + * 内部 assertion / 警告,并把 actor 转到错误的姿态。所以在写入 wasm 边界前统一 normalize。 */ move(positionOrRotation: Vector3 | Quaternion, rotation?: Quaternion): void { + const tempTranslation = PhysXDynamicCollider._tempTranslation; + const tempRotation = PhysXDynamicCollider._tempRotation; + if (rotation) { - this._pxActor.setKinematicTarget(positionOrRotation, rotation); + tempRotation.copyFrom(rotation).normalize(); + this._pxActor.setKinematicTarget(positionOrRotation, tempRotation); return; } - const tempTranslation = PhysXDynamicCollider._tempTranslation; - const tempRotation = PhysXDynamicCollider._tempRotation; - this.getWorldTransform(tempTranslation, tempRotation); if (positionOrRotation instanceof Vector3) { + this.getWorldTransform(tempTranslation, tempRotation); + // current rotation read from PhysX is already normalized; no extra work needed this._pxActor.setKinematicTarget(positionOrRotation, tempRotation); } else { - this._pxActor.setKinematicTarget(tempTranslation, positionOrRotation); + this.getWorldTransform(tempTranslation, tempRotation); + tempRotation.copyFrom(positionOrRotation).normalize(); + this._pxActor.setKinematicTarget(tempTranslation, tempRotation); } } diff --git a/packages/physics-physx/src/PhysXPhysics.ts b/packages/physics-physx/src/PhysXPhysics.ts index 8bcf8d510a..577f5644e3 100644 --- a/packages/physics-physx/src/PhysXPhysics.ts +++ b/packages/physics-physx/src/PhysXPhysics.ts @@ -57,13 +57,26 @@ export class PhysXPhysics implements IPhysics { private _tolerancesScale: any; private _wasmSIMDModeUrl: string; private _wasmModeUrl: string; + private _tolerancesScaleOptions: PhysXTolerancesScale | undefined; + private _defaultContactOffset = 0.02; + private _defaultSleepThreshold = 5e-3; /** * Create a PhysXPhysics instance. * @param runtimeMode - Runtime mode, `Auto` prefers WebAssembly SIMD if supported @see {@link PhysXRuntimeMode} * @param runtimeUrls - Manually specify the runtime URLs + * @param options - PhysX options. */ - constructor(runtimeMode: PhysXRuntimeMode = PhysXRuntimeMode.Auto, runtimeUrls?: PhysXRuntimeUrls) { + constructor(runtimeMode?: PhysXRuntimeMode, runtimeUrls?: PhysXRuntimeUrls, options?: PhysXPhysicsOptions); + constructor(options?: PhysXPhysicsOptions); + constructor( + runtimeModeOrOptions: PhysXRuntimeMode | PhysXPhysicsOptions = PhysXRuntimeMode.Auto, + runtimeUrls?: PhysXRuntimeUrls, + options?: PhysXPhysicsOptions + ) { + const isOptionsObject = typeof runtimeModeOrOptions === "object"; + const runtimeMode = isOptionsObject ? PhysXRuntimeMode.Auto : (runtimeModeOrOptions ?? PhysXRuntimeMode.Auto); + const resolvedOptions = isOptionsObject ? runtimeModeOrOptions : options; this._runTimeMode = runtimeMode; this._wasmSIMDModeUrl = runtimeUrls?.wasmSIMDModeUrl ?? @@ -71,6 +84,11 @@ export class PhysXPhysics implements IPhysics { this._wasmModeUrl = runtimeUrls?.wasmModeUrl ?? "https://mdn.alipayobjects.com/rms/uri/file/as/apwallet/1781696156399/suyi/physx.release.js"; + this._tolerancesScaleOptions = resolvedOptions?.tolerancesScale; + const length = this._tolerancesScaleOptions?.length ?? 1; + const speed = this._tolerancesScaleOptions?.speed ?? 10; + this._validateTolerancesScale(length, speed); + this._updateScaledDefaults(length, speed); } /** @@ -156,6 +174,20 @@ export class PhysXPhysics implements IPhysics { return scene; } + /** + * {@inheritDoc IPhysics.getDefaultContactOffset } + */ + getDefaultContactOffset(): number { + return this._defaultContactOffset; + } + + /** + * {@inheritDoc IPhysics.getDefaultSleepThreshold } + */ + getDefaultSleepThreshold(): number { + return this._defaultSleepThreshold; + } + /** * {@inheritDoc IPhysics.createStaticCollider } */ @@ -279,6 +311,7 @@ export class PhysXPhysics implements IPhysics { const allocator = new physX.PxDefaultAllocator(); const pxFoundation = physX.PxCreateFoundation(version, allocator, defaultErrorCallback); const tolerancesScale = new physX.PxTolerancesScale(); + this._applyTolerancesScale(tolerancesScale); const pxPhysics = physX.PxCreatePhysics(version, pxFoundation, tolerancesScale, false, null); physX.PxInitExtensions(pxPhysics, null); @@ -302,6 +335,33 @@ export class PhysXPhysics implements IPhysics { this._allocator = allocator; this._tolerancesScale = tolerancesScale; } + + private _applyTolerancesScale(tolerancesScale: any): void { + const length = this._tolerancesScaleOptions?.length ?? tolerancesScale.length; + const speed = this._tolerancesScaleOptions?.speed ?? tolerancesScale.speed; + + this._validateTolerancesScale(length, speed); + + tolerancesScale.length = length; + tolerancesScale.speed = speed; + this._updateScaledDefaults(length, speed); + } + + private _assertPositiveFinite(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`PhysXPhysics ${name} must be a positive finite number.`); + } + } + + private _validateTolerancesScale(length: number, speed: number): void { + this._assertPositiveFinite(length, "tolerancesScale.length"); + this._assertPositiveFinite(speed, "tolerancesScale.speed"); + } + + private _updateScaledDefaults(length: number, speed: number): void { + this._defaultContactOffset = 0.02 * length; + this._defaultSleepThreshold = 5e-5 * speed * speed; + } } enum InitializeState { @@ -316,3 +376,15 @@ interface PhysXRuntimeUrls { /*** The URL of `PhysXRuntimeMode.WebAssemblySIMD` mode. */ wasmSIMDModeUrl?: string; } + +export interface PhysXTolerancesScale { + /** Approximate object length in the simulation unit. PhysX default is 1. */ + length?: number; + /** Typical object speed in the simulation unit. PhysX default is 10. */ + speed?: number; +} + +export interface PhysXPhysicsOptions { + /** PhysX world unit scale used before PxPhysics, PxSceneDesc and PxCookingParams are created. */ + tolerancesScale?: PhysXTolerancesScale; +} diff --git a/packages/physics-physx/src/PhysXPhysicsScene.ts b/packages/physics-physx/src/PhysXPhysicsScene.ts index d026304e4d..bee2ab2dc2 100644 --- a/packages/physics-physx/src/PhysXPhysicsScene.ts +++ b/packages/physics-physx/src/PhysXPhysicsScene.ts @@ -50,6 +50,7 @@ export class PhysXPhysicsScene implements IPhysicsScene { private _activeTriggers: DisorderedArray = new DisorderedArray(); private _contactEvents: ContactEvent[] = []; private _contactEventCount = 0; + private _contactEventEnabled = true; private _triggerEvents: TriggerEvent[] = []; private _physicsEvents: IPhysicsEvents = { contactEvents: [], contactEventCount: 0, triggerEvents: [] }; @@ -191,6 +192,20 @@ export class PhysXPhysicsScene implements IPhysicsScene { this._fetchResults(); } + /** + * {@inheritDoc IPhysicsScene.setContactEventEnabled } + */ + setContactEventEnabled(enabled: boolean): void { + if (this._contactEventEnabled === enabled) { + return; + } + + this._contactEventEnabled = enabled; + if (!enabled) { + this._contactEventCount = 0; + } + } + /** * {@inheritDoc IPhysicsScene.updateEvents } */ @@ -547,6 +562,8 @@ export class PhysXPhysicsScene implements IPhysicsScene { } private _bufferContactEvent(collision: ICollision, state: number): void { + if (!this._contactEventEnabled) return; + const index = this._contactEventCount++; const event = (this._contactEvents[index] ||= new ContactEvent()); event.shape0Id = collision.shape0Id; diff --git a/packages/physics-physx/src/index.ts b/packages/physics-physx/src/index.ts index aa3dd875c3..1798c64b70 100644 --- a/packages/physics-physx/src/index.ts +++ b/packages/physics-physx/src/index.ts @@ -1,4 +1,5 @@ export { PhysXPhysics } from "./PhysXPhysics"; +export type { PhysXPhysicsOptions, PhysXTolerancesScale } from "./PhysXPhysics"; export { PhysXRuntimeMode } from "./enum/PhysXRuntimeMode"; //@ts-ignore diff --git a/packages/physics-physx/src/shape/PhysXColliderShape.ts b/packages/physics-physx/src/shape/PhysXColliderShape.ts index ea5a3df8b4..9d782f73fb 100644 --- a/packages/physics-physx/src/shape/PhysXColliderShape.ts +++ b/packages/physics-physx/src/shape/PhysXColliderShape.ts @@ -31,7 +31,7 @@ export abstract class PhysXColliderShape implements IColliderShape { /** @internal */ _controllers: DisorderedArray = new DisorderedArray(); /** @internal */ - _contractOffset: number = 0.02; + _contactOffset: number = 0.02; /** @internal */ _worldScale: Vector3 = new Vector3(1, 1, 1); @@ -56,6 +56,7 @@ export abstract class PhysXColliderShape implements IColliderShape { constructor(physXPhysics: PhysXPhysics) { this._physXPhysics = physXPhysics; + this._contactOffset = physXPhysics.getDefaultContactOffset(); } /** @@ -106,7 +107,7 @@ export abstract class PhysXColliderShape implements IColliderShape { * @default 0.02f * PxTolerancesScale::length */ setContactOffset(offset: number): void { - this._contractOffset = offset; + this._contactOffset = offset; const controllers = this._controllers; if (controllers.length) { for (let i = 0, n = controllers.length; i < n; i++) { diff --git a/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts b/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts index 9a25c2fd89..835c331cb0 100644 --- a/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts +++ b/packages/physics-physx/src/shape/PhysXMeshColliderShape.ts @@ -25,7 +25,8 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC super(physXPhysics); this._isConvex = isConvex; - if (!this._cookMesh(positions, indices, cookingFlags)) { + const cooked = this._cookMesh(positions, indices, isConvex, cookingFlags); + if (!cooked) { return; } @@ -36,7 +37,7 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC const createShapeFn = isConvex ? physX.createConvexMeshShape : physX.createTriMeshShape; this._pxShape = createShapeFn( - this._pxMesh, + cooked.mesh, scaleX, scaleY, scaleZ, @@ -48,6 +49,8 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC this._id = uniqueID; this._pxMaterial = material._pxMaterial; + this._pxMesh = cooked.mesh; + this._pxGeometry = cooked.geometry; this._pxShape.setUUID(uniqueID); this._setLocalPose(); } @@ -61,17 +64,28 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC isConvex: boolean, cookingFlags: number ): boolean { - this._pxMesh?.release(); - this._pxGeometry?.delete(); - this._pxMesh = null; - this._pxGeometry = null; - this._isConvex = isConvex; - - if (!this._cookMesh(positions, indices, cookingFlags)) { + const cooked = this._cookMesh(positions, indices, isConvex, cookingFlags); + if (!cooked) { return false; } - this._pxShape.setGeometry(this._pxGeometry); + const oldMesh = this._pxMesh; + const oldGeometry = this._pxGeometry; + + try { + this._pxShape.setGeometry(cooked.geometry); + } catch (error) { + cooked.mesh?.release(); + cooked.geometry?.delete(); + throw error; + } + + this._pxMesh = cooked.mesh; + this._pxGeometry = cooked.geometry; + this._isConvex = isConvex; + + oldMesh?.release(); + oldGeometry?.delete(); return true; } @@ -94,8 +108,9 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC private _cookMesh( positions: Vector3[], indices: Uint8Array | Uint16Array | Uint32Array | null, + isConvex: boolean, cookingFlags: number - ): boolean { + ): { mesh: any; geometry: any } | null { const { _physX: physX, _pxPhysics: physics, @@ -115,48 +130,42 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC cooking.setParams(cookingParams); const verticesPtr = this._allocatePositions(positions); + let pxMesh: any = null; - if (this._isConvex) { - this._pxMesh = cooking.createConvexMesh(verticesPtr, positions.length, physics); + if (isConvex) { + pxMesh = cooking.createConvexMesh(verticesPtr, positions.length, physics); physX._free(verticesPtr); - if (!this._pxMesh) { + if (!pxMesh) { this._logConvexCookingError(physX); - return false; + return null; } } else { if (!indices) { physX._free(verticesPtr); console.error("PhysXMeshColliderShape: Triangle mesh requires indices."); - return false; + return null; } const isU32 = indices instanceof Uint32Array; const indicesPtr = this._allocateIndices(indices, isU32); - this._pxMesh = cooking.createTriMesh( - verticesPtr, - positions.length, - indicesPtr, - indices.length / 3, - !isU32, - physics - ); + pxMesh = cooking.createTriMesh(verticesPtr, positions.length, indicesPtr, indices.length / 3, !isU32, physics); physX._free(verticesPtr); physX._free(indicesPtr); - if (!this._pxMesh) { + if (!pxMesh) { this._logTriMeshCookingError(physX); - return false; + return null; } } const { x: scaleX, y: scaleY, z: scaleZ } = this._worldScale; - const meshFlag = this._isConvex ? PhysXMeshColliderShape._tightBoundsFlag : 0; - this._pxGeometry = this._isConvex - ? physX.createConvexMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag) - : physX.createTriMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag); + const meshFlag = isConvex ? PhysXMeshColliderShape._tightBoundsFlag : 0; + const geometry = isConvex + ? physX.createConvexMeshGeometry(pxMesh, scaleX, scaleY, scaleZ, meshFlag) + : physX.createTriMeshGeometry(pxMesh, scaleX, scaleY, scaleZ, meshFlag); - return true; + return { mesh: pxMesh, geometry }; } private _logConvexCookingError(physX: any): void { @@ -221,8 +230,14 @@ export class PhysXMeshColliderShape extends PhysXColliderShape implements IMeshC ? physX.createConvexMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag) : physX.createTriMeshGeometry(this._pxMesh, scaleX, scaleY, scaleZ, meshFlag); - this._pxGeometry.delete(); + const oldGeometry = this._pxGeometry; + try { + this._pxShape.setGeometry(newGeometry); + } catch (error) { + newGeometry?.delete(); + throw error; + } this._pxGeometry = newGeometry; - this._pxShape.setGeometry(this._pxGeometry); + oldGeometry?.delete(); } } diff --git a/packages/rhi-webgl/package.json b/packages/rhi-webgl/package.json index 9ae280e0f2..b7fe25511d 100644 --- a/packages/rhi-webgl/package.json +++ b/packages/rhi-webgl/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-rhi-webgl", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "repository": { "url": "https://github.com/galacean/engine.git" }, diff --git a/packages/shader-compiler/package.json b/packages/shader-compiler/package.json index 7c4600bc01..6a1fcb4584 100644 --- a/packages/shader-compiler/package.json +++ b/packages/shader-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-shader-compiler", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/shader-compiler/src/ShaderCompiler.ts b/packages/shader-compiler/src/ShaderCompiler.ts index 7cd2e7c051..f9358cd8a9 100644 --- a/packages/shader-compiler/src/ShaderCompiler.ts +++ b/packages/shader-compiler/src/ShaderCompiler.ts @@ -23,6 +23,15 @@ export class ShaderCompiler { private _includeMap: IncludeMap = {}; private readonly _chunkOutputCache: ChunkOutputCache = new Map(); + /** + * Release token and AST pools retained at their compilation-time peak. + * Call this after shader warm-up when no further compilation is expected soon. + * Later compilation remains supported and will allocate pool entries on demand. + */ + static releaseCompilationCache(): void { + ShaderCompilerUtils.releaseAllShaderCompilerObjectPool(); + } + /** Replace the `#include` lookup table and clear the derived chunk cache. */ _setIncludeMap(includeMap: IncludeMap): void { this._includeMap = includeMap; diff --git a/packages/shader-compiler/src/ShaderCompilerUtils.ts b/packages/shader-compiler/src/ShaderCompilerUtils.ts index 6937d09096..2ba9872449 100644 --- a/packages/shader-compiler/src/ShaderCompilerUtils.ts +++ b/packages/shader-compiler/src/ShaderCompilerUtils.ts @@ -21,6 +21,13 @@ export class ShaderCompilerUtils { } } + /** Release all objects retained by the compiler pools. */ + static releaseAllShaderCompilerObjectPool(): void { + for (let i = 0, n = ShaderCompilerUtils._shaderCompilerObjectPoolSet.length; i < n; i++) { + ShaderCompilerUtils._shaderCompilerObjectPoolSet[i].garbageCollection(); + } + } + static createGSError( message: string, errorName: GSErrorName, diff --git a/packages/shader-compiler/src/lalr/LALR1.ts b/packages/shader-compiler/src/lalr/LALR1.ts index 7a19e86d34..387d2d390e 100644 --- a/packages/shader-compiler/src/lalr/LALR1.ts +++ b/packages/shader-compiler/src/lalr/LALR1.ts @@ -30,6 +30,9 @@ export class LALR1 { generate() { this.computeFirstSet(); this.buildStateTable(); + // Runtime parsing only needs the numeric action/goto tables. The state closure graph is + // construction-only data and otherwise keeps thousands of StateItem/Set objects alive. + State.clearPool(); } private buildStateTable() { @@ -63,12 +66,12 @@ export class LALR1 { const productionList = this.grammar.getProductionList(item.curSymbol); if (item.nextSymbol) { - let newLookaheadSet = new Set(); + const newLookaheadSet = new Set(); let lastFirstSet: Set | undefined; let terminalExist = false; // when A :=> a.BC, a; ==》 B :=> .xy, First(Ca) // newLookAhead = First(Ca) - for (let i = 1, nextSymbol = item.symbolByOffset(1); !!nextSymbol; nextSymbol = item.symbolByOffset(++i)) { + for (let i = 1, nextSymbol = item.symbolByOffset(1); nextSymbol; nextSymbol = item.symbolByOffset(++i)) { if (GrammarUtils.isTerminal(nextSymbol)) { newLookaheadSet.add(nextSymbol); terminalExist = true; diff --git a/packages/shader-compiler/src/lalr/State.ts b/packages/shader-compiler/src/lalr/State.ts index 6ef782249e..531241e777 100644 --- a/packages/shader-compiler/src/lalr/State.ts +++ b/packages/shader-compiler/src/lalr/State.ts @@ -7,6 +7,12 @@ export default class State { static pool: Map = new Map(); static _id = 0; + /** Release the state graph after the action/goto tables have been generated. */ + static clearPool(): void { + this.closureMap.clear(); + this.pool.clear(); + } + readonly id: number; readonly cores: Set; private _items: Set; diff --git a/packages/shader/package.json b/packages/shader/package.json index e38be5b235..4cb8f305c0 100644 --- a/packages/shader/package.json +++ b/packages/shader/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-shader", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/shader/src/Shaders/2D/Spine.shader b/packages/shader/src/Shaders/2D/Spine.shader new file mode 100644 index 0000000000..cd6c977334 --- /dev/null +++ b/packages/shader/src/Shaders/2D/Spine.shader @@ -0,0 +1,80 @@ +Shader "2D/Spine" { + SubShader "Default" { + Pass "Default" { + Tags { pipelineStage = "Forward" } + + BlendFactor sourceColorBlendFactor; + BlendFactor destinationColorBlendFactor; + BlendFactor sourceAlphaBlendFactor; + BlendFactor destinationAlphaBlendFactor; + + BlendState = { + Enabled = true; + SourceColorBlendFactor = sourceColorBlendFactor; + DestinationColorBlendFactor = destinationColorBlendFactor; + SourceAlphaBlendFactor = sourceAlphaBlendFactor; + DestinationAlphaBlendFactor = destinationAlphaBlendFactor; + } + DepthState = { + WriteEnabled = false; + } + RasterState = { + CullMode = CullMode.Off; + } + RenderQueueType = Transparent; + + VertexShader = SpineVertex; + FragmentShader = SpineFragment; + + #include "ShaderLibrary/Common/Common.glsl" + + struct a2v { + vec3 POSITION; + vec2 TEXCOORD_0; + vec4 LIGHT_COLOR; + #ifdef RENDERER_TINT_BLACK + vec3 DARK_COLOR; + #endif + }; + + struct v2f { + vec2 v_uv; + vec4 v_lightColor; + #ifdef RENDERER_TINT_BLACK + vec3 v_darkColor; + #endif + }; + + mat4 renderer_MVPMat; + sampler2D material_SpineTexture; + float renderer_PremultipliedAlpha; + + v2f SpineVertex(a2v attr) { + v2f v; + gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); + v.v_uv = attr.TEXCOORD_0; + v.v_lightColor = attr.LIGHT_COLOR; + #ifdef RENDERER_TINT_BLACK + v.v_darkColor = attr.DARK_COLOR; + #endif + return v; + } + + void SpineFragment(v2f v) { + vec4 texColor = texture2D(material_SpineTexture, v.v_uv); + vec4 lightColor = sRGBToLinear(v.v_lightColor); + #ifdef RENDERER_TINT_BLACK + vec4 darkColor = sRGBToLinear(vec4(v.v_darkColor, 1.0)); + vec3 dark_premult = (texColor.a - texColor.rgb) * darkColor.rgb; + vec3 dark_nonpremult = (1.0 - texColor.rgb) * darkColor.rgb; + vec3 dark = mix(dark_nonpremult, dark_premult, renderer_PremultipliedAlpha); + vec3 light = texColor.rgb * lightColor.rgb; + gl_FragColor.rgb = dark + light; + gl_FragColor.a = texColor.a * lightColor.a; + #else + gl_FragColor = texColor * lightColor; + #endif + } + } + } +} diff --git a/packages/shader/src/Shaders/2D/Text.shader b/packages/shader/src/Shaders/2D/Text.shader index 333e6ad35d..f4d1c14be5 100644 --- a/packages/shader/src/Shaders/2D/Text.shader +++ b/packages/shader/src/Shaders/2D/Text.shader @@ -30,27 +30,81 @@ Shader "2D/Text" { struct v2f { vec2 v_uv; vec4 v_color; + vec2 v_worldPosition; }; mat4 renderer_MVPMat; sampler2D renderElement_TextTexture; + vec2 renderElement_TextTextureSize; + vec4 renderer_OutlineColor; + float renderer_OutlineWidth; + vec4 renderer_UIRectClipRect; + float renderer_UIRectClipEnabled; + vec4 renderer_UIRectClipSoftness; + float renderer_UIRectClipHardClip; v2f TextVertex(a2v attr) { v2f v; gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); v.v_uv = attr.TEXCOORD_0; v.v_color = attr.COLOR_0; + v.v_worldPosition = attr.POSITION.xy; return v; } - void TextFragment(v2f v) { - vec4 texColor = texture2D(renderElement_TextTexture, v.v_uv); + float getUIRectClipAlpha(v2f v) { + vec4 edgeDistance = vec4( + v.v_worldPosition.x - renderer_UIRectClipRect.x, + v.v_worldPosition.y - renderer_UIRectClipRect.y, + renderer_UIRectClipRect.z - v.v_worldPosition.x, + renderer_UIRectClipRect.w - v.v_worldPosition.y + ); + vec4 hardClipFactor = step(vec4(0.0), edgeDistance); + vec4 softness = max(renderer_UIRectClipSoftness, vec4(1e-5)); + vec4 softClipFactor = clamp(edgeDistance / softness, 0.0, 1.0); + vec4 useSoftness = step(vec4(1e-5), renderer_UIRectClipSoftness); + vec4 clipFactor = mix(hardClipFactor, softClipFactor, useSoftness); + return clipFactor.x * clipFactor.y * clipFactor.z * clipFactor.w; + } + + float sampleCoverage(vec2 uv) { + vec4 texColor = texture2D(renderElement_TextTexture, uv); #ifdef GRAPHICS_API_WEBGL2 - float coverage = texColor.r; + return texColor.r; #else - float coverage = texColor.a; + return texColor.a; #endif - gl_FragColor = vec4(v.v_color.rgb, v.v_color.a * coverage); + } + + void TextFragment(v2f v) { + float coverage = sampleCoverage(v.v_uv); + vec4 finalColor; + if (renderer_OutlineWidth > 0.0) { + vec2 texelSize = 1.0 / renderElement_TextTextureSize; + vec2 outlineStep = texelSize * renderer_OutlineWidth; + float outlineCoverage = coverage; + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2( outlineStep.x, 0.0))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2(-outlineStep.x, 0.0))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2( 0.0, outlineStep.y))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2( 0.0, -outlineStep.y))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2( outlineStep.x * 0.7071, outlineStep.y * 0.7071))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2(-outlineStep.x * 0.7071, outlineStep.y * 0.7071))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2( outlineStep.x * 0.7071, -outlineStep.y * 0.7071))); + outlineCoverage = max(outlineCoverage, sampleCoverage(v.v_uv + vec2(-outlineStep.x * 0.7071, -outlineStep.y * 0.7071))); + + vec3 rgb = mix(renderer_OutlineColor.rgb, v.v_color.rgb, coverage); + float alpha = max(coverage, outlineCoverage * renderer_OutlineColor.a) * v.v_color.a; + finalColor = vec4(rgb, alpha); + } else { + finalColor = vec4(v.v_color.rgb, v.v_color.a * coverage); + } + if (renderer_UIRectClipEnabled > 0.5) { + finalColor.a *= getUIRectClipAlpha(v); + if (renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } + } + gl_FragColor = finalColor; } } } diff --git a/packages/shader/src/Shaders/2D/UIDefault.shader b/packages/shader/src/Shaders/2D/UIDefault.shader index 9025f93605..4306eab1a1 100644 --- a/packages/shader/src/Shaders/2D/UIDefault.shader +++ b/packages/shader/src/Shaders/2D/UIDefault.shader @@ -27,6 +27,10 @@ Shader "2D/UIDefault" { mat4 renderer_MVPMat; sampler2D renderer_UITexture; + vec4 renderer_UIRectClipRect; + float renderer_UIRectClipEnabled; + vec4 renderer_UIRectClipSoftness; + float renderer_UIRectClipHardClip; struct Attributes { vec3 POSITION; @@ -37,6 +41,7 @@ Shader "2D/UIDefault" { struct Varyings { vec2 v_uv; vec4 v_color; + vec2 v_worldPosition; }; Varyings vert(Attributes attr) { @@ -45,13 +50,35 @@ Shader "2D/UIDefault" { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); v.v_uv = attr.TEXCOORD_0; v.v_color = attr.COLOR_0; + v.v_worldPosition = attr.POSITION.xy; return v; } + float getUIRectClipAlpha(Varyings v) { + vec4 edgeDistance = vec4( + v.v_worldPosition.x - renderer_UIRectClipRect.x, + v.v_worldPosition.y - renderer_UIRectClipRect.y, + renderer_UIRectClipRect.z - v.v_worldPosition.x, + renderer_UIRectClipRect.w - v.v_worldPosition.y + ); + vec4 hardClipFactor = step(vec4(0.0), edgeDistance); + vec4 softness = max(renderer_UIRectClipSoftness, vec4(1e-5)); + vec4 softClipFactor = clamp(edgeDistance / softness, 0.0, 1.0); + vec4 useSoftness = step(vec4(1e-5), renderer_UIRectClipSoftness); + vec4 clipFactor = mix(hardClipFactor, softClipFactor, useSoftness); + return clipFactor.x * clipFactor.y * clipFactor.z * clipFactor.w; + } + void frag(Varyings v) { vec4 baseColor = texture2DSRGB(renderer_UITexture, v.v_uv); vec4 finalColor = baseColor * v.v_color; + if (renderer_UIRectClipEnabled > 0.5) { + finalColor.a *= getUIRectClipAlpha(v); + if (renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } + } #ifdef ENGINE_SHOULD_SRGB_CORRECT finalColor = outputSRGBCorrection(finalColor); diff --git a/packages/shader/src/Shaders/2D/UIOverlayBlit.shader b/packages/shader/src/Shaders/2D/UIOverlayBlit.shader new file mode 100644 index 0000000000..1961ea3920 --- /dev/null +++ b/packages/shader/src/Shaders/2D/UIOverlayBlit.shader @@ -0,0 +1,64 @@ +Shader "2D/UIOverlayBlit" { + SubShader "Default" { + Pass "Forward" { + Tags { pipelineStage = "Forward" } + + BlendState = { + Enabled = true; + SourceColorBlendFactor = BlendFactor.One; + DestinationColorBlendFactor = BlendFactor.OneMinusSourceAlpha; + SourceAlphaBlendFactor = BlendFactor.One; + DestinationAlphaBlendFactor = BlendFactor.OneMinusSourceAlpha; + } + DepthState = { + Enabled = false; + WriteEnabled = false; + } + + VertexShader = vert; + FragmentShader = frag; + + #include "ShaderLibrary/Common/Common.glsl" + + mediump sampler2D renderer_BlitTexture; + #ifdef HAS_TEX_LOD + float renderer_BlitMipLevel; + #endif + vec4 renderer_SourceScaleOffset; + + struct Attributes { + vec4 POSITION_UV; + }; + + struct Varyings { + vec2 v_uv; + }; + + Varyings vert(Attributes attr) { + Varyings v; + gl_Position = vec4(attr.POSITION_UV.xy, 0.0, 1.0); + v.v_uv = attr.POSITION_UV.zw; + return v; + } + + #ifdef HAS_TEX_LOD + vec4 texture2DLodSRGB(sampler2D tex, vec2 uv, float lod) { + vec4 color = texture2DLodEXT(tex, uv, lod); + #ifdef ENGINE_NO_SRGB + color = sRGBToLinear(color); + #endif + return color; + } + #endif + + void frag(Varyings v) { + vec2 uv = v.v_uv * renderer_SourceScaleOffset.xy + renderer_SourceScaleOffset.zw; + #ifdef HAS_TEX_LOD + gl_FragColor = texture2DLodSRGB(renderer_BlitTexture, uv, renderer_BlitMipLevel); + #else + gl_FragColor = texture2DSRGB(renderer_BlitTexture, uv); + #endif + } + } + } +} diff --git a/packages/shader/src/Shaders/index.ts b/packages/shader/src/Shaders/index.ts index f2ff92859d..5bbb1724a4 100644 --- a/packages/shader/src/Shaders/index.ts +++ b/packages/shader/src/Shaders/index.ts @@ -1,9 +1,11 @@ // Auto-generated by shader-compiler-precompile --emit-sources — do not edit. +import _2D_Spine from "./2D/Spine.shader"; import _2D_Sprite from "./2D/Sprite.shader"; import _2D_SpriteMask from "./2D/SpriteMask.shader"; import _2D_Text from "./2D/Text.shader"; import _2D_UIDefault from "./2D/UIDefault.shader"; +import _2D_UIOverlayBlit from "./2D/UIOverlayBlit.shader"; import BlinnPhong from "./BlinnPhong.shader"; import Blit_Blit from "./Blit/Blit.shader"; import Blit_BlitScreen from "./Blit/BlitScreen.shader"; @@ -30,10 +32,12 @@ export interface IShaderSource { // prettier-ignore export const shaders: IShaderSource[] = [ + { source: _2D_Spine, path: "Shaders/2D/Spine.shader" }, { source: _2D_Sprite, path: "Shaders/2D/Sprite.shader" }, { source: _2D_SpriteMask, path: "Shaders/2D/SpriteMask.shader" }, { source: _2D_Text, path: "Shaders/2D/Text.shader" }, { source: _2D_UIDefault, path: "Shaders/2D/UIDefault.shader" }, + { source: _2D_UIOverlayBlit, path: "Shaders/2D/UIOverlayBlit.shader" }, { source: BlinnPhong, path: "Shaders/BlinnPhong.shader" }, { source: Blit_Blit, path: "Shaders/Blit/Blit.shader" }, { source: Blit_BlitScreen, path: "Shaders/Blit/BlitScreen.shader" }, diff --git a/packages/spine-core-3.8/package.json b/packages/spine-core-3.8/package.json new file mode 100644 index 0000000000..412899d422 --- /dev/null +++ b/packages/spine-core-3.8/package.json @@ -0,0 +1,40 @@ +{ + "name": "@galacean/engine-spine-core-3.8", + "version": "0.0.0-experimental-2.0-migrate.3", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/galacean/engine.git" + }, + "license": "MIT", + "main": "dist/main.js", + "module": "dist/module.js", + "debug": "src/index.ts", + "browser": "dist/browser.js", + "types": "types/index.d.ts", + "scripts": { + "b:types": "tsc" + }, + "umd": { + "name": "Galacean.SpineCore38", + "globals": { + "@galacean/engine": "Galacean", + "@galacean/engine-spine": "Galacean.Spine" + } + }, + "files": [ + "dist/**/*", + "types/**/*" + ], + "dependencies": { + "@galacean/engine-spine": "workspace:*" + }, + "devDependencies": { + "@galacean/engine": "workspace:*" + }, + "peerDependencies": { + "@galacean/engine": "workspace:*" + } +} diff --git a/packages/spine-core-3.8/src/Spine38Runtime.ts b/packages/spine-core-3.8/src/Spine38Runtime.ts new file mode 100644 index 0000000000..f2d8a9c7e9 --- /dev/null +++ b/packages/spine-core-3.8/src/Spine38Runtime.ts @@ -0,0 +1,94 @@ +import { + AnimationState, + AnimationStateData, + AtlasAttachmentLoader, + Skeleton, + SkeletonBinary, + SkeletonData, + SkeletonJson, + TextureAtlas +} from "./spine-core"; +import type { Texture2D } from "@galacean/engine"; +import type { ISpineRenderTarget } from "@galacean/engine-spine"; +import { SpineGenerator } from "./SpineGenerator"; +import { SpineTexture } from "./SpineTexture"; + +/** + * Spine 3.8 runtime backend. + * + * @remarks + * Owns everything specific to the spine 3.8 runtime line: parsing (`SkeletonJson`/`SkeletonBinary`/ + * `TextureAtlas`), object-model construction, world-transform stepping (3.8 has no `Physics` argument), + * and the attachment-reading mesh generator. Registered automatically when this package is imported. + * + * Deliberately does not `implements ISpineRuntime`: that interface types its methods against the + * 4.2 npm package as the "canonical contract", but 4.2 and 3.8 have actually diverged enough + * (4.2 added `TextureAtlas.setTextures`; 4.2 dropped `Skeleton`/`SkeletonData`'s `updateCacheReset`/ + * `findBoneIndex`/`findSlotIndex`/`findPathConstraintIndex`, all present in 3.8) that TS's structural + * check fails even though every member `ISpineRuntime` actually calls lines up. Registered via a cast + * in `index.ts` instead of fighting the type checker method-by-method. + */ +export class Spine38Runtime { + private _generator = new SpineGenerator(); + + parseAtlasPageNames(atlasText: string): string[] { + // Unlike 4.2, spine 3.8's `TextureAtlas` constructor requires a synchronous texture loader and + // calls `setFilters`/`setWraps` on whatever it returns immediately during parsing, so a bare + // `null` loader would throw; hand back a no-op stub instead (its return type is `any`). + const pageNames: string[] = []; + new TextureAtlas(atlasText, (path) => { + pageNames.push(path); + return { setFilters() {}, setWraps() {} }; + }); + return pageNames; + } + + createTextureAtlas(atlasText: string, textures: Texture2D[]): TextureAtlas { + let index = 0; + return new TextureAtlas(atlasText, (path) => { + const engineTexture = textures.find((item) => item.name === path) || textures[index]; + index++; + return new SpineTexture(engineTexture); + }); + } + + createSkeletonData( + skeletonRawData: string | ArrayBuffer, + textureAtlas: TextureAtlas, + skeletonDataScale: number + ): SkeletonData { + const atlasLoader = new AtlasAttachmentLoader(textureAtlas); + if (typeof skeletonRawData === "string") { + const skeletonJson = new SkeletonJson(atlasLoader); + skeletonJson.scale = skeletonDataScale; + return skeletonJson.readSkeletonData(skeletonRawData); + } else { + const skeletonBinary = new SkeletonBinary(atlasLoader); + skeletonBinary.scale = skeletonDataScale; + return skeletonBinary.readSkeletonData(new Uint8Array(skeletonRawData as ArrayBuffer)); + } + } + + createAnimationStateData(skeletonData: SkeletonData): AnimationStateData { + return new AnimationStateData(skeletonData); + } + + createSkeleton(skeletonData: SkeletonData): Skeleton { + return new Skeleton(skeletonData); + } + + createAnimationState(stateData: AnimationStateData): AnimationState { + return new AnimationState(stateData); + } + + updateState(skeleton: Skeleton, state: AnimationState, delta: number): void { + state.update(delta); + state.apply(skeleton); + skeleton.update(delta); + skeleton.updateWorldTransform(); + } + + buildPrimitive(skeleton: Skeleton, target: ISpineRenderTarget): void { + this._generator.buildPrimitive(skeleton, target); + } +} diff --git a/packages/spine-core-3.8/src/SpineGenerator.ts b/packages/spine-core-3.8/src/SpineGenerator.ts new file mode 100644 index 0000000000..5ba048f320 --- /dev/null +++ b/packages/spine-core-3.8/src/SpineGenerator.ts @@ -0,0 +1,358 @@ +import { + ArrayLike, + BlendMode, + ClippingAttachment, + Color, + MeshAttachment, + RegionAttachment, + Skeleton, + SkeletonClipping, + TextureAtlasRegion +} from "./spine-core"; +import { BoundingBox, SubPrimitive } from "@galacean/engine"; +import { SpineTexture } from "./SpineTexture"; +import { ClearablePool } from "./util/ClearablePool"; +import { ReturnablePool } from "./util/ReturnablePool"; +import type { ISpineRenderTarget } from "@galacean/engine-spine"; +import { SpineBlendMode, SpineVertexStride } from "@galacean/engine-spine"; + +class SubRenderItem { + subPrimitive: SubPrimitive; + blendMode: BlendMode; + texture: any; +} + +/** + * @internal + */ +export class SpineGenerator { + static tempDark = new Color(); + static tempColor = new Color(); + static tempVerts = new Array(8); + static QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; + static subPrimitivePool = new ReturnablePool(SubPrimitive); + static subRenderItemPool = new ClearablePool(SubRenderItem); + + private _subRenderItems: SubRenderItem[] = []; + private _clipper: SkeletonClipping = new SkeletonClipping(); + + buildPrimitive(skeleton: Skeleton, renderer: ISpineRenderTarget) { + const { _indices, _vertices, _localBounds, _vertexCount, _subPrimitives, zSpacing, premultipliedAlpha, tintBlack } = + renderer; + + _localBounds.min.set(Infinity, Infinity, Infinity); + _localBounds.max.set(-Infinity, -Infinity, -Infinity); + + const { _clipper, _subRenderItems } = this; + + const { tempVerts, subRenderItemPool, subPrimitivePool } = SpineGenerator; + const { withTint: vertexStrideWithTint, withoutTint: vertexStrideWithoutTint } = SpineVertexStride; + + _subRenderItems.length = 0; + subRenderItemPool.clear(); + + let triangles: Array; + let uvs: ArrayLike; + + let verticesLength = 0; + let indicesLength = 0; + let start = 0; + let count = 0; + + let blend = BlendMode.Normal; + let texture: SpineTexture = null; + let tempBlendMode: BlendMode | null = null; + let tempTexture: SpineTexture | null = null; + + let primitiveIndex = 0; + + const drawOrder = skeleton.drawOrder; + for (let slotIndex = 0, n = drawOrder.length; slotIndex < n; ++slotIndex) { + const slot = drawOrder[slotIndex]; + if (!slot.bone.active) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const attachment = slot.getAttachment(); + if (!attachment) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const z = zSpacing * slotIndex; + const isClipping = _clipper.isClipping(); + let numFloats = 0; + let attachmentColor: Color = null; + + // This vertexSize will be passed to spine-core's computeWorldVertices function. + // + // Expected format by computeWorldVertices: + // - Without tintBlack: [x, y, u, v, r, g, b, a] = 8 components + // - With tintBlack: [x, y, u, v, r, g, b, a, dr, dg, db, da] = 12 components + // + // Our actual vertex buffer format: + // - vertexStrideWithoutTint: [x, y, z, u, v, r, g, b, a] = 9 components + // - vertexStrideWithTint: [x, y, z, u, v, r, g, b, a, dr, dg, db] = 12 components + // (Note: we optimized 'da' as uniform instead of buffer attribute) + // + // Calculation: + // - Without tintBlack: 9 - 1 (remove z) = 8 ✓ + // - With tintBlack: 12 - 1 (remove z) + 1 (add back da) = 12 ✓ + const vertexSize = tintBlack ? vertexStrideWithTint : vertexStrideWithoutTint - 1; + const clippedVertexSize = isClipping ? 2 : vertexSize; + + switch (attachment.constructor) { + case RegionAttachment: + const regionAttachment = attachment; + attachmentColor = regionAttachment.color; + numFloats = clippedVertexSize << 2; + // spine 3.8's RegionAttachment.computeWorldVertices takes the Bone, not the Slot (4.2 takes Slot). + regionAttachment.computeWorldVertices(slot.bone, tempVerts, 0, clippedVertexSize); + triangles = SpineGenerator.QUAD_TRIANGLES; + uvs = regionAttachment.uvs; + // `region` is statically typed as the base TextureRegion (no `texture` field); it's + // always a TextureAtlasRegion in practice, since AtlasAttachmentLoader is the only + // source of regions. Its `.texture` is in turn always a SpineTexture, since + // createTextureAtlas is the only place that constructs one. + texture = (regionAttachment.region as TextureAtlasRegion).texture as unknown as SpineTexture; + break; + case MeshAttachment: + const meshAttachment = attachment; + attachmentColor = meshAttachment.color; + numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize; + if (numFloats > _vertices.length) { + SpineGenerator.tempVerts = new Array(numFloats); + } + meshAttachment.computeWorldVertices( + slot, + 0, + meshAttachment.worldVerticesLength, + tempVerts, + 0, + clippedVertexSize + ); + triangles = meshAttachment.triangles; + uvs = meshAttachment.uvs; + texture = (meshAttachment.region as TextureAtlasRegion).texture as unknown as SpineTexture; + break; + case ClippingAttachment: + const clip = attachment; + _clipper.clipStart(slot, clip); + continue; + default: + _clipper.clipEndWithSlot(slot); + continue; + } + + if (texture != null) { + let finalVertices: ArrayLike; + let finalVerticesLength: number; + let finalIndices: ArrayLike; + let finalIndicesLength: number; + + const skeleton = slot.bone.skeleton; + const skeletonColor = skeleton.color; + const slotColor = slot.color; + const finalColor = SpineGenerator.tempColor; + const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a; + + finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r; + finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g; + finalColor.b = skeletonColor.b * slotColor.b * attachmentColor.b; + finalColor.a = finalAlpha; + + if (premultipliedAlpha) { + finalColor.r *= finalAlpha; + finalColor.g *= finalAlpha; + finalColor.b *= finalAlpha; + } + + const darkColor = SpineGenerator.tempDark; + const slotDarkColor = slot.darkColor; + if (!slotDarkColor) { + darkColor.set(0, 0, 0, 1); + } else { + if (premultipliedAlpha) { + darkColor.r = slotDarkColor.r * finalAlpha; + darkColor.g = slotDarkColor.g * finalAlpha; + darkColor.b = slotDarkColor.b * finalAlpha; + } else { + darkColor.setFromColor(slotDarkColor); + } + } + + if (isClipping) { + // spine 3.8's SkeletonClipping.clipTriangles takes an extra (unused) verticesLength param + // right after `vertices` that 4.2 dropped. + _clipper.clipTriangles( + tempVerts, + numFloats, + triangles, + triangles.length, + uvs, + finalColor, + darkColor, + tintBlack + ); + finalVertices = _clipper.clippedVertices; + finalVerticesLength = finalVertices.length; + finalIndices = _clipper.clippedTriangles; + finalIndicesLength = finalIndices.length; + } else { + const { r, g, b, a } = finalColor; + for (let v = 2, u = 0, n = numFloats; v < n; v += vertexSize, u += 2) { + tempVerts[v] = r; + tempVerts[v + 1] = g; + tempVerts[v + 2] = b; + tempVerts[v + 3] = a; + tempVerts[v + 4] = uvs[u]; + tempVerts[v + 5] = uvs[u + 1]; + if (tintBlack) { + tempVerts[v + 6] = darkColor.r; + tempVerts[v + 7] = darkColor.g; + tempVerts[v + 8] = darkColor.b; + tempVerts[v + 9] = darkColor.a; + } + } + finalVertices = tempVerts; + finalVerticesLength = numFloats; + finalIndices = triangles; + finalIndicesLength = triangles.length; + } + + if (finalVerticesLength == 0 || finalIndicesLength == 0) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const stride = tintBlack ? vertexStrideWithTint : vertexStrideWithoutTint; + const indexStart = verticesLength / stride; + let i = verticesLength; + let j = 0; + for (; j < finalVerticesLength; ) { + const x = finalVertices[j++]; + const y = finalVertices[j++]; + _vertices[i++] = x; + _vertices[i++] = y; + _vertices[i++] = z; + _vertices[i++] = finalVertices[j++]; // u + _vertices[i++] = finalVertices[j++]; // v + _vertices[i++] = finalVertices[j++]; // r + _vertices[i++] = finalVertices[j++]; // g + _vertices[i++] = finalVertices[j++]; // b + _vertices[i++] = finalVertices[j++]; // a + if (tintBlack) { + _vertices[i++] = finalVertices[j++]; // darkR + _vertices[i++] = finalVertices[j++]; // darkG + _vertices[i++] = finalVertices[j++]; // darkB + j++; // darkA + } + this._expandBounds(x, y, z, _localBounds); + } + verticesLength = i; + + for (i = indicesLength, j = 0; j < finalIndicesLength; i++, j++) { + _indices[i] = finalIndices[j] + indexStart; + } + indicesLength += finalIndicesLength; + + const textureChanged = tempTexture !== null && tempTexture !== texture; + blend = slot.data.blendMode; + const blendModeChanged = tempBlendMode !== null && tempBlendMode !== blend; + + if (blendModeChanged || textureChanged) { + // Finish accumulated count first + if (count > 0) { + primitiveIndex = this._createRenderItem( + _subPrimitives, + primitiveIndex, + start, + count, + tempTexture, + tempBlendMode + ); + start += count; + count = 0; + } + } + count += finalIndicesLength; + tempTexture = texture; + tempBlendMode = blend; + } + + _clipper.clipEndWithSlot(slot); + } // slot traverse end + + // add reset sub primitive + if (count > 0) { + primitiveIndex = this._createRenderItem(_subPrimitives, primitiveIndex, start, count, texture, blend); + count = 0; + } + + _clipper.clipEnd(); + + const lastLen = _subPrimitives.length; + const curLen = _subRenderItems.length; + for (let i = curLen; i < lastLen; i++) { + const item = _subPrimitives[i]; + subPrimitivePool.return(item); + } + + renderer._clearSubPrimitives(); + for (let i = 0, l = curLen; i < l; ++i) { + const item = _subRenderItems[i]; + const { blendMode, texture } = item; + renderer._addSubPrimitive(item.subPrimitive); + const material = renderer._getMaterial(texture.getImage(), blendMode as unknown as SpineBlendMode); + renderer.setMaterial(i, material); + } + + if (indicesLength > _vertexCount || renderer._needResizeBuffer) { + renderer._createAndBindBuffer(indicesLength); + renderer._needResizeBuffer = false; + this.buildPrimitive(skeleton, renderer); + return; + } + + if (renderer._vertexBuffer) { + renderer._vertexBuffer.setData(_vertices); + renderer._indexBuffer.setData(_indices); + } + } + + private _createRenderItem( + subPrimitives: SubPrimitive[], + primitiveIndex: number, + start: number, + count: number, + texture: SpineTexture, + blend: BlendMode + ): number { + const { subPrimitivePool, subRenderItemPool } = SpineGenerator; + const origin = subPrimitives[primitiveIndex]; + + if (origin) { + primitiveIndex++; + } + + const subPrimitive = origin || subPrimitivePool.get(); + subPrimitive.start = start; + subPrimitive.count = count; + + const renderItem = subRenderItemPool.get(); + renderItem.blendMode = blend; + renderItem.subPrimitive = subPrimitive; + renderItem.texture = texture; + + this._subRenderItems.push(renderItem); + + return primitiveIndex; + } + + private _expandBounds(x: number, y: number, z: number, localBounds: BoundingBox) { + const { min, max } = localBounds; + min.set(Math.min(min.x, x), Math.min(min.y, y), Math.min(min.z, z)); + max.set(Math.max(max.x, x), Math.max(max.y, y), Math.max(max.z, z)); + } +} diff --git a/packages/spine-core-3.8/src/SpineTexture.ts b/packages/spine-core-3.8/src/SpineTexture.ts new file mode 100644 index 0000000000..cd604451ab --- /dev/null +++ b/packages/spine-core-3.8/src/SpineTexture.ts @@ -0,0 +1,50 @@ +import { Texture, TextureFilter, TextureWrap } from "./spine-core/Texture"; +import { Texture2D, TextureFilterMode, TextureWrapMode } from "@galacean/engine"; + +/** + * @internal + */ +export class SpineTexture extends Texture { + constructor(image: Texture2D) { + super(image); + image.generateMipmaps(); + } + + // The vendored 3.8 `Texture` base class exposes the Texture2D via a `texture` getter instead of + // spine-core 4.2's `getImage()`; this alias keeps SpineGenerator's call sites identical across backends. + getImage(): Texture2D { + return this._texture; + } + + setFilters(minFilter: TextureFilter, magFilter: TextureFilter) { + if (minFilter === TextureFilter.Nearest) { + this._texture.filterMode = TextureFilterMode.Point; + } else if (minFilter === TextureFilter.Linear) { + this._texture.filterMode = TextureFilterMode.Bilinear; + } else { + // The remaining min filters are the MipMap* family (mag filters are never MipMap*); + // mipmaps are generated in the constructor. + this._texture.filterMode = TextureFilterMode.Trilinear; + } + } + + setWraps(uWrap: TextureWrap, vWrap: TextureWrap) { + this._texture.wrapModeU = this._convertWrapMode(uWrap); + this._texture.wrapModeV = this._convertWrapMode(vWrap); + } + + dispose() {} + + private _convertWrapMode(wrap: TextureWrap): TextureWrapMode { + switch (wrap) { + case TextureWrap.ClampToEdge: + return TextureWrapMode.Clamp; + case TextureWrap.Repeat: + return TextureWrapMode.Repeat; + case TextureWrap.MirroredRepeat: + return TextureWrapMode.Mirror; + default: + throw new Error("Unsupported texture wrap mode."); + } + } +} diff --git a/packages/spine-core-3.8/src/index.ts b/packages/spine-core-3.8/src/index.ts new file mode 100644 index 0000000000..c9e0710bc4 --- /dev/null +++ b/packages/spine-core-3.8/src/index.ts @@ -0,0 +1,17 @@ +import type { ISpineRuntime } from "@galacean/engine-spine"; +import { registerSpineRuntime } from "@galacean/engine-spine"; +import { Spine38Runtime } from "./Spine38Runtime"; + +// Self-register on import: `import "@galacean/engine-spine-core-3.8"` wires the 3.8 backend +// into the shared `@galacean/engine-spine` package. Cast needed because Spine38Runtime doesn't +// `implements ISpineRuntime` at the type level — see the class doc comment for why. +registerSpineRuntime(new Spine38Runtime() as unknown as ISpineRuntime); + +export { Spine38Runtime }; + +// Re-export the vendored spine 3.8 runtime API. Power users that construct spine-core objects +// directly (custom attachments, manual skeleton parsing) import them from this core package. +export * from "./spine-core"; + +export const version = `__buildVersion`; +console.log(`Galacean spine-core-3.8 version: ${version}`); diff --git a/packages/spine-core-3.8/src/spine-core/Animation.ts b/packages/spine-core-3.8/src/spine-core/Animation.ts new file mode 100644 index 0000000000..e15edbdc9d --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Animation.ts @@ -0,0 +1,1828 @@ +import { Skeleton } from "./Skeleton"; +import { Utils, MathUtils, ArrayLike } from "./Utils"; +import { Slot } from "./Slot"; +import { VertexAttachment, Attachment } from "./attachments/Attachment"; +import { IkConstraint } from "./IkConstraint"; +import { TransformConstraint } from "./TransformConstraint"; +import { PathConstraint } from "./PathConstraint"; +import { Event } from "./Event"; + +/** A simple container for a list of timelines and a name. */ +export class Animation { + /** The animation's name, which is unique across all animations in the skeleton. */ + name: string; + timelines: Array; + timelineIds: Array; + + /** The duration of the animation in seconds, which is the highest time of all keys in the timeline. */ + duration: number; + + constructor(name: string, timelines: Array, duration: number) { + if (name == null) throw new Error("name cannot be null."); + if (timelines == null) throw new Error("timelines cannot be null."); + this.name = name; + this.timelines = timelines; + this.timelineIds = []; + for (let i = 0; i < timelines.length; i++) this.timelineIds[timelines[i].getPropertyId()] = true; + this.duration = duration; + } + + hasTimeline(id: number) { + return this.timelineIds[id] == true; + } + + /** Applies all the animation's timelines to the specified skeleton. + * + * See Timeline {@link Timeline#apply(Skeleton, float, float, Array, float, MixBlend, MixDirection)}. + * @param loop If true, the animation repeats after {@link #getDuration()}. + * @param events May be null to ignore fired events. */ + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + loop: boolean, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + if (skeleton == null) throw new Error("skeleton cannot be null."); + + if (loop && this.duration != 0) { + time %= this.duration; + if (lastTime > 0) lastTime %= this.duration; + } + + const timelines = this.timelines; + for (let i = 0, n = timelines.length; i < n; i++) + timelines[i].apply(skeleton, lastTime, time, events, alpha, blend, direction); + } + + /** @param target After the first and before the last value. + * @returns index of first value greater than the target. */ + static binarySearch(values: ArrayLike, target: number, step: number = 1) { + let low = 0; + let high = values.length / step - 2; + if (high == 0) return step; + let current = high >>> 1; + while (true) { + if (values[(current + 1) * step] <= target) low = current + 1; + else high = current; + if (low == high) return (low + 1) * step; + current = (low + high) >>> 1; + } + } + + static linearSearch(values: ArrayLike, target: number, step: number) { + for (let i = 0, last = values.length - step; i <= last; i += step) if (values[i] > target) return i; + return -1; + } +} + +/** The interface for all timelines. */ +export interface Timeline { + /** Applies this timeline to the skeleton. + * @param skeleton The skeleton the timeline is being applied to. This provides access to the bones, slots, and other + * skeleton components the timeline may change. + * @param lastTime The time this timeline was last applied. Timelines such as {@link EventTimeline}} trigger only at specific + * times rather than every frame. In that case, the timeline triggers everything between `lastTime` + * (exclusive) and `time` (inclusive). + * @param time The time within the animation. Most timelines find the key before and the key after this time so they can + * interpolate between the keys. + * @param events If any events are fired, they are added to this list. Can be null to ignore fired events or if the timeline + * does not fire events. + * @param alpha 0 applies the current or setup value (depending on `blend`). 1 applies the timeline value. + * Between 0 and 1 applies a value between the current or setup value and the timeline value. By adjusting + * `alpha` over time, an animation can be mixed in or out. `alpha` can also be useful to + * apply animations on top of each other (layering). + * @param blend Controls how mixing is applied when `alpha` < 1. + * @param direction Indicates whether the timeline is mixing in or out. Used by timelines which perform instant transitions, + * such as {@link DrawOrderTimeline} or {@link AttachmentTimeline}. */ + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ): void; + + /** Uniquely encodes both the type of this timeline and the skeleton property that it affects. */ + getPropertyId(): number; +} + +/** Controls how a timeline value is mixed with the setup pose value or current pose value when a timeline's `alpha` + * < 1. + * + * See Timeline {@link Timeline#apply(Skeleton, float, float, Array, float, MixBlend, MixDirection)}. */ +export enum MixBlend { + /** Transitions from the setup value to the timeline value (the current value is not used). Before the first key, the setup + * value is set. */ + setup, + /** Transitions from the current value to the timeline value. Before the first key, transitions from the current value to + * the setup value. Timelines which perform instant transitions, such as {@link DrawOrderTimeline} or + * {@link AttachmentTimeline}, use the setup value before the first key. + * + * `first` is intended for the first animations applied, not for animations layered on top of those. */ + first, + /** Transitions from the current value to the timeline value. No change is made before the first key (the current value is + * kept until the first key). + * + * `replace` is intended for animations layered on top of others, not for the first animations applied. */ + replace, + /** Transitions from the current value to the current value plus the timeline value. No change is made before the first key + * (the current value is kept until the first key). + * + * `add` is intended for animations layered on top of others, not for the first animations applied. Properties + * keyed by additive animations must be set manually or by another animation before applying the additive animations, else + * the property values will increase continually. */ + add +} + +/** Indicates whether a timeline's `alpha` is mixing out over time toward 0 (the setup or current pose value) or + * mixing in toward 1 (the timeline's value). + * + * See Timeline {@link Timeline#apply(Skeleton, float, float, Array, float, MixBlend, MixDirection)}. */ +export enum MixDirection { + mixIn, + mixOut +} + +export enum TimelineType { + rotate, + translate, + scale, + shear, + attachment, + color, + deform, + event, + drawOrder, + ikConstraint, + transformConstraint, + pathConstraintPosition, + pathConstraintSpacing, + pathConstraintMix, + twoColor +} + +/** The base class for timelines that use interpolation between key frame values. */ +export abstract class CurveTimeline implements Timeline { + static LINEAR = 0; + static STEPPED = 1; + static BEZIER = 2; + static BEZIER_SIZE = 10 * 2 - 1; + + private curves: ArrayLike; // type, x, y, ... + + abstract getPropertyId(): number; + + constructor(frameCount: number) { + if (frameCount <= 0) throw new Error("frameCount must be > 0: " + frameCount); + this.curves = Utils.newFloatArray((frameCount - 1) * CurveTimeline.BEZIER_SIZE); + } + + /** The number of key frames for this timeline. */ + getFrameCount() { + return this.curves.length / CurveTimeline.BEZIER_SIZE + 1; + } + + /** Sets the specified key frame to linear interpolation. */ + setLinear(frameIndex: number) { + this.curves[frameIndex * CurveTimeline.BEZIER_SIZE] = CurveTimeline.LINEAR; + } + + /** Sets the specified key frame to stepped interpolation. */ + setStepped(frameIndex: number) { + this.curves[frameIndex * CurveTimeline.BEZIER_SIZE] = CurveTimeline.STEPPED; + } + + /** Returns the interpolation type for the specified key frame. + * @returns Linear is 0, stepped is 1, Bezier is 2. */ + getCurveType(frameIndex: number): number { + const index = frameIndex * CurveTimeline.BEZIER_SIZE; + if (index == this.curves.length) return CurveTimeline.LINEAR; + const type = this.curves[index]; + if (type == CurveTimeline.LINEAR) return CurveTimeline.LINEAR; + if (type == CurveTimeline.STEPPED) return CurveTimeline.STEPPED; + return CurveTimeline.BEZIER; + } + + /** Sets the specified key frame to Bezier interpolation. `cx1` and `cx2` are from 0 to 1, + * representing the percent of time between the two key frames. `cy1` and `cy2` are the percent of the + * difference between the key frame's values. */ + setCurve(frameIndex: number, cx1: number, cy1: number, cx2: number, cy2: number) { + const tmpx = (-cx1 * 2 + cx2) * 0.03, + tmpy = (-cy1 * 2 + cy2) * 0.03; + const dddfx = ((cx1 - cx2) * 3 + 1) * 0.006, + dddfy = ((cy1 - cy2) * 3 + 1) * 0.006; + let ddfx = tmpx * 2 + dddfx, + ddfy = tmpy * 2 + dddfy; + let dfx = cx1 * 0.3 + tmpx + dddfx * 0.16666667, + dfy = cy1 * 0.3 + tmpy + dddfy * 0.16666667; + + let i = frameIndex * CurveTimeline.BEZIER_SIZE; + const curves = this.curves; + curves[i++] = CurveTimeline.BEZIER; + + let x = dfx, + y = dfy; + for (let n = i + CurveTimeline.BEZIER_SIZE - 1; i < n; i += 2) { + curves[i] = x; + curves[i + 1] = y; + dfx += ddfx; + dfy += ddfy; + ddfx += dddfx; + ddfy += dddfy; + x += dfx; + y += dfy; + } + } + + /** Returns the interpolated percentage for the specified key frame and linear percentage. */ + getCurvePercent(frameIndex: number, percent: number) { + percent = MathUtils.clamp(percent, 0, 1); + const curves = this.curves; + let i = frameIndex * CurveTimeline.BEZIER_SIZE; + const type = curves[i]; + if (type == CurveTimeline.LINEAR) return percent; + if (type == CurveTimeline.STEPPED) return 0; + i++; + let x = 0; + for (let start = i, n = i + CurveTimeline.BEZIER_SIZE - 1; i < n; i += 2) { + x = curves[i]; + if (x >= percent) { + let prevX: number, prevY: number; + if (i == start) { + prevX = 0; + prevY = 0; + } else { + prevX = curves[i - 2]; + prevY = curves[i - 1]; + } + return prevY + ((curves[i + 1] - prevY) * (percent - prevX)) / (x - prevX); + } + } + const y = curves[i - 1]; + return y + ((1 - y) * (percent - x)) / (1 - x); // Last point is 1,1. + } + + abstract apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ): void; +} + +/** Changes a bone's local {@link Bone#rotation}. */ +export class RotateTimeline extends CurveTimeline { + static ENTRIES = 2; + static PREV_TIME = -2; + static PREV_ROTATION = -1; + static ROTATION = 1; + + /** The index of the bone in {@link Skeleton#bones} that will be changed. */ + boneIndex: number; + + /** The time in seconds and rotation in degrees for each key frame. */ + frames: ArrayLike; // time, degrees, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount << 1); + } + + getPropertyId() { + return (TimelineType.rotate << 24) + this.boneIndex; + } + + /** Sets the time and angle of the specified keyframe. */ + setFrame(frameIndex: number, time: number, degrees: number) { + frameIndex <<= 1; + this.frames[frameIndex] = time; + this.frames[frameIndex + RotateTimeline.ROTATION] = degrees; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + + const bone = skeleton.bones[this.boneIndex]; + if (!bone.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + bone.rotation = bone.data.rotation; + return; + case MixBlend.first: + const r = bone.data.rotation - bone.rotation; + bone.rotation += (r - (16384 - ((16384.499999999996 - r / 360) | 0)) * 360) * alpha; + } + return; + } + + if (time >= frames[frames.length - RotateTimeline.ENTRIES]) { + // Time is after last frame. + let r = frames[frames.length + RotateTimeline.PREV_ROTATION]; + switch (blend) { + case MixBlend.setup: + bone.rotation = bone.data.rotation + r * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + r += bone.data.rotation - bone.rotation; + r -= (16384 - ((16384.499999999996 - r / 360) | 0)) * 360; // Wrap within -180 and 180. + case MixBlend.add: + bone.rotation += r * alpha; + } + return; + } + + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, RotateTimeline.ENTRIES); + const prevRotation = frames[frame + RotateTimeline.PREV_ROTATION]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + (frame >> 1) - 1, + 1 - (time - frameTime) / (frames[frame + RotateTimeline.PREV_TIME] - frameTime) + ); + + let r = frames[frame + RotateTimeline.ROTATION] - prevRotation; + r = prevRotation + (r - (16384 - ((16384.499999999996 - r / 360) | 0)) * 360) * percent; + switch (blend) { + case MixBlend.setup: + bone.rotation = bone.data.rotation + (r - (16384 - ((16384.499999999996 - r / 360) | 0)) * 360) * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + r += bone.data.rotation - bone.rotation; + case MixBlend.add: + bone.rotation += (r - (16384 - ((16384.499999999996 - r / 360) | 0)) * 360) * alpha; + } + } +} + +/** Changes a bone's local {@link Bone#x} and {@link Bone#y}. */ +export class TranslateTimeline extends CurveTimeline { + static ENTRIES = 3; + static PREV_TIME = -3; + static PREV_X = -2; + static PREV_Y = -1; + static X = 1; + static Y = 2; + + /** The index of the bone in {@link Skeleton#bones} that will be changed. */ + boneIndex: number; + + /** The time in seconds, x, and y values for each key frame. */ + frames: ArrayLike; // time, x, y, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * TranslateTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.translate << 24) + this.boneIndex; + } + + /** Sets the time in seconds, x, and y values for the specified key frame. */ + setFrame(frameIndex: number, time: number, x: number, y: number) { + frameIndex *= TranslateTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + TranslateTimeline.X] = x; + this.frames[frameIndex + TranslateTimeline.Y] = y; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + + const bone = skeleton.bones[this.boneIndex]; + if (!bone.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + bone.x = bone.data.x; + bone.y = bone.data.y; + return; + case MixBlend.first: + bone.x += (bone.data.x - bone.x) * alpha; + bone.y += (bone.data.y - bone.y) * alpha; + } + return; + } + + let x = 0, + y = 0; + if (time >= frames[frames.length - TranslateTimeline.ENTRIES]) { + // Time is after last frame. + x = frames[frames.length + TranslateTimeline.PREV_X]; + y = frames[frames.length + TranslateTimeline.PREV_Y]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, TranslateTimeline.ENTRIES); + x = frames[frame + TranslateTimeline.PREV_X]; + y = frames[frame + TranslateTimeline.PREV_Y]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / TranslateTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + TranslateTimeline.PREV_TIME] - frameTime) + ); + + x += (frames[frame + TranslateTimeline.X] - x) * percent; + y += (frames[frame + TranslateTimeline.Y] - y) * percent; + } + switch (blend) { + case MixBlend.setup: + bone.x = bone.data.x + x * alpha; + bone.y = bone.data.y + y * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + bone.x += (bone.data.x + x - bone.x) * alpha; + bone.y += (bone.data.y + y - bone.y) * alpha; + break; + case MixBlend.add: + bone.x += x * alpha; + bone.y += y * alpha; + } + } +} + +/** Changes a bone's local {@link Bone#scaleX)} and {@link Bone#scaleY}. */ +export class ScaleTimeline extends TranslateTimeline { + constructor(frameCount: number) { + super(frameCount); + } + + override getPropertyId() { + return (TimelineType.scale << 24) + this.boneIndex; + } + + override apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + + const bone = skeleton.bones[this.boneIndex]; + if (!bone.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + bone.scaleX = bone.data.scaleX; + bone.scaleY = bone.data.scaleY; + return; + case MixBlend.first: + bone.scaleX += (bone.data.scaleX - bone.scaleX) * alpha; + bone.scaleY += (bone.data.scaleY - bone.scaleY) * alpha; + } + return; + } + + let x = 0, + y = 0; + if (time >= frames[frames.length - ScaleTimeline.ENTRIES]) { + // Time is after last frame. + x = frames[frames.length + ScaleTimeline.PREV_X] * bone.data.scaleX; + y = frames[frames.length + ScaleTimeline.PREV_Y] * bone.data.scaleY; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, ScaleTimeline.ENTRIES); + x = frames[frame + ScaleTimeline.PREV_X]; + y = frames[frame + ScaleTimeline.PREV_Y]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / ScaleTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + ScaleTimeline.PREV_TIME] - frameTime) + ); + + x = (x + (frames[frame + ScaleTimeline.X] - x) * percent) * bone.data.scaleX; + y = (y + (frames[frame + ScaleTimeline.Y] - y) * percent) * bone.data.scaleY; + } + if (alpha == 1) { + if (blend == MixBlend.add) { + bone.scaleX += x - bone.data.scaleX; + bone.scaleY += y - bone.data.scaleY; + } else { + bone.scaleX = x; + bone.scaleY = y; + } + } else { + let bx = 0, + by = 0; + if (direction == MixDirection.mixOut) { + switch (blend) { + case MixBlend.setup: + bx = bone.data.scaleX; + by = bone.data.scaleY; + bone.scaleX = bx + (Math.abs(x) * MathUtils.signum(bx) - bx) * alpha; + bone.scaleY = by + (Math.abs(y) * MathUtils.signum(by) - by) * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + bx = bone.scaleX; + by = bone.scaleY; + bone.scaleX = bx + (Math.abs(x) * MathUtils.signum(bx) - bx) * alpha; + bone.scaleY = by + (Math.abs(y) * MathUtils.signum(by) - by) * alpha; + break; + case MixBlend.add: + bx = bone.scaleX; + by = bone.scaleY; + bone.scaleX = bx + (Math.abs(x) * MathUtils.signum(bx) - bone.data.scaleX) * alpha; + bone.scaleY = by + (Math.abs(y) * MathUtils.signum(by) - bone.data.scaleY) * alpha; + } + } else { + switch (blend) { + case MixBlend.setup: + bx = Math.abs(bone.data.scaleX) * MathUtils.signum(x); + by = Math.abs(bone.data.scaleY) * MathUtils.signum(y); + bone.scaleX = bx + (x - bx) * alpha; + bone.scaleY = by + (y - by) * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + bx = Math.abs(bone.scaleX) * MathUtils.signum(x); + by = Math.abs(bone.scaleY) * MathUtils.signum(y); + bone.scaleX = bx + (x - bx) * alpha; + bone.scaleY = by + (y - by) * alpha; + break; + case MixBlend.add: + bx = MathUtils.signum(x); + by = MathUtils.signum(y); + bone.scaleX = Math.abs(bone.scaleX) * bx + (x - Math.abs(bone.data.scaleX) * bx) * alpha; + bone.scaleY = Math.abs(bone.scaleY) * by + (y - Math.abs(bone.data.scaleY) * by) * alpha; + } + } + } + } +} + +/** Changes a bone's local {@link Bone#shearX} and {@link Bone#shearY}. */ +export class ShearTimeline extends TranslateTimeline { + constructor(frameCount: number) { + super(frameCount); + } + + override getPropertyId() { + return (TimelineType.shear << 24) + this.boneIndex; + } + + override apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + + const bone = skeleton.bones[this.boneIndex]; + if (!bone.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + bone.shearX = bone.data.shearX; + bone.shearY = bone.data.shearY; + return; + case MixBlend.first: + bone.shearX += (bone.data.shearX - bone.shearX) * alpha; + bone.shearY += (bone.data.shearY - bone.shearY) * alpha; + } + return; + } + + let x = 0, + y = 0; + if (time >= frames[frames.length - ShearTimeline.ENTRIES]) { + // Time is after last frame. + x = frames[frames.length + ShearTimeline.PREV_X]; + y = frames[frames.length + ShearTimeline.PREV_Y]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, ShearTimeline.ENTRIES); + x = frames[frame + ShearTimeline.PREV_X]; + y = frames[frame + ShearTimeline.PREV_Y]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / ShearTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + ShearTimeline.PREV_TIME] - frameTime) + ); + + x = x + (frames[frame + ShearTimeline.X] - x) * percent; + y = y + (frames[frame + ShearTimeline.Y] - y) * percent; + } + switch (blend) { + case MixBlend.setup: + bone.shearX = bone.data.shearX + x * alpha; + bone.shearY = bone.data.shearY + y * alpha; + break; + case MixBlend.first: + case MixBlend.replace: + bone.shearX += (bone.data.shearX + x - bone.shearX) * alpha; + bone.shearY += (bone.data.shearY + y - bone.shearY) * alpha; + break; + case MixBlend.add: + bone.shearX += x * alpha; + bone.shearY += y * alpha; + } + } +} + +/** Changes a slot's {@link Slot#color}. */ +export class ColorTimeline extends CurveTimeline { + static ENTRIES = 5; + static PREV_TIME = -5; + static PREV_R = -4; + static PREV_G = -3; + static PREV_B = -2; + static PREV_A = -1; + static R = 1; + static G = 2; + static B = 3; + static A = 4; + + /** The index of the slot in {@link Skeleton#slots} that will be changed. */ + slotIndex: number; + + /** The time in seconds, red, green, blue, and alpha values for each key frame. */ + frames: ArrayLike; // time, r, g, b, a, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * ColorTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.color << 24) + this.slotIndex; + } + + /** Sets the time in seconds, red, green, blue, and alpha for the specified key frame. */ + setFrame(frameIndex: number, time: number, r: number, g: number, b: number, a: number) { + frameIndex *= ColorTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + ColorTimeline.R] = r; + this.frames[frameIndex + ColorTimeline.G] = g; + this.frames[frameIndex + ColorTimeline.B] = b; + this.frames[frameIndex + ColorTimeline.A] = a; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const slot = skeleton.slots[this.slotIndex]; + if (!slot.bone.active) return; + const frames = this.frames; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + slot.color.setFromColor(slot.data.color); + return; + case MixBlend.first: + const color = slot.color, + setup = slot.data.color; + color.add( + (setup.r - color.r) * alpha, + (setup.g - color.g) * alpha, + (setup.b - color.b) * alpha, + (setup.a - color.a) * alpha + ); + } + return; + } + + let r = 0, + g = 0, + b = 0, + a = 0; + if (time >= frames[frames.length - ColorTimeline.ENTRIES]) { + // Time is after last frame. + const i = frames.length; + r = frames[i + ColorTimeline.PREV_R]; + g = frames[i + ColorTimeline.PREV_G]; + b = frames[i + ColorTimeline.PREV_B]; + a = frames[i + ColorTimeline.PREV_A]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, ColorTimeline.ENTRIES); + r = frames[frame + ColorTimeline.PREV_R]; + g = frames[frame + ColorTimeline.PREV_G]; + b = frames[frame + ColorTimeline.PREV_B]; + a = frames[frame + ColorTimeline.PREV_A]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / ColorTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + ColorTimeline.PREV_TIME] - frameTime) + ); + + r += (frames[frame + ColorTimeline.R] - r) * percent; + g += (frames[frame + ColorTimeline.G] - g) * percent; + b += (frames[frame + ColorTimeline.B] - b) * percent; + a += (frames[frame + ColorTimeline.A] - a) * percent; + } + if (alpha == 1) slot.color.set(r, g, b, a); + else { + const color = slot.color; + if (blend == MixBlend.setup) color.setFromColor(slot.data.color); + color.add((r - color.r) * alpha, (g - color.g) * alpha, (b - color.b) * alpha, (a - color.a) * alpha); + } + } +} + +/** Changes a slot's {@link Slot#color} and {@link Slot#darkColor} for two color tinting. */ +export class TwoColorTimeline extends CurveTimeline { + static ENTRIES = 8; + static PREV_TIME = -8; + static PREV_R = -7; + static PREV_G = -6; + static PREV_B = -5; + static PREV_A = -4; + static PREV_R2 = -3; + static PREV_G2 = -2; + static PREV_B2 = -1; + static R = 1; + static G = 2; + static B = 3; + static A = 4; + static R2 = 5; + static G2 = 6; + static B2 = 7; + + /** The index of the slot in {@link Skeleton#slots()} that will be changed. The {@link Slot#darkColor()} must not be + * null. */ + slotIndex: number; + + /** The time in seconds, red, green, blue, and alpha values of the color, red, green, blue of the dark color, for each key frame. */ + frames: ArrayLike; // time, r, g, b, a, r2, g2, b2, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * TwoColorTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.twoColor << 24) + this.slotIndex; + } + + /** Sets the time in seconds, light, and dark colors for the specified key frame. */ + setFrame( + frameIndex: number, + time: number, + r: number, + g: number, + b: number, + a: number, + r2: number, + g2: number, + b2: number + ) { + frameIndex *= TwoColorTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + TwoColorTimeline.R] = r; + this.frames[frameIndex + TwoColorTimeline.G] = g; + this.frames[frameIndex + TwoColorTimeline.B] = b; + this.frames[frameIndex + TwoColorTimeline.A] = a; + this.frames[frameIndex + TwoColorTimeline.R2] = r2; + this.frames[frameIndex + TwoColorTimeline.G2] = g2; + this.frames[frameIndex + TwoColorTimeline.B2] = b2; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const slot = skeleton.slots[this.slotIndex]; + if (!slot.bone.active) return; + const frames = this.frames; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + slot.color.setFromColor(slot.data.color); + slot.darkColor.setFromColor(slot.data.darkColor); + return; + case MixBlend.first: + const light = slot.color, + dark = slot.darkColor, + setupLight = slot.data.color, + setupDark = slot.data.darkColor; + light.add( + (setupLight.r - light.r) * alpha, + (setupLight.g - light.g) * alpha, + (setupLight.b - light.b) * alpha, + (setupLight.a - light.a) * alpha + ); + dark.add((setupDark.r - dark.r) * alpha, (setupDark.g - dark.g) * alpha, (setupDark.b - dark.b) * alpha, 0); + } + return; + } + + let r = 0, + g = 0, + b = 0, + a = 0, + r2 = 0, + g2 = 0, + b2 = 0; + if (time >= frames[frames.length - TwoColorTimeline.ENTRIES]) { + // Time is after last frame. + const i = frames.length; + r = frames[i + TwoColorTimeline.PREV_R]; + g = frames[i + TwoColorTimeline.PREV_G]; + b = frames[i + TwoColorTimeline.PREV_B]; + a = frames[i + TwoColorTimeline.PREV_A]; + r2 = frames[i + TwoColorTimeline.PREV_R2]; + g2 = frames[i + TwoColorTimeline.PREV_G2]; + b2 = frames[i + TwoColorTimeline.PREV_B2]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, TwoColorTimeline.ENTRIES); + r = frames[frame + TwoColorTimeline.PREV_R]; + g = frames[frame + TwoColorTimeline.PREV_G]; + b = frames[frame + TwoColorTimeline.PREV_B]; + a = frames[frame + TwoColorTimeline.PREV_A]; + r2 = frames[frame + TwoColorTimeline.PREV_R2]; + g2 = frames[frame + TwoColorTimeline.PREV_G2]; + b2 = frames[frame + TwoColorTimeline.PREV_B2]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / TwoColorTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + TwoColorTimeline.PREV_TIME] - frameTime) + ); + + r += (frames[frame + TwoColorTimeline.R] - r) * percent; + g += (frames[frame + TwoColorTimeline.G] - g) * percent; + b += (frames[frame + TwoColorTimeline.B] - b) * percent; + a += (frames[frame + TwoColorTimeline.A] - a) * percent; + r2 += (frames[frame + TwoColorTimeline.R2] - r2) * percent; + g2 += (frames[frame + TwoColorTimeline.G2] - g2) * percent; + b2 += (frames[frame + TwoColorTimeline.B2] - b2) * percent; + } + if (alpha == 1) { + slot.color.set(r, g, b, a); + slot.darkColor.set(r2, g2, b2, 1); + } else { + const light = slot.color, + dark = slot.darkColor; + if (blend == MixBlend.setup) { + light.setFromColor(slot.data.color); + dark.setFromColor(slot.data.darkColor); + } + light.add((r - light.r) * alpha, (g - light.g) * alpha, (b - light.b) * alpha, (a - light.a) * alpha); + dark.add((r2 - dark.r) * alpha, (g2 - dark.g) * alpha, (b2 - dark.b) * alpha, 0); + } + } +} + +/** Changes a slot's {@link Slot#attachment}. */ +export class AttachmentTimeline implements Timeline { + /** The index of the slot in {@link Skeleton#slots} that will be changed. */ + slotIndex: number; + + /** The time in seconds for each key frame. */ + frames: ArrayLike; // time, ... + + /** The attachment name for each key frame. May contain null values to clear the attachment. */ + attachmentNames: Array; + + constructor(frameCount: number) { + this.frames = Utils.newFloatArray(frameCount); + this.attachmentNames = new Array(frameCount); + } + + getPropertyId() { + return (TimelineType.attachment << 24) + this.slotIndex; + } + + /** The number of key frames for this timeline. */ + getFrameCount() { + return this.frames.length; + } + + /** Sets the time in seconds and the attachment name for the specified key frame. */ + setFrame(frameIndex: number, time: number, attachmentName: string) { + this.frames[frameIndex] = time; + this.attachmentNames[frameIndex] = attachmentName; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + events: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const slot = skeleton.slots[this.slotIndex]; + if (!slot.bone.active) return; + if (direction == MixDirection.mixOut) { + if (blend == MixBlend.setup) this.setAttachment(skeleton, slot, slot.data.attachmentName); + return; + } + + const frames = this.frames; + if (time < frames[0]) { + if (blend == MixBlend.setup || blend == MixBlend.first) + this.setAttachment(skeleton, slot, slot.data.attachmentName); + return; + } + + let frameIndex = 0; + if (time >= frames[frames.length - 1]) + // Time is after last frame. + frameIndex = frames.length - 1; + else frameIndex = Animation.binarySearch(frames, time, 1) - 1; + + const attachmentName = this.attachmentNames[frameIndex]; + skeleton.slots[this.slotIndex].setAttachment( + attachmentName == null ? null : skeleton.getAttachment(this.slotIndex, attachmentName) + ); + } + + setAttachment(skeleton: Skeleton, slot: Slot, attachmentName: string) { + slot.attachment = attachmentName == null ? null : skeleton.getAttachment(this.slotIndex, attachmentName); + } +} + +let zeros: ArrayLike = null; + +/** Changes a slot's {@link Slot#deform} to deform a {@link VertexAttachment}. */ +export class DeformTimeline extends CurveTimeline { + /** The index of the slot in {@link Skeleton#getSlots()} that will be changed. */ + slotIndex: number; + + /** The attachment that will be deformed. */ + attachment: VertexAttachment; + + /** The time in seconds for each key frame. */ + frames: ArrayLike; // time, ... + + /** The vertices for each key frame. */ + frameVertices: Array>; + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount); + this.frameVertices = new Array>(frameCount); + if (zeros == null) zeros = Utils.newFloatArray(64); + } + + getPropertyId() { + return (TimelineType.deform << 27) + +this.attachment.id + this.slotIndex; + } + + /** Sets the time in seconds and the vertices for the specified key frame. + * @param vertices Vertex positions for an unweighted VertexAttachment, or deform offsets if it has weights. */ + setFrame(frameIndex: number, time: number, vertices: ArrayLike) { + this.frames[frameIndex] = time; + this.frameVertices[frameIndex] = vertices; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const slot: Slot = skeleton.slots[this.slotIndex]; + if (!slot.bone.active) return; + const slotAttachment: Attachment = slot.getAttachment(); + if ( + !(slotAttachment instanceof VertexAttachment) || + !((slotAttachment).deformAttachment == this.attachment) + ) + return; + + const deformArray: Array = slot.deform; + if (deformArray.length == 0) blend = MixBlend.setup; + + const frameVertices = this.frameVertices; + const vertexCount = frameVertices[0].length; + + const frames = this.frames; + if (time < frames[0]) { + const vertexAttachment = slotAttachment; + switch (blend) { + case MixBlend.setup: + deformArray.length = 0; + return; + case MixBlend.first: + if (alpha == 1) { + deformArray.length = 0; + break; + } + const deform: Array = Utils.setArraySize(deformArray, vertexCount); + if (vertexAttachment.bones == null) { + // Unweighted vertex positions. + const setupVertices = vertexAttachment.vertices; + for (var i = 0; i < vertexCount; i++) deform[i] += (setupVertices[i] - deform[i]) * alpha; + } else { + // Weighted deform offsets. + alpha = 1 - alpha; + for (var i = 0; i < vertexCount; i++) deform[i] *= alpha; + } + } + return; + } + + const deform: Array = Utils.setArraySize(deformArray, vertexCount); + if (time >= frames[frames.length - 1]) { + // Time is after last frame. + const lastVertices = frameVertices[frames.length - 1]; + if (alpha == 1) { + if (blend == MixBlend.add) { + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + deform[i] += lastVertices[i] - setupVertices[i]; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) deform[i] += lastVertices[i]; + } + } else { + Utils.arrayCopy(lastVertices, 0, deform, 0, vertexCount); + } + } else { + switch (blend) { + case MixBlend.setup: { + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + const setup = setupVertices[i]; + deform[i] = setup + (lastVertices[i] - setup) * alpha; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) deform[i] = lastVertices[i] * alpha; + } + break; + } + case MixBlend.first: + case MixBlend.replace: + for (let i = 0; i < vertexCount; i++) deform[i] += (lastVertices[i] - deform[i]) * alpha; + break; + case MixBlend.add: + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + deform[i] += (lastVertices[i] - setupVertices[i]) * alpha; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) deform[i] += lastVertices[i] * alpha; + } + } + } + return; + } + + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time); + const prevVertices = frameVertices[frame - 1]; + const nextVertices = frameVertices[frame]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent(frame - 1, 1 - (time - frameTime) / (frames[frame - 1] - frameTime)); + + if (alpha == 1) { + if (blend == MixBlend.add) { + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] += prev + (nextVertices[i] - prev) * percent - setupVertices[i]; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] += prev + (nextVertices[i] - prev) * percent; + } + } + } else { + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] = prev + (nextVertices[i] - prev) * percent; + } + } + } else { + switch (blend) { + case MixBlend.setup: { + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i], + setup = setupVertices[i]; + deform[i] = setup + (prev + (nextVertices[i] - prev) * percent - setup) * alpha; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] = (prev + (nextVertices[i] - prev) * percent) * alpha; + } + } + break; + } + case MixBlend.first: + case MixBlend.replace: + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] += (prev + (nextVertices[i] - prev) * percent - deform[i]) * alpha; + } + break; + case MixBlend.add: + const vertexAttachment = slotAttachment as VertexAttachment; + if (vertexAttachment.bones == null) { + // Unweighted vertex positions, with alpha. + const setupVertices = vertexAttachment.vertices; + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] += (prev + (nextVertices[i] - prev) * percent - setupVertices[i]) * alpha; + } + } else { + // Weighted deform offsets, with alpha. + for (let i = 0; i < vertexCount; i++) { + const prev = prevVertices[i]; + deform[i] += (prev + (nextVertices[i] - prev) * percent) * alpha; + } + } + } + } + } +} + +/** Fires an {@link Event} when specific animation times are reached. */ +export class EventTimeline implements Timeline { + /** The time in seconds for each key frame. */ + frames: ArrayLike; // time, ... + + /** The event for each key frame. */ + events: Array; + + constructor(frameCount: number) { + this.frames = Utils.newFloatArray(frameCount); + this.events = new Array(frameCount); + } + + getPropertyId() { + return TimelineType.event << 24; + } + + /** The number of key frames for this timeline. */ + getFrameCount() { + return this.frames.length; + } + + /** Sets the time in seconds and the event for the specified key frame. */ + setFrame(frameIndex: number, event: Event) { + this.frames[frameIndex] = event.time; + this.events[frameIndex] = event; + } + + /** Fires events for frames > `lastTime` and <= `time`. */ + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + if (firedEvents == null) return; + const frames = this.frames; + const frameCount = this.frames.length; + + if (lastTime > time) { + // Fire events after last time for looped animations. + this.apply(skeleton, lastTime, Number.MAX_VALUE, firedEvents, alpha, blend, direction); + lastTime = -1; + } else if (lastTime >= frames[frameCount - 1]) + // Last time is after last frame. + return; + if (time < frames[0]) return; // Time is before first frame. + + let frame = 0; + if (lastTime < frames[0]) frame = 0; + else { + frame = Animation.binarySearch(frames, lastTime); + const frameTime = frames[frame]; + while (frame > 0) { + // Fire multiple events with the same frame. + if (frames[frame - 1] != frameTime) break; + frame--; + } + } + for (; frame < frameCount && time >= frames[frame]; frame++) firedEvents.push(this.events[frame]); + } +} + +/** Changes a skeleton's {@link Skeleton#drawOrder}. */ +export class DrawOrderTimeline implements Timeline { + /** The time in seconds for each key frame. */ + frames: ArrayLike; // time, ... + + /** The draw order for each key frame. See {@link #setFrame(int, float, int[])}. */ + drawOrders: Array>; + + constructor(frameCount: number) { + this.frames = Utils.newFloatArray(frameCount); + this.drawOrders = new Array>(frameCount); + } + + getPropertyId() { + return TimelineType.drawOrder << 24; + } + + /** The number of key frames for this timeline. */ + getFrameCount() { + return this.frames.length; + } + + /** Sets the time in seconds and the draw order for the specified key frame. + * @param drawOrder For each slot in {@link Skeleton#slots}, the index of the new draw order. May be null to use setup pose + * draw order. */ + setFrame(frameIndex: number, time: number, drawOrder: Array) { + this.frames[frameIndex] = time; + this.drawOrders[frameIndex] = drawOrder; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const drawOrder: Array = skeleton.drawOrder; + const slots: Array = skeleton.slots; + if (direction == MixDirection.mixOut) { + if (blend == MixBlend.setup) Utils.arrayCopy(skeleton.slots, 0, skeleton.drawOrder, 0, skeleton.slots.length); + return; + } + + const frames = this.frames; + if (time < frames[0]) { + if (blend == MixBlend.setup || blend == MixBlend.first) + Utils.arrayCopy(skeleton.slots, 0, skeleton.drawOrder, 0, skeleton.slots.length); + return; + } + + let frame = 0; + if (time >= frames[frames.length - 1]) + // Time is after last frame. + frame = frames.length - 1; + else frame = Animation.binarySearch(frames, time) - 1; + + const drawOrderToSetupIndex = this.drawOrders[frame]; + if (drawOrderToSetupIndex == null) Utils.arrayCopy(slots, 0, drawOrder, 0, slots.length); + else { + for (let i = 0, n = drawOrderToSetupIndex.length; i < n; i++) drawOrder[i] = slots[drawOrderToSetupIndex[i]]; + } + } +} + +/** Changes an IK constraint's {@link IkConstraint#mix}, {@link IkConstraint#softness}, + * {@link IkConstraint#bendDirection}, {@link IkConstraint#stretch}, and {@link IkConstraint#compress}. */ +export class IkConstraintTimeline extends CurveTimeline { + static ENTRIES = 6; + static PREV_TIME = -6; + static PREV_MIX = -5; + static PREV_SOFTNESS = -4; + static PREV_BEND_DIRECTION = -3; + static PREV_COMPRESS = -2; + static PREV_STRETCH = -1; + static MIX = 1; + static SOFTNESS = 2; + static BEND_DIRECTION = 3; + static COMPRESS = 4; + static STRETCH = 5; + + /** The index of the IK constraint slot in {@link Skeleton#ikConstraints} that will be changed. */ + ikConstraintIndex: number; + + /** The time in seconds, mix, softness, bend direction, compress, and stretch for each key frame. */ + frames: ArrayLike; // time, mix, softness, bendDirection, compress, stretch, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * IkConstraintTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.ikConstraint << 24) + this.ikConstraintIndex; + } + + /** Sets the time in seconds, mix, softness, bend direction, compress, and stretch for the specified key frame. */ + setFrame( + frameIndex: number, + time: number, + mix: number, + softness: number, + bendDirection: number, + compress: boolean, + stretch: boolean + ) { + frameIndex *= IkConstraintTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + IkConstraintTimeline.MIX] = mix; + this.frames[frameIndex + IkConstraintTimeline.SOFTNESS] = softness; + this.frames[frameIndex + IkConstraintTimeline.BEND_DIRECTION] = bendDirection; + this.frames[frameIndex + IkConstraintTimeline.COMPRESS] = compress ? 1 : 0; + this.frames[frameIndex + IkConstraintTimeline.STRETCH] = stretch ? 1 : 0; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + const constraint: IkConstraint = skeleton.ikConstraints[this.ikConstraintIndex]; + if (!constraint.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + constraint.mix = constraint.data.mix; + constraint.softness = constraint.data.softness; + constraint.bendDirection = constraint.data.bendDirection; + constraint.compress = constraint.data.compress; + constraint.stretch = constraint.data.stretch; + return; + case MixBlend.first: + constraint.mix += (constraint.data.mix - constraint.mix) * alpha; + constraint.softness += (constraint.data.softness - constraint.softness) * alpha; + constraint.bendDirection = constraint.data.bendDirection; + constraint.compress = constraint.data.compress; + constraint.stretch = constraint.data.stretch; + } + return; + } + + if (time >= frames[frames.length - IkConstraintTimeline.ENTRIES]) { + // Time is after last frame. + if (blend == MixBlend.setup) { + constraint.mix = + constraint.data.mix + (frames[frames.length + IkConstraintTimeline.PREV_MIX] - constraint.data.mix) * alpha; + constraint.softness = + constraint.data.softness + + (frames[frames.length + IkConstraintTimeline.PREV_SOFTNESS] - constraint.data.softness) * alpha; + if (direction == MixDirection.mixOut) { + constraint.bendDirection = constraint.data.bendDirection; + constraint.compress = constraint.data.compress; + constraint.stretch = constraint.data.stretch; + } else { + constraint.bendDirection = frames[frames.length + IkConstraintTimeline.PREV_BEND_DIRECTION]; + constraint.compress = frames[frames.length + IkConstraintTimeline.PREV_COMPRESS] != 0; + constraint.stretch = frames[frames.length + IkConstraintTimeline.PREV_STRETCH] != 0; + } + } else { + constraint.mix += (frames[frames.length + IkConstraintTimeline.PREV_MIX] - constraint.mix) * alpha; + constraint.softness += + (frames[frames.length + IkConstraintTimeline.PREV_SOFTNESS] - constraint.softness) * alpha; + if (direction == MixDirection.mixIn) { + constraint.bendDirection = frames[frames.length + IkConstraintTimeline.PREV_BEND_DIRECTION]; + constraint.compress = frames[frames.length + IkConstraintTimeline.PREV_COMPRESS] != 0; + constraint.stretch = frames[frames.length + IkConstraintTimeline.PREV_STRETCH] != 0; + } + } + return; + } + + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, IkConstraintTimeline.ENTRIES); + const mix = frames[frame + IkConstraintTimeline.PREV_MIX]; + const softness = frames[frame + IkConstraintTimeline.PREV_SOFTNESS]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / IkConstraintTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + IkConstraintTimeline.PREV_TIME] - frameTime) + ); + + if (blend == MixBlend.setup) { + constraint.mix = + constraint.data.mix + + (mix + (frames[frame + IkConstraintTimeline.MIX] - mix) * percent - constraint.data.mix) * alpha; + constraint.softness = + constraint.data.softness + + (softness + (frames[frame + IkConstraintTimeline.SOFTNESS] - softness) * percent - constraint.data.softness) * + alpha; + if (direction == MixDirection.mixOut) { + constraint.bendDirection = constraint.data.bendDirection; + constraint.compress = constraint.data.compress; + constraint.stretch = constraint.data.stretch; + } else { + constraint.bendDirection = frames[frame + IkConstraintTimeline.PREV_BEND_DIRECTION]; + constraint.compress = frames[frame + IkConstraintTimeline.PREV_COMPRESS] != 0; + constraint.stretch = frames[frame + IkConstraintTimeline.PREV_STRETCH] != 0; + } + } else { + constraint.mix += (mix + (frames[frame + IkConstraintTimeline.MIX] - mix) * percent - constraint.mix) * alpha; + constraint.softness += + (softness + (frames[frame + IkConstraintTimeline.SOFTNESS] - softness) * percent - constraint.softness) * alpha; + if (direction == MixDirection.mixIn) { + constraint.bendDirection = frames[frame + IkConstraintTimeline.PREV_BEND_DIRECTION]; + constraint.compress = frames[frame + IkConstraintTimeline.PREV_COMPRESS] != 0; + constraint.stretch = frames[frame + IkConstraintTimeline.PREV_STRETCH] != 0; + } + } + } +} + +/** Changes a transform constraint's {@link TransformConstraint#rotateMix}, {@link TransformConstraint#translateMix}, + * {@link TransformConstraint#scaleMix}, and {@link TransformConstraint#shearMix}. */ +export class TransformConstraintTimeline extends CurveTimeline { + static ENTRIES = 5; + static PREV_TIME = -5; + static PREV_ROTATE = -4; + static PREV_TRANSLATE = -3; + static PREV_SCALE = -2; + static PREV_SHEAR = -1; + static ROTATE = 1; + static TRANSLATE = 2; + static SCALE = 3; + static SHEAR = 4; + + /** The index of the transform constraint slot in {@link Skeleton#transformConstraints} that will be changed. */ + transformConstraintIndex: number; + + /** The time in seconds, rotate mix, translate mix, scale mix, and shear mix for each key frame. */ + frames: ArrayLike; // time, rotate mix, translate mix, scale mix, shear mix, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * TransformConstraintTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.transformConstraint << 24) + this.transformConstraintIndex; + } + + /** The time in seconds, rotate mix, translate mix, scale mix, and shear mix for the specified key frame. */ + setFrame( + frameIndex: number, + time: number, + rotateMix: number, + translateMix: number, + scaleMix: number, + shearMix: number + ) { + frameIndex *= TransformConstraintTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + TransformConstraintTimeline.ROTATE] = rotateMix; + this.frames[frameIndex + TransformConstraintTimeline.TRANSLATE] = translateMix; + this.frames[frameIndex + TransformConstraintTimeline.SCALE] = scaleMix; + this.frames[frameIndex + TransformConstraintTimeline.SHEAR] = shearMix; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + + const constraint: TransformConstraint = skeleton.transformConstraints[this.transformConstraintIndex]; + if (!constraint.active) return; + if (time < frames[0]) { + const data = constraint.data; + switch (blend) { + case MixBlend.setup: + constraint.rotateMix = data.rotateMix; + constraint.translateMix = data.translateMix; + constraint.scaleMix = data.scaleMix; + constraint.shearMix = data.shearMix; + return; + case MixBlend.first: + constraint.rotateMix += (data.rotateMix - constraint.rotateMix) * alpha; + constraint.translateMix += (data.translateMix - constraint.translateMix) * alpha; + constraint.scaleMix += (data.scaleMix - constraint.scaleMix) * alpha; + constraint.shearMix += (data.shearMix - constraint.shearMix) * alpha; + } + return; + } + + let rotate = 0, + translate = 0, + scale = 0, + shear = 0; + if (time >= frames[frames.length - TransformConstraintTimeline.ENTRIES]) { + // Time is after last frame. + const i = frames.length; + rotate = frames[i + TransformConstraintTimeline.PREV_ROTATE]; + translate = frames[i + TransformConstraintTimeline.PREV_TRANSLATE]; + scale = frames[i + TransformConstraintTimeline.PREV_SCALE]; + shear = frames[i + TransformConstraintTimeline.PREV_SHEAR]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, TransformConstraintTimeline.ENTRIES); + rotate = frames[frame + TransformConstraintTimeline.PREV_ROTATE]; + translate = frames[frame + TransformConstraintTimeline.PREV_TRANSLATE]; + scale = frames[frame + TransformConstraintTimeline.PREV_SCALE]; + shear = frames[frame + TransformConstraintTimeline.PREV_SHEAR]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / TransformConstraintTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + TransformConstraintTimeline.PREV_TIME] - frameTime) + ); + + rotate += (frames[frame + TransformConstraintTimeline.ROTATE] - rotate) * percent; + translate += (frames[frame + TransformConstraintTimeline.TRANSLATE] - translate) * percent; + scale += (frames[frame + TransformConstraintTimeline.SCALE] - scale) * percent; + shear += (frames[frame + TransformConstraintTimeline.SHEAR] - shear) * percent; + } + if (blend == MixBlend.setup) { + const data = constraint.data; + constraint.rotateMix = data.rotateMix + (rotate - data.rotateMix) * alpha; + constraint.translateMix = data.translateMix + (translate - data.translateMix) * alpha; + constraint.scaleMix = data.scaleMix + (scale - data.scaleMix) * alpha; + constraint.shearMix = data.shearMix + (shear - data.shearMix) * alpha; + } else { + constraint.rotateMix += (rotate - constraint.rotateMix) * alpha; + constraint.translateMix += (translate - constraint.translateMix) * alpha; + constraint.scaleMix += (scale - constraint.scaleMix) * alpha; + constraint.shearMix += (shear - constraint.shearMix) * alpha; + } + } +} + +/** Changes a path constraint's {@link PathConstraint#position}. */ +export class PathConstraintPositionTimeline extends CurveTimeline { + static ENTRIES = 2; + static PREV_TIME = -2; + static PREV_VALUE = -1; + static VALUE = 1; + + /** The index of the path constraint slot in {@link Skeleton#pathConstraints} that will be changed. */ + pathConstraintIndex: number; + + /** The time in seconds and path constraint position for each key frame. */ + frames: ArrayLike; // time, position, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * PathConstraintPositionTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.pathConstraintPosition << 24) + this.pathConstraintIndex; + } + + /** Sets the time in seconds and path constraint position for the specified key frame. */ + setFrame(frameIndex: number, time: number, value: number) { + frameIndex *= PathConstraintPositionTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + PathConstraintPositionTimeline.VALUE] = value; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + const constraint: PathConstraint = skeleton.pathConstraints[this.pathConstraintIndex]; + if (!constraint.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + constraint.position = constraint.data.position; + return; + case MixBlend.first: + constraint.position += (constraint.data.position - constraint.position) * alpha; + } + return; + } + + let position = 0; + if (time >= frames[frames.length - PathConstraintPositionTimeline.ENTRIES]) + // Time is after last frame. + position = frames[frames.length + PathConstraintPositionTimeline.PREV_VALUE]; + else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, PathConstraintPositionTimeline.ENTRIES); + position = frames[frame + PathConstraintPositionTimeline.PREV_VALUE]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / PathConstraintPositionTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + PathConstraintPositionTimeline.PREV_TIME] - frameTime) + ); + + position += (frames[frame + PathConstraintPositionTimeline.VALUE] - position) * percent; + } + if (blend == MixBlend.setup) + constraint.position = constraint.data.position + (position - constraint.data.position) * alpha; + else constraint.position += (position - constraint.position) * alpha; + } +} + +/** Changes a path constraint's {@link PathConstraint#spacing}. */ +export class PathConstraintSpacingTimeline extends PathConstraintPositionTimeline { + constructor(frameCount: number) { + super(frameCount); + } + + override getPropertyId() { + return (TimelineType.pathConstraintSpacing << 24) + this.pathConstraintIndex; + } + + override apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + const constraint: PathConstraint = skeleton.pathConstraints[this.pathConstraintIndex]; + if (!constraint.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + constraint.spacing = constraint.data.spacing; + return; + case MixBlend.first: + constraint.spacing += (constraint.data.spacing - constraint.spacing) * alpha; + } + return; + } + + let spacing = 0; + if (time >= frames[frames.length - PathConstraintSpacingTimeline.ENTRIES]) + // Time is after last frame. + spacing = frames[frames.length + PathConstraintSpacingTimeline.PREV_VALUE]; + else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, PathConstraintSpacingTimeline.ENTRIES); + spacing = frames[frame + PathConstraintSpacingTimeline.PREV_VALUE]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / PathConstraintSpacingTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + PathConstraintSpacingTimeline.PREV_TIME] - frameTime) + ); + + spacing += (frames[frame + PathConstraintSpacingTimeline.VALUE] - spacing) * percent; + } + + if (blend == MixBlend.setup) + constraint.spacing = constraint.data.spacing + (spacing - constraint.data.spacing) * alpha; + else constraint.spacing += (spacing - constraint.spacing) * alpha; + } +} + +/** Changes a transform constraint's {@link PathConstraint#rotateMix} and + * {@link TransformConstraint#translateMix}. */ +export class PathConstraintMixTimeline extends CurveTimeline { + static ENTRIES = 3; + static PREV_TIME = -3; + static PREV_ROTATE = -2; + static PREV_TRANSLATE = -1; + static ROTATE = 1; + static TRANSLATE = 2; + + /** The index of the path constraint slot in {@link Skeleton#getPathConstraints()} that will be changed. */ + pathConstraintIndex: number; + + /** The time in seconds, rotate mix, and translate mix for each key frame. */ + frames: ArrayLike; // time, rotate mix, translate mix, ... + + constructor(frameCount: number) { + super(frameCount); + this.frames = Utils.newFloatArray(frameCount * PathConstraintMixTimeline.ENTRIES); + } + + getPropertyId() { + return (TimelineType.pathConstraintMix << 24) + this.pathConstraintIndex; + } + + /** The time in seconds, rotate mix, and translate mix for the specified key frame. */ + setFrame(frameIndex: number, time: number, rotateMix: number, translateMix: number) { + frameIndex *= PathConstraintMixTimeline.ENTRIES; + this.frames[frameIndex] = time; + this.frames[frameIndex + PathConstraintMixTimeline.ROTATE] = rotateMix; + this.frames[frameIndex + PathConstraintMixTimeline.TRANSLATE] = translateMix; + } + + apply( + skeleton: Skeleton, + lastTime: number, + time: number, + firedEvents: Array, + alpha: number, + blend: MixBlend, + direction: MixDirection + ) { + const frames = this.frames; + const constraint: PathConstraint = skeleton.pathConstraints[this.pathConstraintIndex]; + if (!constraint.active) return; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + constraint.rotateMix = constraint.data.rotateMix; + constraint.translateMix = constraint.data.translateMix; + return; + case MixBlend.first: + constraint.rotateMix += (constraint.data.rotateMix - constraint.rotateMix) * alpha; + constraint.translateMix += (constraint.data.translateMix - constraint.translateMix) * alpha; + } + return; + } + + let rotate = 0, + translate = 0; + if (time >= frames[frames.length - PathConstraintMixTimeline.ENTRIES]) { + // Time is after last frame. + rotate = frames[frames.length + PathConstraintMixTimeline.PREV_ROTATE]; + translate = frames[frames.length + PathConstraintMixTimeline.PREV_TRANSLATE]; + } else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, PathConstraintMixTimeline.ENTRIES); + rotate = frames[frame + PathConstraintMixTimeline.PREV_ROTATE]; + translate = frames[frame + PathConstraintMixTimeline.PREV_TRANSLATE]; + const frameTime = frames[frame]; + const percent = this.getCurvePercent( + frame / PathConstraintMixTimeline.ENTRIES - 1, + 1 - (time - frameTime) / (frames[frame + PathConstraintMixTimeline.PREV_TIME] - frameTime) + ); + + rotate += (frames[frame + PathConstraintMixTimeline.ROTATE] - rotate) * percent; + translate += (frames[frame + PathConstraintMixTimeline.TRANSLATE] - translate) * percent; + } + + if (blend == MixBlend.setup) { + constraint.rotateMix = constraint.data.rotateMix + (rotate - constraint.data.rotateMix) * alpha; + constraint.translateMix = constraint.data.translateMix + (translate - constraint.data.translateMix) * alpha; + } else { + constraint.rotateMix += (rotate - constraint.rotateMix) * alpha; + constraint.translateMix += (translate - constraint.translateMix) * alpha; + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/AnimationState.ts b/packages/spine-core-3.8/src/spine-core/AnimationState.ts new file mode 100644 index 0000000000..7bb6b64177 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/AnimationState.ts @@ -0,0 +1,1193 @@ +import { AnimationStateData } from "./AnimationStateData"; +import { IntSet, Pool, Utils, MathUtils } from "./Utils"; +import { Skeleton } from "./Skeleton"; +import { + MixBlend, + AttachmentTimeline, + MixDirection, + RotateTimeline, + DrawOrderTimeline, + Timeline, + EventTimeline +} from "./Animation"; +import { Slot } from "./Slot"; +import { Animation } from "./Animation"; +import { Event } from "./Event"; + +/** Applies animations over time, queues animations for later playback, mixes (crossfading) between animations, and applies + * multiple animations on top of each other (layering). + * + * See [Applying Animations](http://esotericsoftware.com/spine-applying-animations/) in the Spine Runtimes Guide. */ +export class AnimationState { + static emptyAnimation = new Animation("", [], 0); + + /** 1. A previously applied timeline has set this property. + * + * Result: Mix from the current pose to the timeline pose. */ + static SUBSEQUENT = 0; + /** 1. This is the first timeline to set this property. + * 2. The next track entry applied after this one does not have a timeline to set this property. + * + * Result: Mix from the setup pose to the timeline pose. */ + static FIRST = 1; + /** 1) A previously applied timeline has set this property.
+ * 2) The next track entry to be applied does have a timeline to set this property.
+ * 3) The next track entry after that one does not have a timeline to set this property.
+ * Result: Mix from the current pose to the timeline pose, but do not mix out. This avoids "dipping" when crossfading + * animations that key the same property. A subsequent timeline will set this property using a mix. */ + static HOLD_SUBSEQUENT = 2; + /** 1) This is the first timeline to set this property.
+ * 2) The next track entry to be applied does have a timeline to set this property.
+ * 3) The next track entry after that one does not have a timeline to set this property.
+ * Result: Mix from the setup pose to the timeline pose, but do not mix out. This avoids "dipping" when crossfading animations + * that key the same property. A subsequent timeline will set this property using a mix. */ + static HOLD_FIRST = 3; + /** 1. This is the first timeline to set this property. + * 2. The next track entry to be applied does have a timeline to set this property. + * 3. The next track entry after that one does have a timeline to set this property. + * 4. timelineHoldMix stores the first subsequent track entry that does not have a timeline to set this property. + * + * Result: The same as HOLD except the mix percentage from the timelineHoldMix track entry is used. This handles when more than + * 2 track entries in a row have a timeline that sets the same property. + * + * Eg, A -> B -> C -> D where A, B, and C have a timeline setting same property, but D does not. When A is applied, to avoid + * "dipping" A is not mixed out, however D (the first entry that doesn't set the property) mixing in is used to mix out A + * (which affects B and C). Without using D to mix out, A would be applied fully until mixing completes, then snap into + * place. */ + static HOLD_MIX = 4; + + static SETUP = 1; + static CURRENT = 2; + + /** The AnimationStateData to look up mix durations. */ + data: AnimationStateData; + + /** The list of tracks that currently have animations, which may contain null entries. */ + tracks = new Array(); + + /** Multiplier for the delta time when the animation state is updated, causing time for all animations and mixes to play slower + * or faster. Defaults to 1. + * + * See TrackEntry {@link TrackEntry#timeScale} for affecting a single animation. */ + timeScale = 1; + unkeyedState = 0; + + events = new Array(); + listeners = new Array(); + queue = new EventQueue(this); + propertyIDs = new IntSet(); + animationsChanged = false; + + trackEntryPool = new Pool(() => new TrackEntry()); + + constructor(data: AnimationStateData) { + this.data = data; + } + + /** Increments each track entry {@link TrackEntry#trackTime()}, setting queued animations as current if needed. */ + update(delta: number) { + delta *= this.timeScale; + const tracks = this.tracks; + for (let i = 0, n = tracks.length; i < n; i++) { + const current = tracks[i]; + if (current == null) continue; + + current.animationLast = current.nextAnimationLast; + current.trackLast = current.nextTrackLast; + + let currentDelta = delta * current.timeScale; + + if (current.delay > 0) { + current.delay -= currentDelta; + if (current.delay > 0) continue; + currentDelta = -current.delay; + current.delay = 0; + } + + let next = current.next; + if (next != null) { + // When the next entry's delay is passed, change to the next entry, preserving leftover time. + const nextTime = current.trackLast - next.delay; + if (nextTime >= 0) { + next.delay = 0; + next.trackTime += current.timeScale == 0 ? 0 : (nextTime / current.timeScale + delta) * next.timeScale; + current.trackTime += currentDelta; + this.setCurrent(i, next, true); + while (next.mixingFrom != null) { + next.mixTime += delta; + next = next.mixingFrom; + } + continue; + } + } else if (current.trackLast >= current.trackEnd && current.mixingFrom == null) { + tracks[i] = null; + this.queue.end(current); + this.disposeNext(current); + continue; + } + if (current.mixingFrom != null && this.updateMixingFrom(current, delta)) { + // End mixing from entries once all have completed. + let from = current.mixingFrom; + current.mixingFrom = null; + if (from != null) from.mixingTo = null; + while (from != null) { + this.queue.end(from); + from = from.mixingFrom; + } + } + + current.trackTime += currentDelta; + } + + this.queue.drain(); + } + + /** Returns true when all mixing from entries are complete. */ + updateMixingFrom(to: TrackEntry, delta: number): boolean { + const from = to.mixingFrom; + if (from == null) return true; + + const finished = this.updateMixingFrom(from, delta); + + from.animationLast = from.nextAnimationLast; + from.trackLast = from.nextTrackLast; + + // Require mixTime > 0 to ensure the mixing from entry was applied at least once. + if (to.mixTime > 0 && to.mixTime >= to.mixDuration) { + // Require totalAlpha == 0 to ensure mixing is complete, unless mixDuration == 0 (the transition is a single frame). + if (from.totalAlpha == 0 || to.mixDuration == 0) { + to.mixingFrom = from.mixingFrom; + if (from.mixingFrom != null) from.mixingFrom.mixingTo = to; + to.interruptAlpha = from.interruptAlpha; + this.queue.end(from); + } + return finished; + } + + from.trackTime += delta * from.timeScale; + to.mixTime += delta; + return false; + } + + /** Poses the skeleton using the track entry animations. There are no side effects other than invoking listeners, so the + * animation state can be applied to multiple skeletons to pose them identically. + * @returns True if any animations were applied. */ + apply(skeleton: Skeleton): boolean { + if (skeleton == null) throw new Error("skeleton cannot be null."); + if (this.animationsChanged) this._animationsChanged(); + + const events = this.events; + const tracks = this.tracks; + let applied = false; + + for (let i = 0, n = tracks.length; i < n; i++) { + const current = tracks[i]; + if (current == null || current.delay > 0) continue; + applied = true; + const blend: MixBlend = i == 0 ? MixBlend.first : current.mixBlend; + + // Apply mixing from entries first. + let mix = current.alpha; + if (current.mixingFrom != null) mix *= this.applyMixingFrom(current, skeleton, blend); + else if (current.trackTime >= current.trackEnd && current.next == null) mix = 0; + + // Apply current entry. + const animationLast = current.animationLast, + animationTime = current.getAnimationTime(); + const timelineCount = current.animation.timelines.length; + const timelines = current.animation.timelines; + if ((i == 0 && mix == 1) || blend == MixBlend.add) { + for (let ii = 0; ii < timelineCount; ii++) { + // Fixes issue #302 on IOS9 where mix, blend sometimes became undefined and caused assets + // to sometimes stop rendering when using color correction, as their RGBA values become NaN. + // (https://github.com/pixijs/pixi-spine/issues/302) + Utils.webkit602BugfixHelper(mix, blend); + const timeline = timelines[ii]; + if (timeline instanceof AttachmentTimeline) + this.applyAttachmentTimeline(timeline, skeleton, animationTime, blend, true); + else timeline.apply(skeleton, animationLast, animationTime, events, mix, blend, MixDirection.mixIn); + } + } else { + const timelineMode = current.timelineMode; + + const firstFrame = current.timelinesRotation.length == 0; + if (firstFrame) Utils.setArraySize(current.timelinesRotation, timelineCount << 1, null); + const timelinesRotation = current.timelinesRotation; + + for (let ii = 0; ii < timelineCount; ii++) { + const timeline = timelines[ii]; + const timelineBlend = timelineMode[ii] == AnimationState.SUBSEQUENT ? blend : MixBlend.setup; + if (timeline instanceof RotateTimeline) { + this.applyRotateTimeline( + timeline, + skeleton, + animationTime, + mix, + timelineBlend, + timelinesRotation, + ii << 1, + firstFrame + ); + } else if (timeline instanceof AttachmentTimeline) { + this.applyAttachmentTimeline(timeline, skeleton, animationTime, blend, true); + } else { + // This fixes the WebKit 602 specific issue described at http://esotericsoftware.com/forum/iOS-10-disappearing-graphics-10109 + Utils.webkit602BugfixHelper(mix, blend); + timeline.apply(skeleton, animationLast, animationTime, events, mix, timelineBlend, MixDirection.mixIn); + } + } + } + this.queueEvents(current, animationTime); + events.length = 0; + current.nextAnimationLast = animationTime; + current.nextTrackLast = current.trackTime; + } + + // Set slots attachments to the setup pose, if needed. This occurs if an animation that is mixing out sets attachments so + // subsequent timelines see any deform, but the subsequent timelines don't set an attachment (eg they are also mixing out or + // the time is before the first key). + const setupState = this.unkeyedState + AnimationState.SETUP; + const slots = skeleton.slots; + for (let i = 0, n = skeleton.slots.length; i < n; i++) { + const slot = slots[i]; + if (slot.attachmentState == setupState) { + const attachmentName = slot.data.attachmentName; + slot.attachment = attachmentName == null ? null : skeleton.getAttachment(slot.data.index, attachmentName); + } + } + this.unkeyedState += 2; // Increasing after each use avoids the need to reset attachmentState for every slot. + + this.queue.drain(); + return applied; + } + + applyMixingFrom(to: TrackEntry, skeleton: Skeleton, blend: MixBlend) { + const from = to.mixingFrom; + if (from.mixingFrom != null) this.applyMixingFrom(from, skeleton, blend); + + let mix = 0; + if (to.mixDuration == 0) { + // Single frame mix to undo mixingFrom changes. + mix = 1; + if (blend == MixBlend.first) blend = MixBlend.setup; + } else { + mix = to.mixTime / to.mixDuration; + if (mix > 1) mix = 1; + if (blend != MixBlend.first) blend = from.mixBlend; + } + + const events = mix < from.eventThreshold ? this.events : null; + const attachments = mix < from.attachmentThreshold, + drawOrder = mix < from.drawOrderThreshold; + const animationLast = from.animationLast, + animationTime = from.getAnimationTime(); + const timelineCount = from.animation.timelines.length; + const timelines = from.animation.timelines; + const alphaHold = from.alpha * to.interruptAlpha, + alphaMix = alphaHold * (1 - mix); + if (blend == MixBlend.add) { + for (let i = 0; i < timelineCount; i++) + timelines[i].apply(skeleton, animationLast, animationTime, events, alphaMix, blend, MixDirection.mixOut); + } else { + const timelineMode = from.timelineMode; + const timelineHoldMix = from.timelineHoldMix; + + const firstFrame = from.timelinesRotation.length == 0; + if (firstFrame) Utils.setArraySize(from.timelinesRotation, timelineCount << 1, null); + const timelinesRotation = from.timelinesRotation; + + from.totalAlpha = 0; + for (let i = 0; i < timelineCount; i++) { + const timeline = timelines[i]; + let direction = MixDirection.mixOut; + let timelineBlend: MixBlend; + let alpha = 0; + switch (timelineMode[i]) { + case AnimationState.SUBSEQUENT: + if (!drawOrder && timeline instanceof DrawOrderTimeline) continue; + timelineBlend = blend; + alpha = alphaMix; + break; + case AnimationState.FIRST: + timelineBlend = MixBlend.setup; + alpha = alphaMix; + break; + case AnimationState.HOLD_SUBSEQUENT: + timelineBlend = blend; + alpha = alphaHold; + break; + case AnimationState.HOLD_FIRST: + timelineBlend = MixBlend.setup; + alpha = alphaHold; + break; + default: + timelineBlend = MixBlend.setup; + const holdMix = timelineHoldMix[i]; + alpha = alphaHold * Math.max(0, 1 - holdMix.mixTime / holdMix.mixDuration); + break; + } + from.totalAlpha += alpha; + + if (timeline instanceof RotateTimeline) + this.applyRotateTimeline( + timeline, + skeleton, + animationTime, + alpha, + timelineBlend, + timelinesRotation, + i << 1, + firstFrame + ); + else if (timeline instanceof AttachmentTimeline) + this.applyAttachmentTimeline(timeline, skeleton, animationTime, timelineBlend, attachments); + else { + // This fixes the WebKit 602 specific issue described at http://esotericsoftware.com/forum/iOS-10-disappearing-graphics-10109 + Utils.webkit602BugfixHelper(alpha, blend); + if (drawOrder && timeline instanceof DrawOrderTimeline && timelineBlend == MixBlend.setup) + direction = MixDirection.mixIn; + timeline.apply(skeleton, animationLast, animationTime, events, alpha, timelineBlend, direction); + } + } + } + + if (to.mixDuration > 0) this.queueEvents(from, animationTime); + this.events.length = 0; + from.nextAnimationLast = animationTime; + from.nextTrackLast = from.trackTime; + + return mix; + } + + applyAttachmentTimeline( + timeline: AttachmentTimeline, + skeleton: Skeleton, + time: number, + blend: MixBlend, + attachments: boolean + ) { + const slot = skeleton.slots[timeline.slotIndex]; + if (!slot.bone.active) return; + + const frames = timeline.frames; + if (time < frames[0]) { + // Time is before first frame. + if (blend == MixBlend.setup || blend == MixBlend.first) + this.setAttachment(skeleton, slot, slot.data.attachmentName, attachments); + } else { + let frameIndex; + if (time >= frames[frames.length - 1]) + // Time is after last frame. + frameIndex = frames.length - 1; + else frameIndex = Animation.binarySearch(frames, time) - 1; + this.setAttachment(skeleton, slot, timeline.attachmentNames[frameIndex], attachments); + } + + // If an attachment wasn't set (ie before the first frame or attachments is false), set the setup attachment later. + if (slot.attachmentState <= this.unkeyedState) slot.attachmentState = this.unkeyedState + AnimationState.SETUP; + } + + setAttachment(skeleton: Skeleton, slot: Slot, attachmentName: string, attachments: boolean) { + slot.attachment = attachmentName == null ? null : skeleton.getAttachment(slot.data.index, attachmentName); + if (attachments) slot.attachmentState = this.unkeyedState + AnimationState.CURRENT; + } + + applyRotateTimeline( + timeline: Timeline, + skeleton: Skeleton, + time: number, + alpha: number, + blend: MixBlend, + timelinesRotation: Array, + i: number, + firstFrame: boolean + ) { + if (firstFrame) timelinesRotation[i] = 0; + + if (alpha == 1) { + timeline.apply(skeleton, 0, time, null, 1, blend, MixDirection.mixIn); + return; + } + + const rotateTimeline = timeline as RotateTimeline; + const frames = rotateTimeline.frames; + const bone = skeleton.bones[rotateTimeline.boneIndex]; + if (!bone.active) return; + let r1 = 0, + r2 = 0; + if (time < frames[0]) { + switch (blend) { + case MixBlend.setup: + bone.rotation = bone.data.rotation; + default: + return; + case MixBlend.first: + r1 = bone.rotation; + r2 = bone.data.rotation; + } + } else { + r1 = blend == MixBlend.setup ? bone.data.rotation : bone.rotation; + if (time >= frames[frames.length - RotateTimeline.ENTRIES]) + // Time is after last frame. + r2 = bone.data.rotation + frames[frames.length + RotateTimeline.PREV_ROTATION]; + else { + // Interpolate between the previous frame and the current frame. + const frame = Animation.binarySearch(frames, time, RotateTimeline.ENTRIES); + const prevRotation = frames[frame + RotateTimeline.PREV_ROTATION]; + const frameTime = frames[frame]; + const percent = rotateTimeline.getCurvePercent( + (frame >> 1) - 1, + 1 - (time - frameTime) / (frames[frame + RotateTimeline.PREV_TIME] - frameTime) + ); + + r2 = frames[frame + RotateTimeline.ROTATION] - prevRotation; + r2 -= (16384 - ((16384.499999999996 - r2 / 360) | 0)) * 360; + r2 = prevRotation + r2 * percent + bone.data.rotation; + r2 -= (16384 - ((16384.499999999996 - r2 / 360) | 0)) * 360; + } + } + + // Mix between rotations using the direction of the shortest route on the first frame while detecting crosses. + let total = 0, + diff = r2 - r1; + diff -= (16384 - ((16384.499999999996 - diff / 360) | 0)) * 360; + if (diff == 0) { + total = timelinesRotation[i]; + } else { + let lastTotal = 0, + lastDiff = 0; + if (firstFrame) { + lastTotal = 0; + lastDiff = diff; + } else { + lastTotal = timelinesRotation[i]; // Angle and direction of mix, including loops. + lastDiff = timelinesRotation[i + 1]; // Difference between bones. + } + let current = diff > 0, + dir = lastTotal >= 0; + // Detect cross at 0 (not 180). + if (MathUtils.signum(lastDiff) != MathUtils.signum(diff) && Math.abs(lastDiff) <= 90) { + // A cross after a 360 rotation is a loop. + if (Math.abs(lastTotal) > 180) lastTotal += 360 * MathUtils.signum(lastTotal); + dir = current; + } + total = diff + lastTotal - (lastTotal % 360); // Store loops as part of lastTotal. + if (dir != current) total += 360 * MathUtils.signum(lastTotal); + timelinesRotation[i] = total; + } + timelinesRotation[i + 1] = diff; + r1 += total * alpha; + bone.rotation = r1 - (16384 - ((16384.499999999996 - r1 / 360) | 0)) * 360; + } + + queueEvents(entry: TrackEntry, animationTime: number) { + const animationStart = entry.animationStart, + animationEnd = entry.animationEnd; + const duration = animationEnd - animationStart; + const trackLastWrapped = entry.trackLast % duration; + + // Queue events before complete. + const events = this.events; + let i = 0, + n = events.length; + for (; i < n; i++) { + const event = events[i]; + if (event.time < trackLastWrapped) break; + if (event.time > animationEnd) continue; // Discard events outside animation start/end. + this.queue.event(entry, event); + } + + // Queue complete if completed a loop iteration or the animation. + let complete = false; + if (entry.loop) complete = duration == 0 || trackLastWrapped > entry.trackTime % duration; + else complete = animationTime >= animationEnd && entry.animationLast < animationEnd; + if (complete) this.queue.complete(entry); + + // Queue events after complete. + for (; i < n; i++) { + const event = events[i]; + if (event.time < animationStart) continue; // Discard events outside animation start/end. + this.queue.event(entry, events[i]); + } + } + + /** Removes all animations from all tracks, leaving skeletons in their current pose. + * + * It may be desired to use {@link AnimationState#setEmptyAnimation()} to mix the skeletons back to the setup pose, + * rather than leaving them in their current pose. */ + clearTracks() { + const oldDrainDisabled = this.queue.drainDisabled; + this.queue.drainDisabled = true; + for (let i = 0, n = this.tracks.length; i < n; i++) this.clearTrack(i); + this.tracks.length = 0; + this.queue.drainDisabled = oldDrainDisabled; + this.queue.drain(); + } + + /** Removes all animations from the track, leaving skeletons in their current pose. + * + * It may be desired to use {@link AnimationState#setEmptyAnimation()} to mix the skeletons back to the setup pose, + * rather than leaving them in their current pose. */ + clearTrack(trackIndex: number) { + if (trackIndex >= this.tracks.length) return; + const current = this.tracks[trackIndex]; + if (current == null) return; + + this.queue.end(current); + + this.disposeNext(current); + + let entry = current; + while (true) { + const from = entry.mixingFrom; + if (from == null) break; + this.queue.end(from); + entry.mixingFrom = null; + entry.mixingTo = null; + entry = from; + } + + this.tracks[current.trackIndex] = null; + + this.queue.drain(); + } + + setCurrent(index: number, current: TrackEntry, interrupt: boolean) { + const from = this.expandToIndex(index); + this.tracks[index] = current; + + if (from != null) { + if (interrupt) this.queue.interrupt(from); + current.mixingFrom = from; + from.mixingTo = current; + current.mixTime = 0; + + // Store the interrupted mix percentage. + if (from.mixingFrom != null && from.mixDuration > 0) + current.interruptAlpha *= Math.min(1, from.mixTime / from.mixDuration); + + from.timelinesRotation.length = 0; // Reset rotation for mixing out, in case entry was mixed in. + } + + this.queue.start(current); + } + + /** Sets an animation by name. + * + * {@link #setAnimationWith(}. */ + setAnimation(trackIndex: number, animationName: string, loop: boolean) { + const animation = this.data.skeletonData.findAnimation(animationName); + if (animation == null) throw new Error("Animation not found: " + animationName); + return this.setAnimationWith(trackIndex, animation, loop); + } + + /** Sets the current animation for a track, discarding any queued animations. If the formerly current track entry was never + * applied to a skeleton, it is replaced (not mixed from). + * @param loop If true, the animation will repeat. If false it will not, instead its last frame is applied if played beyond its + * duration. In either case {@link TrackEntry#trackEnd} determines when the track is cleared. + * @returns A track entry to allow further customization of animation playback. References to the track entry must not be kept + * after the {@link AnimationStateListener#dispose()} event occurs. */ + setAnimationWith(trackIndex: number, animation: Animation, loop: boolean) { + if (animation == null) throw new Error("animation cannot be null."); + let interrupt = true; + let current = this.expandToIndex(trackIndex); + if (current != null) { + if (current.nextTrackLast == -1) { + // Don't mix from an entry that was never applied. + this.tracks[trackIndex] = current.mixingFrom; + this.queue.interrupt(current); + this.queue.end(current); + this.disposeNext(current); + current = current.mixingFrom; + interrupt = false; + } else this.disposeNext(current); + } + const entry = this.trackEntry(trackIndex, animation, loop, current); + this.setCurrent(trackIndex, entry, interrupt); + this.queue.drain(); + return entry; + } + + /** Queues an animation by name. + * + * See {@link #addAnimationWith()}. */ + addAnimation(trackIndex: number, animationName: string, loop: boolean, delay: number) { + const animation = this.data.skeletonData.findAnimation(animationName); + if (animation == null) throw new Error("Animation not found: " + animationName); + return this.addAnimationWith(trackIndex, animation, loop, delay); + } + + /** Adds an animation to be played after the current or last queued animation for a track. If the track is empty, it is + * equivalent to calling {@link #setAnimationWith()}. + * @param delay If > 0, sets {@link TrackEntry#delay}. If <= 0, the delay set is the duration of the previous track entry + * minus any mix duration (from the {@link AnimationStateData}) plus the specified `delay` (ie the mix + * ends at (`delay` = 0) or before (`delay` < 0) the previous track entry duration). If the + * previous entry is looping, its next loop completion is used instead of its duration. + * @returns A track entry to allow further customization of animation playback. References to the track entry must not be kept + * after the {@link AnimationStateListener#dispose()} event occurs. */ + addAnimationWith(trackIndex: number, animation: Animation, loop: boolean, delay: number) { + if (animation == null) throw new Error("animation cannot be null."); + + let last = this.expandToIndex(trackIndex); + if (last != null) { + while (last.next != null) last = last.next; + } + + const entry = this.trackEntry(trackIndex, animation, loop, last); + + if (last == null) { + this.setCurrent(trackIndex, entry, true); + this.queue.drain(); + } else { + last.next = entry; + if (delay <= 0) { + const duration = last.animationEnd - last.animationStart; + if (duration != 0) { + if (last.loop) delay += duration * (1 + ((last.trackTime / duration) | 0)); + else delay += Math.max(duration, last.trackTime); + delay -= this.data.getMix(last.animation, animation); + } else delay = last.trackTime; + } + } + + entry.delay = delay; + return entry; + } + + /** Sets an empty animation for a track, discarding any queued animations, and sets the track entry's + * {@link TrackEntry#mixduration}. An empty animation has no timelines and serves as a placeholder for mixing in or out. + * + * Mixing out is done by setting an empty animation with a mix duration using either {@link #setEmptyAnimation()}, + * {@link #setEmptyAnimations()}, or {@link #addEmptyAnimation()}. Mixing to an empty animation causes + * the previous animation to be applied less and less over the mix duration. Properties keyed in the previous animation + * transition to the value from lower tracks or to the setup pose value if no lower tracks key the property. A mix duration of + * 0 still mixes out over one frame. + * + * Mixing in is done by first setting an empty animation, then adding an animation using + * {@link #addAnimation()} and on the returned track entry, set the + * {@link TrackEntry#setMixDuration()}. Mixing from an empty animation causes the new animation to be applied more and + * more over the mix duration. Properties keyed in the new animation transition from the value from lower tracks or from the + * setup pose value if no lower tracks key the property to the value keyed in the new animation. */ + setEmptyAnimation(trackIndex: number, mixDuration: number) { + const entry = this.setAnimationWith(trackIndex, AnimationState.emptyAnimation, false); + entry.mixDuration = mixDuration; + entry.trackEnd = mixDuration; + return entry; + } + + /** Adds an empty animation to be played after the current or last queued animation for a track, and sets the track entry's + * {@link TrackEntry#mixDuration}. If the track is empty, it is equivalent to calling + * {@link #setEmptyAnimation()}. + * + * See {@link #setEmptyAnimation()}. + * @param delay If > 0, sets {@link TrackEntry#delay}. If <= 0, the delay set is the duration of the previous track entry + * minus any mix duration plus the specified `delay` (ie the mix ends at (`delay` = 0) or + * before (`delay` < 0) the previous track entry duration). If the previous entry is looping, its next + * loop completion is used instead of its duration. + * @return A track entry to allow further customization of animation playback. References to the track entry must not be kept + * after the {@link AnimationStateListener#dispose()} event occurs. */ + addEmptyAnimation(trackIndex: number, mixDuration: number, delay: number) { + if (delay <= 0) delay -= mixDuration; + const entry = this.addAnimationWith(trackIndex, AnimationState.emptyAnimation, false, delay); + entry.mixDuration = mixDuration; + entry.trackEnd = mixDuration; + return entry; + } + + /** Sets an empty animation for every track, discarding any queued animations, and mixes to it over the specified mix + * duration. */ + setEmptyAnimations(mixDuration: number) { + const oldDrainDisabled = this.queue.drainDisabled; + this.queue.drainDisabled = true; + for (let i = 0, n = this.tracks.length; i < n; i++) { + const current = this.tracks[i]; + if (current != null) this.setEmptyAnimation(current.trackIndex, mixDuration); + } + this.queue.drainDisabled = oldDrainDisabled; + this.queue.drain(); + } + + expandToIndex(index: number) { + if (index < this.tracks.length) return this.tracks[index]; + Utils.ensureArrayCapacity(this.tracks, index + 1, null); + this.tracks.length = index + 1; + return null; + } + + /** @param last May be null. */ + trackEntry(trackIndex: number, animation: Animation, loop: boolean, last: TrackEntry) { + const entry = this.trackEntryPool.obtain(); + entry.trackIndex = trackIndex; + entry.animation = animation; + entry.loop = loop; + entry.holdPrevious = false; + + entry.eventThreshold = 0; + entry.attachmentThreshold = 0; + entry.drawOrderThreshold = 0; + + entry.animationStart = 0; + entry.animationEnd = animation.duration; + entry.animationLast = -1; + entry.nextAnimationLast = -1; + + entry.delay = 0; + entry.trackTime = 0; + entry.trackLast = -1; + entry.nextTrackLast = -1; + entry.trackEnd = Number.MAX_VALUE; + entry.timeScale = 1; + + entry.alpha = 1; + entry.interruptAlpha = 1; + entry.mixTime = 0; + entry.mixDuration = last == null ? 0 : this.data.getMix(last.animation, animation); + entry.mixBlend = MixBlend.replace; + return entry; + } + + disposeNext(entry: TrackEntry) { + let next = entry.next; + while (next != null) { + this.queue.dispose(next); + next = next.next; + } + entry.next = null; + } + + _animationsChanged() { + this.animationsChanged = false; + + this.propertyIDs.clear(); + + for (let i = 0, n = this.tracks.length; i < n; i++) { + let entry = this.tracks[i]; + if (entry == null) continue; + while (entry.mixingFrom != null) entry = entry.mixingFrom; + + do { + if (entry.mixingFrom == null || entry.mixBlend != MixBlend.add) this.computeHold(entry); + entry = entry.mixingTo; + } while (entry != null); + } + } + + computeHold(entry: TrackEntry) { + const to = entry.mixingTo; + const timelines = entry.animation.timelines; + const timelinesCount = entry.animation.timelines.length; + const timelineMode = Utils.setArraySize(entry.timelineMode, timelinesCount); + entry.timelineHoldMix.length = 0; + const timelineDipMix = Utils.setArraySize(entry.timelineHoldMix, timelinesCount); + const propertyIDs = this.propertyIDs; + + if (to != null && to.holdPrevious) { + for (let i = 0; i < timelinesCount; i++) { + timelineMode[i] = propertyIDs.add(timelines[i].getPropertyId()) + ? AnimationState.HOLD_FIRST + : AnimationState.HOLD_SUBSEQUENT; + } + return; + } + + outer: for (let i = 0; i < timelinesCount; i++) { + const timeline = timelines[i]; + const id = timeline.getPropertyId(); + if (!propertyIDs.add(id)) timelineMode[i] = AnimationState.SUBSEQUENT; + else if ( + to == null || + timeline instanceof AttachmentTimeline || + timeline instanceof DrawOrderTimeline || + timeline instanceof EventTimeline || + !to.animation.hasTimeline(id) + ) { + timelineMode[i] = AnimationState.FIRST; + } else { + for (let next = to.mixingTo; next != null; next = next.mixingTo) { + if (next.animation.hasTimeline(id)) continue; + if (entry.mixDuration > 0) { + timelineMode[i] = AnimationState.HOLD_MIX; + timelineDipMix[i] = next; + continue outer; + } + break; + } + timelineMode[i] = AnimationState.HOLD_FIRST; + } + } + } + + /** Returns the track entry for the animation currently playing on the track, or null if no animation is currently playing. */ + getCurrent(trackIndex: number) { + if (trackIndex >= this.tracks.length) return null; + return this.tracks[trackIndex]; + } + + /** Adds a listener to receive events for all track entries. */ + addListener(listener: AnimationStateListener) { + if (listener == null) throw new Error("listener cannot be null."); + this.listeners.push(listener); + } + + /** Removes the listener added with {@link #addListener()}. */ + removeListener(listener: AnimationStateListener) { + const index = this.listeners.indexOf(listener); + if (index >= 0) this.listeners.splice(index, 1); + } + + /** Removes all listeners added with {@link #addListener()}. */ + clearListeners() { + this.listeners.length = 0; + } + + /** Discards all listener notifications that have not yet been delivered. This can be useful to call from an + * {@link AnimationStateListener} when it is known that further notifications that may have been already queued for delivery + * are not wanted because new animations are being set. */ + clearListenerNotifications() { + this.queue.clear(); + } +} + +/** Stores settings and other state for the playback of an animation on an {@link AnimationState} track. + * + * References to a track entry must not be kept after the {@link AnimationStateListener#dispose()} event occurs. */ +export class TrackEntry { + /** The animation to apply for this track entry. */ + animation: Animation; + + /** The animation queued to start after this animation, or null. `next` makes up a linked list. */ + next: TrackEntry; + + /** The track entry for the previous animation when mixing from the previous animation to this animation, or null if no + * mixing is currently occuring. When mixing from multiple animations, `mixingFrom` makes up a linked list. */ + mixingFrom: TrackEntry; + + /** The track entry for the next animation when mixing from this animation to the next animation, or null if no mixing is + * currently occuring. When mixing to multiple animations, `mixingTo` makes up a linked list. */ + mixingTo: TrackEntry; + + /** The listener for events generated by this track entry, or null. + * + * A track entry returned from {@link AnimationState#setAnimation()} is already the current animation + * for the track, so the track entry listener {@link AnimationStateListener#start()} will not be called. */ + listener: AnimationStateListener; + + /** The index of the track where this track entry is either current or queued. + * + * See {@link AnimationState#getCurrent()}. */ + trackIndex: number; + + /** If true, the animation will repeat. If false it will not, instead its last frame is applied if played beyond its + * duration. */ + loop: boolean; + + /** If true, when mixing from the previous animation to this animation, the previous animation is applied as normal instead + * of being mixed out. + * + * When mixing between animations that key the same property, if a lower track also keys that property then the value will + * briefly dip toward the lower track value during the mix. This happens because the first animation mixes from 100% to 0% + * while the second animation mixes from 0% to 100%. Setting `holdPrevious` to true applies the first animation + * at 100% during the mix so the lower track value is overwritten. Such dipping does not occur on the lowest track which + * keys the property, only when a higher track also keys the property. + * + * Snapping will occur if `holdPrevious` is true and this animation does not key all the same properties as the + * previous animation. */ + holdPrevious: boolean; + + /** When the mix percentage ({@link #mixTime} / {@link #mixDuration}) is less than the + * `eventThreshold`, event timelines are applied while this animation is being mixed out. Defaults to 0, so event + * timelines are not applied while this animation is being mixed out. */ + eventThreshold: number; + + /** When the mix percentage ({@link #mixtime} / {@link #mixDuration}) is less than the + * `attachmentThreshold`, attachment timelines are applied while this animation is being mixed out. Defaults to + * 0, so attachment timelines are not applied while this animation is being mixed out. */ + attachmentThreshold: number; + + /** When the mix percentage ({@link #mixTime} / {@link #mixDuration}) is less than the + * `drawOrderThreshold`, draw order timelines are applied while this animation is being mixed out. Defaults to 0, + * so draw order timelines are not applied while this animation is being mixed out. */ + drawOrderThreshold: number; + + /** Seconds when this animation starts, both initially and after looping. Defaults to 0. + * + * When changing the `animationStart` time, it often makes sense to set {@link #animationLast} to the same + * value to prevent timeline keys before the start time from triggering. */ + animationStart: number; + + /** Seconds for the last frame of this animation. Non-looping animations won't play past this time. Looping animations will + * loop back to {@link #animationStart} at this time. Defaults to the animation {@link Animation#duration}. */ + animationEnd: number; + + /** The time in seconds this animation was last applied. Some timelines use this for one-time triggers. Eg, when this + * animation is applied, event timelines will fire all events between the `animationLast` time (exclusive) and + * `animationTime` (inclusive). Defaults to -1 to ensure triggers on frame 0 happen the first time this animation + * is applied. */ + animationLast: number; + + nextAnimationLast: number; + + /** Seconds to postpone playing the animation. When this track entry is the current track entry, `delay` + * postpones incrementing the {@link #trackTime}. When this track entry is queued, `delay` is the time from + * the start of the previous animation to when this track entry will become the current track entry (ie when the previous + * track entry {@link TrackEntry#trackTime} >= this track entry's `delay`). + * + * {@link #timeScale} affects the delay. */ + delay: number; + + /** Current time in seconds this track entry has been the current track entry. The track time determines + * {@link #animationTime}. The track time can be set to start the animation at a time other than 0, without affecting + * looping. */ + trackTime: number; + + trackLast: number; + nextTrackLast: number; + + /** The track time in seconds when this animation will be removed from the track. Defaults to the highest possible float + * value, meaning the animation will be applied until a new animation is set or the track is cleared. If the track end time + * is reached, no other animations are queued for playback, and mixing from any previous animations is complete, then the + * properties keyed by the animation are set to the setup pose and the track is cleared. + * + * It may be desired to use {@link AnimationState#addEmptyAnimation()} rather than have the animation + * abruptly cease being applied. */ + trackEnd: number; + + /** Multiplier for the delta time when this track entry is updated, causing time for this animation to pass slower or + * faster. Defaults to 1. + * + * {@link #mixTime} is not affected by track entry time scale, so {@link #mixDuration} may need to be adjusted to + * match the animation speed. + * + * When using {@link AnimationState#addAnimation()} with a `delay` <= 0, note the + * {@link #delay} is set using the mix duration from the {@link AnimationStateData}, assuming time scale to be 1. If + * the time scale is not 1, the delay may need to be adjusted. + * + * See AnimationState {@link AnimationState#timeScale} for affecting all animations. */ + timeScale: number; + + /** Values < 1 mix this animation with the skeleton's current pose (usually the pose resulting from lower tracks). Defaults + * to 1, which overwrites the skeleton's current pose with this animation. + * + * Typically track 0 is used to completely pose the skeleton, then alpha is used on higher tracks. It doesn't make sense to + * use alpha on track 0 if the skeleton pose is from the last frame render. */ + alpha: number; + + /** Seconds from 0 to the {@link #getMixDuration()} when mixing from the previous animation to this animation. May be + * slightly more than `mixDuration` when the mix is complete. */ + mixTime: number; + + /** Seconds for mixing from the previous animation to this animation. Defaults to the value provided by AnimationStateData + * {@link AnimationStateData#getMix()} based on the animation before this animation (if any). + * + * A mix duration of 0 still mixes out over one frame to provide the track entry being mixed out a chance to revert the + * properties it was animating. + * + * The `mixDuration` can be set manually rather than use the value from + * {@link AnimationStateData#getMix()}. In that case, the `mixDuration` can be set for a new + * track entry only before {@link AnimationState#update(float)} is first called. + * + * When using {@link AnimationState#addAnimation()} with a `delay` <= 0, note the + * {@link #delay} is set using the mix duration from the {@link AnimationStateData}, not a mix duration set + * afterward. */ + mixDuration: number; + interruptAlpha: number; + totalAlpha: number; + + /** Controls how properties keyed in the animation are mixed with lower tracks. Defaults to {@link MixBlend#replace}, which + * replaces the values from the lower tracks with the animation values. {@link MixBlend#add} adds the animation values to + * the values from the lower tracks. + * + * The `mixBlend` can be set for a new track entry only before {@link AnimationState#apply()} is first + * called. */ + mixBlend = MixBlend.replace; + timelineMode = new Array(); + timelineHoldMix = new Array(); + timelinesRotation = new Array(); + + reset() { + this.next = null; + this.mixingFrom = null; + this.mixingTo = null; + this.animation = null; + this.listener = null; + this.timelineMode.length = 0; + this.timelineHoldMix.length = 0; + this.timelinesRotation.length = 0; + } + + /** Uses {@link #trackTime} to compute the `animationTime`, which is between {@link #animationStart} + * and {@link #animationEnd}. When the `trackTime` is 0, the `animationTime` is equal to the + * `animationStart` time. */ + getAnimationTime() { + if (this.loop) { + const duration = this.animationEnd - this.animationStart; + if (duration == 0) return this.animationStart; + return (this.trackTime % duration) + this.animationStart; + } + return Math.min(this.trackTime + this.animationStart, this.animationEnd); + } + + setAnimationLast(animationLast: number) { + this.animationLast = animationLast; + this.nextAnimationLast = animationLast; + } + + /** Returns true if at least one loop has been completed. + * + * See {@link AnimationStateListener#complete()}. */ + isComplete() { + return this.trackTime >= this.animationEnd - this.animationStart; + } + + /** Resets the rotation directions for mixing this entry's rotate timelines. This can be useful to avoid bones rotating the + * long way around when using {@link #alpha} and starting animations on other tracks. + * + * Mixing with {@link MixBlend#replace} involves finding a rotation between two others, which has two possible solutions: + * the short way or the long way around. The two rotations likely change over time, so which direction is the short or long + * way also changes. If the short way was always chosen, bones would flip to the other side when that direction became the + * long way. TrackEntry chooses the short way the first time it is applied and remembers that direction. */ + resetRotationDirections() { + this.timelinesRotation.length = 0; + } +} + +export class EventQueue { + objects: Array = []; + drainDisabled = false; + animState: AnimationState; + + constructor(animState: AnimationState) { + this.animState = animState; + } + + start(entry: TrackEntry) { + this.objects.push(EventType.start); + this.objects.push(entry); + this.animState.animationsChanged = true; + } + + interrupt(entry: TrackEntry) { + this.objects.push(EventType.interrupt); + this.objects.push(entry); + } + + end(entry: TrackEntry) { + this.objects.push(EventType.end); + this.objects.push(entry); + this.animState.animationsChanged = true; + } + + dispose(entry: TrackEntry) { + this.objects.push(EventType.dispose); + this.objects.push(entry); + } + + complete(entry: TrackEntry) { + this.objects.push(EventType.complete); + this.objects.push(entry); + } + + event(entry: TrackEntry, event: Event) { + this.objects.push(EventType.event); + this.objects.push(entry); + this.objects.push(event); + } + + drain() { + if (this.drainDisabled) return; + this.drainDisabled = true; + + const objects = this.objects; + const listeners = this.animState.listeners; + + for (let i = 0; i < objects.length; i += 2) { + const type = objects[i] as EventType; + const entry = objects[i + 1] as TrackEntry; + switch (type) { + case EventType.start: + if (entry.listener != null && entry.listener.start) entry.listener.start(entry); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].start) listeners[ii].start(entry); + break; + case EventType.interrupt: + if (entry.listener != null && entry.listener.interrupt) entry.listener.interrupt(entry); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].interrupt) listeners[ii].interrupt(entry); + break; + case EventType.end: + if (entry.listener != null && entry.listener.end) entry.listener.end(entry); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].end) listeners[ii].end(entry); + // Fall through. + case EventType.dispose: + if (entry.listener != null && entry.listener.dispose) entry.listener.dispose(entry); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].dispose) listeners[ii].dispose(entry); + this.animState.trackEntryPool.free(entry); + break; + case EventType.complete: + if (entry.listener != null && entry.listener.complete) entry.listener.complete(entry); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].complete) listeners[ii].complete(entry); + break; + case EventType.event: + const event = objects[i++ + 2] as Event; + if (entry.listener != null && entry.listener.event) entry.listener.event(entry, event); + for (let ii = 0; ii < listeners.length; ii++) if (listeners[ii].event) listeners[ii].event(entry, event); + break; + } + } + this.clear(); + + this.drainDisabled = false; + } + + clear() { + this.objects.length = 0; + } +} + +export enum EventType { + start, + interrupt, + end, + dispose, + complete, + event +} + +/** The interface to implement for receiving TrackEntry events. It is always safe to call AnimationState methods when receiving + * events. + * + * See TrackEntry {@link TrackEntry#listener} and AnimationState + * {@link AnimationState#addListener()}. */ +export interface AnimationStateListener { + /** Invoked when this entry has been set as the current entry. */ + start(entry: TrackEntry): void; + + /** Invoked when another entry has replaced this entry as the current entry. This entry may continue being applied for + * mixing. */ + interrupt(entry: TrackEntry): void; + + /** Invoked when this entry is no longer the current entry and will never be applied again. */ + end(entry: TrackEntry): void; + + /** Invoked when this entry will be disposed. This may occur without the entry ever being set as the current entry. + * References to the entry should not be kept after dispose is called, as it may be destroyed or reused. */ + dispose(entry: TrackEntry): void; + + /** Invoked every time this entry's animation completes a loop. */ + complete(entry: TrackEntry): void; + + /** Invoked when this entry's animation triggers an event. */ + event(entry: TrackEntry, event: Event): void; +} + +export abstract class AnimationStateAdapter implements AnimationStateListener { + start(entry: TrackEntry) {} + + interrupt(entry: TrackEntry) {} + + end(entry: TrackEntry) {} + + dispose(entry: TrackEntry) {} + + complete(entry: TrackEntry) {} + + event(entry: TrackEntry, event: Event) {} +} diff --git a/packages/spine-core-3.8/src/spine-core/AnimationStateData.ts b/packages/spine-core-3.8/src/spine-core/AnimationStateData.ts new file mode 100644 index 0000000000..009fcfad95 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/AnimationStateData.ts @@ -0,0 +1,48 @@ +import { SkeletonData } from "./SkeletonData"; +import { Animation } from "./Animation"; +import { Map } from "./Utils"; + +/** Stores mix (crossfade) durations to be applied when {@link AnimationState} animations are changed. */ +export class AnimationStateData { + /** The SkeletonData to look up animations when they are specified by name. */ + skeletonData: SkeletonData; + + animationToMixTime: Map = {}; + + /** The mix duration to use when no mix duration has been defined between two animations. */ + defaultMix = 0; + + constructor(skeletonData: SkeletonData) { + if (skeletonData == null) throw new Error("skeletonData cannot be null."); + this.skeletonData = skeletonData; + } + + /** Sets a mix duration by animation name. + * + * See {@link #setMixWith()}. */ + setMix(fromName: string, toName: string, duration: number) { + const from = this.skeletonData.findAnimation(fromName); + if (from == null) throw new Error("Animation not found: " + fromName); + const to = this.skeletonData.findAnimation(toName); + if (to == null) throw new Error("Animation not found: " + toName); + this.setMixWith(from, to, duration); + } + + /** Sets the mix duration when changing from the specified animation to the other. + * + * See {@link TrackEntry#mixDuration}. */ + setMixWith(from: Animation, to: Animation, duration: number) { + if (from == null) throw new Error("from cannot be null."); + if (to == null) throw new Error("to cannot be null."); + const key = from.name + "." + to.name; + this.animationToMixTime[key] = duration; + } + + /** Returns the mix duration to use when changing from the specified animation to the other, or the {@link #defaultMix} if + * no mix duration has been set. */ + getMix(from: Animation, to: Animation) { + const key = from.name + "." + to.name; + const value = this.animationToMixTime[key]; + return value === undefined ? this.defaultMix : value; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/AtlasAttachmentLoader.ts b/packages/spine-core-3.8/src/spine-core/AtlasAttachmentLoader.ts new file mode 100644 index 0000000000..eca31cbf2e --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/AtlasAttachmentLoader.ts @@ -0,0 +1,55 @@ +import { AttachmentLoader } from "./attachments/AttachmentLoader"; +import { TextureAtlas } from "./TextureAtlas"; +import { Skin } from "./Skin"; +import { RegionAttachment } from "./attachments/RegionAttachment"; +import { MeshAttachment } from "./attachments/MeshAttachment"; +import { BoundingBoxAttachment } from "./attachments/BoundingBoxAttachment"; +import { PathAttachment } from "./attachments/PathAttachment"; +import { PointAttachment } from "./attachments/PointAttachment"; +import { ClippingAttachment } from "./attachments/ClippingAttachment"; + +/** An {@link AttachmentLoader} that configures attachments using texture regions from an {@link TextureAtlas}. + * + * See [Loading skeleton data](http://esotericsoftware.com/spine-loading-skeleton-data#JSON-and-binary-data) in the + * Spine Runtimes Guide. */ +export class AtlasAttachmentLoader implements AttachmentLoader { + atlas: TextureAtlas; + + constructor(atlas: TextureAtlas) { + this.atlas = atlas; + } + + newRegionAttachment(skin: Skin, name: string, path: string): RegionAttachment { + const region = this.atlas.findRegion(path); + if (region == null) throw new Error("Region not found in atlas: " + path + " (region attachment: " + name + ")"); + region.renderObject = region; + const attachment = new RegionAttachment(name); + attachment.setRegion(region); + return attachment; + } + + newMeshAttachment(skin: Skin, name: string, path: string): MeshAttachment { + const region = this.atlas.findRegion(path); + if (region == null) throw new Error("Region not found in atlas: " + path + " (mesh attachment: " + name + ")"); + region.renderObject = region; + const attachment = new MeshAttachment(name); + attachment.region = region; + return attachment; + } + + newBoundingBoxAttachment(skin: Skin, name: string): BoundingBoxAttachment { + return new BoundingBoxAttachment(name); + } + + newPathAttachment(skin: Skin, name: string): PathAttachment { + return new PathAttachment(name); + } + + newPointAttachment(skin: Skin, name: string): PointAttachment { + return new PointAttachment(name); + } + + newClippingAttachment(skin: Skin, name: string): ClippingAttachment { + return new ClippingAttachment(name); + } +} diff --git a/packages/spine-core-3.8/src/spine-core/BlendMode.ts b/packages/spine-core-3.8/src/spine-core/BlendMode.ts new file mode 100644 index 0000000000..2f004954b4 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/BlendMode.ts @@ -0,0 +1,7 @@ +/** Determines how images are blended with existing pixels when drawn. */ +export enum BlendMode { + Normal, + Additive, + Multiply, + Screen +} diff --git a/packages/spine-core-3.8/src/spine-core/Bone.ts b/packages/spine-core-3.8/src/spine-core/Bone.ts new file mode 100644 index 0000000000..1d4c9b7ee3 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Bone.ts @@ -0,0 +1,391 @@ +import { Updatable } from "./Updatable"; +import { BoneData, TransformMode } from "./BoneData"; +import { Skeleton } from "./Skeleton"; +import { MathUtils, Vector2 } from "./Utils"; + +/** Stores a bone's current pose. + * + * A bone has a local transform which is used to compute its world transform. A bone also has an applied transform, which is a + * local transform that can be applied to compute the world transform. The local transform and applied transform may differ if a + * constraint or application code modifies the world transform after it was computed from the local transform. */ +export class Bone implements Updatable { + /** The bone's setup pose data. */ + data: BoneData; + + /** The skeleton this bone belongs to. */ + skeleton: Skeleton; + + /** The parent bone, or null if this is the root bone. */ + parent: Bone; + + /** The immediate children of this bone. */ + children = new Array(); + + /** The local x translation. */ + x = 0; + + /** The local y translation. */ + y = 0; + + /** The local rotation in degrees, counter clockwise. */ + rotation = 0; + + /** The local scaleX. */ + scaleX = 0; + + /** The local scaleY. */ + scaleY = 0; + + /** The local shearX. */ + shearX = 0; + + /** The local shearY. */ + shearY = 0; + + /** The applied local x translation. */ + ax = 0; + + /** The applied local y translation. */ + ay = 0; + + /** The applied local rotation in degrees, counter clockwise. */ + arotation = 0; + + /** The applied local scaleX. */ + ascaleX = 0; + + /** The applied local scaleY. */ + ascaleY = 0; + + /** The applied local shearX. */ + ashearX = 0; + + /** The applied local shearY. */ + ashearY = 0; + + /** If true, the applied transform matches the world transform. If false, the world transform has been modified since it was + * computed and {@link #updateAppliedTransform()} must be called before accessing the applied transform. */ + appliedValid = false; + + /** Part of the world transform matrix for the X axis. If changed, {@link #appliedValid} should be set to false. */ + a = 0; + + /** Part of the world transform matrix for the Y axis. If changed, {@link #appliedValid} should be set to false. */ + b = 0; + + /** Part of the world transform matrix for the X axis. If changed, {@link #appliedValid} should be set to false. */ + c = 0; + + /** Part of the world transform matrix for the Y axis. If changed, {@link #appliedValid} should be set to false. */ + d = 0; + + /** The world X position. If changed, {@link #appliedValid} should be set to false. */ + worldY = 0; + + /** The world Y position. If changed, {@link #appliedValid} should be set to false. */ + worldX = 0; + + sorted = false; + active = false; + + /** @param parent May be null. */ + constructor(data: BoneData, skeleton: Skeleton, parent: Bone) { + if (data == null) throw new Error("data cannot be null."); + if (skeleton == null) throw new Error("skeleton cannot be null."); + this.data = data; + this.skeleton = skeleton; + this.parent = parent; + this.setToSetupPose(); + } + + /** Returns false when the bone has not been computed because {@link BoneData#skinRequired} is true and the + * {@link Skeleton#skin active skin} does not {@link Skin#bones contain} this bone. */ + isActive() { + return this.active; + } + + /** Same as {@link #updateWorldTransform()}. This method exists for Bone to implement {@link Updatable}. */ + update() { + this.updateWorldTransformWith(this.x, this.y, this.rotation, this.scaleX, this.scaleY, this.shearX, this.shearY); + } + + /** Computes the world transform using the parent bone and this bone's local transform. + * + * See {@link #updateWorldTransformWith()}. */ + updateWorldTransform() { + this.updateWorldTransformWith(this.x, this.y, this.rotation, this.scaleX, this.scaleY, this.shearX, this.shearY); + } + + /** Computes the world transform using the parent bone and the specified local transform. Child bones are not updated. + * + * See [World transforms](http://esotericsoftware.com/spine-runtime-skeletons#World-transforms) in the Spine + * Runtimes Guide. */ + updateWorldTransformWith( + x: number, + y: number, + rotation: number, + scaleX: number, + scaleY: number, + shearX: number, + shearY: number + ) { + this.ax = x; + this.ay = y; + this.arotation = rotation; + this.ascaleX = scaleX; + this.ascaleY = scaleY; + this.ashearX = shearX; + this.ashearY = shearY; + this.appliedValid = true; + + const parent = this.parent; + if (parent == null) { + // Root bone. + const skeleton = this.skeleton; + const rotationY = rotation + 90 + shearY; + const sx = skeleton.scaleX; + const sy = skeleton.scaleY; + this.a = MathUtils.cosDeg(rotation + shearX) * scaleX * sx; + this.b = MathUtils.cosDeg(rotationY) * scaleY * sx; + this.c = MathUtils.sinDeg(rotation + shearX) * scaleX * sy; + this.d = MathUtils.sinDeg(rotationY) * scaleY * sy; + this.worldX = x * sx + skeleton.x; + this.worldY = y * sy + skeleton.y; + return; + } + + let pa = parent.a, + pb = parent.b, + pc = parent.c, + pd = parent.d; + this.worldX = pa * x + pb * y + parent.worldX; + this.worldY = pc * x + pd * y + parent.worldY; + + switch (this.data.transformMode) { + case TransformMode.Normal: { + const rotationY = rotation + 90 + shearY; + const la = MathUtils.cosDeg(rotation + shearX) * scaleX; + const lb = MathUtils.cosDeg(rotationY) * scaleY; + const lc = MathUtils.sinDeg(rotation + shearX) * scaleX; + const ld = MathUtils.sinDeg(rotationY) * scaleY; + this.a = pa * la + pb * lc; + this.b = pa * lb + pb * ld; + this.c = pc * la + pd * lc; + this.d = pc * lb + pd * ld; + return; + } + case TransformMode.OnlyTranslation: { + const rotationY = rotation + 90 + shearY; + this.a = MathUtils.cosDeg(rotation + shearX) * scaleX; + this.b = MathUtils.cosDeg(rotationY) * scaleY; + this.c = MathUtils.sinDeg(rotation + shearX) * scaleX; + this.d = MathUtils.sinDeg(rotationY) * scaleY; + break; + } + case TransformMode.NoRotationOrReflection: { + let s = pa * pa + pc * pc; + let prx = 0; + if (s > 0.0001) { + s = Math.abs(pa * pd - pb * pc) / s; + pa /= this.skeleton.scaleX; + pc /= this.skeleton.scaleY; + pb = pc * s; + pd = pa * s; + prx = Math.atan2(pc, pa) * MathUtils.radDeg; + } else { + pa = 0; + pc = 0; + prx = 90 - Math.atan2(pd, pb) * MathUtils.radDeg; + } + const rx = rotation + shearX - prx; + const ry = rotation + shearY - prx + 90; + const la = MathUtils.cosDeg(rx) * scaleX; + const lb = MathUtils.cosDeg(ry) * scaleY; + const lc = MathUtils.sinDeg(rx) * scaleX; + const ld = MathUtils.sinDeg(ry) * scaleY; + this.a = pa * la - pb * lc; + this.b = pa * lb - pb * ld; + this.c = pc * la + pd * lc; + this.d = pc * lb + pd * ld; + break; + } + case TransformMode.NoScale: + case TransformMode.NoScaleOrReflection: { + const cos = MathUtils.cosDeg(rotation); + const sin = MathUtils.sinDeg(rotation); + let za = (pa * cos + pb * sin) / this.skeleton.scaleX; + let zc = (pc * cos + pd * sin) / this.skeleton.scaleY; + let s = Math.sqrt(za * za + zc * zc); + if (s > 0.00001) s = 1 / s; + za *= s; + zc *= s; + s = Math.sqrt(za * za + zc * zc); + if ( + this.data.transformMode == TransformMode.NoScale && + pa * pd - pb * pc < 0 != (this.skeleton.scaleX < 0 != this.skeleton.scaleY < 0) + ) + s = -s; + const r = Math.PI / 2 + Math.atan2(zc, za); + const zb = Math.cos(r) * s; + const zd = Math.sin(r) * s; + const la = MathUtils.cosDeg(shearX) * scaleX; + const lb = MathUtils.cosDeg(90 + shearY) * scaleY; + const lc = MathUtils.sinDeg(shearX) * scaleX; + const ld = MathUtils.sinDeg(90 + shearY) * scaleY; + this.a = za * la + zb * lc; + this.b = za * lb + zb * ld; + this.c = zc * la + zd * lc; + this.d = zc * lb + zd * ld; + break; + } + } + this.a *= this.skeleton.scaleX; + this.b *= this.skeleton.scaleX; + this.c *= this.skeleton.scaleY; + this.d *= this.skeleton.scaleY; + } + + /** Sets this bone's local transform to the setup pose. */ + setToSetupPose() { + const data = this.data; + this.x = data.x; + this.y = data.y; + this.rotation = data.rotation; + this.scaleX = data.scaleX; + this.scaleY = data.scaleY; + this.shearX = data.shearX; + this.shearY = data.shearY; + } + + /** The world rotation for the X axis, calculated using {@link #a} and {@link #c}. */ + getWorldRotationX() { + return Math.atan2(this.c, this.a) * MathUtils.radDeg; + } + + /** The world rotation for the Y axis, calculated using {@link #b} and {@link #d}. */ + getWorldRotationY() { + return Math.atan2(this.d, this.b) * MathUtils.radDeg; + } + + /** The magnitude (always positive) of the world scale X, calculated using {@link #a} and {@link #c}. */ + getWorldScaleX() { + return Math.sqrt(this.a * this.a + this.c * this.c); + } + + /** The magnitude (always positive) of the world scale Y, calculated using {@link #b} and {@link #d}. */ + getWorldScaleY() { + return Math.sqrt(this.b * this.b + this.d * this.d); + } + + /** Computes the applied transform values from the world transform. This allows the applied transform to be accessed after the + * world transform has been modified (by a constraint, {@link #rotateWorld()}, etc). + * + * If {@link #updateWorldTransform()} has been called for a bone and {@link #appliedValid} is false, then + * {@link #updateAppliedTransform()} must be called before accessing the applied transform. + * + * Some information is ambiguous in the world transform, such as -1,-1 scale versus 180 rotation. The applied transform after + * calling this method is equivalent to the local tranform used to compute the world transform, but may not be identical. */ + updateAppliedTransform() { + this.appliedValid = true; + const parent = this.parent; + if (parent == null) { + this.ax = this.worldX; + this.ay = this.worldY; + this.arotation = Math.atan2(this.c, this.a) * MathUtils.radDeg; + this.ascaleX = Math.sqrt(this.a * this.a + this.c * this.c); + this.ascaleY = Math.sqrt(this.b * this.b + this.d * this.d); + this.ashearX = 0; + this.ashearY = + Math.atan2(this.a * this.b + this.c * this.d, this.a * this.d - this.b * this.c) * MathUtils.radDeg; + return; + } + const pa = parent.a, + pb = parent.b, + pc = parent.c, + pd = parent.d; + const pid = 1 / (pa * pd - pb * pc); + const dx = this.worldX - parent.worldX, + dy = this.worldY - parent.worldY; + this.ax = dx * pd * pid - dy * pb * pid; + this.ay = dy * pa * pid - dx * pc * pid; + const ia = pid * pd; + const id = pid * pa; + const ib = pid * pb; + const ic = pid * pc; + const ra = ia * this.a - ib * this.c; + const rb = ia * this.b - ib * this.d; + const rc = id * this.c - ic * this.a; + const rd = id * this.d - ic * this.b; + this.ashearX = 0; + this.ascaleX = Math.sqrt(ra * ra + rc * rc); + if (this.ascaleX > 0.0001) { + const det = ra * rd - rb * rc; + this.ascaleY = det / this.ascaleX; + this.ashearY = Math.atan2(ra * rb + rc * rd, det) * MathUtils.radDeg; + this.arotation = Math.atan2(rc, ra) * MathUtils.radDeg; + } else { + this.ascaleX = 0; + this.ascaleY = Math.sqrt(rb * rb + rd * rd); + this.ashearY = 0; + this.arotation = 90 - Math.atan2(rd, rb) * MathUtils.radDeg; + } + } + + /** Transforms a point from world coordinates to the bone's local coordinates. */ + worldToLocal(world: Vector2) { + const a = this.a, + b = this.b, + c = this.c, + d = this.d; + const invDet = 1 / (a * d - b * c); + const x = world.x - this.worldX, + y = world.y - this.worldY; + world.x = x * d * invDet - y * b * invDet; + world.y = y * a * invDet - x * c * invDet; + return world; + } + + /** Transforms a point from the bone's local coordinates to world coordinates. */ + localToWorld(local: Vector2) { + const x = local.x, + y = local.y; + local.x = x * this.a + y * this.b + this.worldX; + local.y = x * this.c + y * this.d + this.worldY; + return local; + } + + /** Transforms a world rotation to a local rotation. */ + worldToLocalRotation(worldRotation: number) { + const sin = MathUtils.sinDeg(worldRotation), + cos = MathUtils.cosDeg(worldRotation); + return ( + Math.atan2(this.a * sin - this.c * cos, this.d * cos - this.b * sin) * MathUtils.radDeg + + this.rotation - + this.shearX + ); + } + + /** Transforms a local rotation to a world rotation. */ + localToWorldRotation(localRotation: number) { + localRotation -= this.rotation - this.shearX; + const sin = MathUtils.sinDeg(localRotation), + cos = MathUtils.cosDeg(localRotation); + return Math.atan2(cos * this.c + sin * this.d, cos * this.a + sin * this.b) * MathUtils.radDeg; + } + + /** Rotates the world transform the specified amount and sets {@link #appliedValid} to false. + * {@link #updateWorldTransform()} will need to be called on any child bones, recursively, and any constraints reapplied. */ + rotateWorld(degrees: number) { + const a = this.a, + b = this.b, + c = this.c, + d = this.d; + const cos = MathUtils.cosDeg(degrees), + sin = MathUtils.sinDeg(degrees); + this.a = cos * a - sin * c; + this.b = cos * b - sin * d; + this.c = sin * a + cos * c; + this.d = sin * b + cos * d; + this.appliedValid = false; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/BoneData.ts b/packages/spine-core-3.8/src/spine-core/BoneData.ts new file mode 100644 index 0000000000..a51d1b16cf --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/BoneData.ts @@ -0,0 +1,66 @@ +import { Color } from "./Utils"; + +/** Stores the setup pose for a {@link Bone}. */ +export class BoneData { + /** The index of the bone in {@link Skeleton#getBones()}. */ + index: number; + + /** The name of the bone, which is unique across all bones in the skeleton. */ + name: string; + + /** @returns May be null. */ + parent: BoneData; + + /** The bone's length. */ + length: number; + + /** The local x translation. */ + x = 0; + + /** The local y translation. */ + y = 0; + + /** The local rotation. */ + rotation = 0; + + /** The local scaleX. */ + scaleX = 1; + + /** The local scaleY. */ + scaleY = 1; + + /** The local shearX. */ + shearX = 0; + + /** The local shearX. */ + shearY = 0; + + /** The transform mode for how parent world transforms affect this bone. */ + transformMode = TransformMode.Normal; + + /** When true, {@link Skeleton#updateWorldTransform()} only updates this bone if the {@link Skeleton#skin} contains this + * bone. + * @see Skin#bones */ + skinRequired = false; + + /** The color of the bone as it was in Spine. Available only when nonessential data was exported. Bones are not usually + * rendered at runtime. */ + color = new Color(); + + constructor(index: number, name: string, parent: BoneData) { + if (index < 0) throw new Error("index must be >= 0."); + if (name == null) throw new Error("name cannot be null."); + this.index = index; + this.name = name; + this.parent = parent; + } +} + +/** Determines how a bone inherits world transforms from parent bones. */ +export enum TransformMode { + Normal, + OnlyTranslation, + NoRotationOrReflection, + NoScale, + NoScaleOrReflection +} diff --git a/packages/spine-core-3.8/src/spine-core/ConstraintData.ts b/packages/spine-core-3.8/src/spine-core/ConstraintData.ts new file mode 100644 index 0000000000..5d928d2169 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/ConstraintData.ts @@ -0,0 +1,8 @@ +/** The base class for all constraint datas. */ +export abstract class ConstraintData { + constructor( + public name: string, + public order: number, + public skinRequired: boolean + ) {} +} diff --git a/packages/spine-core-3.8/src/spine-core/Event.ts b/packages/spine-core-3.8/src/spine-core/Event.ts new file mode 100644 index 0000000000..a6318c90a9 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Event.ts @@ -0,0 +1,22 @@ +import { EventData } from "./EventData"; + +/** Stores the current pose values for an {@link Event}. + * + * See Timeline {@link Timeline#apply()}, + * AnimationStateListener {@link AnimationStateListener#event()}, and + * [Events](http://esotericsoftware.com/spine-events) in the Spine User Guide. */ +export class Event { + data: EventData; + intValue: number; + floatValue: number; + stringValue: string; + time: number; + volume: number; + balance: number; + + constructor(time: number, data: EventData) { + if (data == null) throw new Error("data cannot be null."); + this.time = time; + this.data = data; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/EventData.ts b/packages/spine-core-3.8/src/spine-core/EventData.ts new file mode 100644 index 0000000000..46aa71702e --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/EventData.ts @@ -0,0 +1,16 @@ +/** Stores the setup pose values for an {@link Event}. + * + * See [Events](http://esotericsoftware.com/spine-events) in the Spine User Guide. */ +export class EventData { + name: string; + intValue: number; + floatValue: number; + stringValue: string; + audioPath: string; + volume: number; + balance: number; + + constructor(name: string) { + this.name = name; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/IkConstraint.ts b/packages/spine-core-3.8/src/spine-core/IkConstraint.ts new file mode 100644 index 0000000000..7f05ce8e04 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/IkConstraint.ts @@ -0,0 +1,346 @@ +import { Updatable } from "./Updatable"; +import { IkConstraintData } from "./IkConstraintData"; +import { Bone } from "./Bone"; +import { Skeleton } from "./Skeleton"; +import { TransformMode } from "./BoneData"; +import { MathUtils } from "./Utils"; + +/** Stores the current pose for an IK constraint. An IK constraint adjusts the rotation of 1 or 2 constrained bones so the tip of + * the last bone is as close to the target bone as possible. + * + * See [IK constraints](http://esotericsoftware.com/spine-ik-constraints) in the Spine User Guide. */ +export class IkConstraint implements Updatable { + /** The IK constraint's setup pose data. */ + data: IkConstraintData; + + /** The bones that will be modified by this IK constraint. */ + bones: Array; + + /** The bone that is the IK target. */ + target: Bone; + + /** Controls the bend direction of the IK bones, either 1 or -1. */ + bendDirection = 0; + + /** When true and only a single bone is being constrained, if the target is too close, the bone is scaled to reach it. */ + compress = false; + + /** When true, if the target is out of range, the parent bone is scaled to reach it. If more than one bone is being constrained + * and the parent bone has local nonuniform scale, stretch is not applied. */ + stretch = false; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + mix = 1; + + /** For two bone IK, the distance from the maximum reach of the bones that rotation will slow. */ + softness = 0; + active = false; + + constructor(data: IkConstraintData, skeleton: Skeleton) { + if (data == null) throw new Error("data cannot be null."); + if (skeleton == null) throw new Error("skeleton cannot be null."); + this.data = data; + this.mix = data.mix; + this.softness = data.softness; + this.bendDirection = data.bendDirection; + this.compress = data.compress; + this.stretch = data.stretch; + + this.bones = new Array(); + for (let i = 0; i < data.bones.length; i++) this.bones.push(skeleton.findBone(data.bones[i].name)); + this.target = skeleton.findBone(data.target.name); + } + + isActive() { + return this.active; + } + + /** Applies the constraint to the constrained bones. */ + apply() { + this.update(); + } + + update() { + const target = this.target; + const bones = this.bones; + switch (bones.length) { + case 1: + this.apply1(bones[0], target.worldX, target.worldY, this.compress, this.stretch, this.data.uniform, this.mix); + break; + case 2: + this.apply2( + bones[0], + bones[1], + target.worldX, + target.worldY, + this.bendDirection, + this.stretch, + this.softness, + this.mix + ); + break; + } + } + + /** Applies 1 bone IK. The target is specified in the world coordinate system. */ + apply1( + bone: Bone, + targetX: number, + targetY: number, + compress: boolean, + stretch: boolean, + uniform: boolean, + alpha: number + ) { + if (!bone.appliedValid) bone.updateAppliedTransform(); + const p = bone.parent; + + let pa = p.a, + pb = p.b, + pc = p.c, + pd = p.d; + let rotationIK = -bone.ashearX - bone.arotation, + tx = 0, + ty = 0; + + switch (bone.data.transformMode) { + case TransformMode.OnlyTranslation: + tx = targetX - bone.worldX; + ty = targetY - bone.worldY; + break; + case TransformMode.NoRotationOrReflection: + const s = Math.abs(pa * pd - pb * pc) / (pa * pa + pc * pc); + const sa = pa / bone.skeleton.scaleX; + const sc = pc / bone.skeleton.scaleY; + pb = -sc * s * bone.skeleton.scaleX; + pd = sa * s * bone.skeleton.scaleY; + rotationIK += Math.atan2(sc, sa) * MathUtils.radDeg; + // Fall through + default: + const x = targetX - p.worldX, + y = targetY - p.worldY; + const d = pa * pd - pb * pc; + tx = (x * pd - y * pb) / d - bone.ax; + ty = (y * pa - x * pc) / d - bone.ay; + } + rotationIK += Math.atan2(ty, tx) * MathUtils.radDeg; + if (bone.ascaleX < 0) rotationIK += 180; + if (rotationIK > 180) rotationIK -= 360; + else if (rotationIK < -180) rotationIK += 360; + let sx = bone.ascaleX, + sy = bone.ascaleY; + if (compress || stretch) { + switch (bone.data.transformMode) { + case TransformMode.NoScale: + case TransformMode.NoScaleOrReflection: + tx = targetX - bone.worldX; + ty = targetY - bone.worldY; + } + const b = bone.data.length * sx, + dd = Math.sqrt(tx * tx + ty * ty); + if ((compress && dd < b) || (stretch && dd > b && b > 0.0001)) { + const s = (dd / b - 1) * alpha + 1; + sx *= s; + if (uniform) sy *= s; + } + } + bone.updateWorldTransformWith( + bone.ax, + bone.ay, + bone.arotation + rotationIK * alpha, + sx, + sy, + bone.ashearX, + bone.ashearY + ); + } + + /** Applies 2 bone IK. The target is specified in the world coordinate system. + * @param child A direct descendant of the parent bone. */ + apply2( + parent: Bone, + child: Bone, + targetX: number, + targetY: number, + bendDir: number, + stretch: boolean, + softness: number, + alpha: number + ) { + if (alpha == 0) { + child.updateWorldTransform(); + return; + } + if (!parent.appliedValid) parent.updateAppliedTransform(); + if (!child.appliedValid) child.updateAppliedTransform(); + let px = parent.ax, + py = parent.ay, + psx = parent.ascaleX, + sx = psx, + psy = parent.ascaleY, + csx = child.ascaleX; + let os1 = 0, + os2 = 0, + s2 = 0; + if (psx < 0) { + psx = -psx; + os1 = 180; + s2 = -1; + } else { + os1 = 0; + s2 = 1; + } + if (psy < 0) { + psy = -psy; + s2 = -s2; + } + if (csx < 0) { + csx = -csx; + os2 = 180; + } else os2 = 0; + let cx = child.ax, + cy = 0, + cwx = 0, + cwy = 0, + a = parent.a, + b = parent.b, + c = parent.c, + d = parent.d; + const u = Math.abs(psx - psy) <= 0.0001; + if (!u) { + cy = 0; + cwx = a * cx + parent.worldX; + cwy = c * cx + parent.worldY; + } else { + cy = child.ay; + cwx = a * cx + b * cy + parent.worldX; + cwy = c * cx + d * cy + parent.worldY; + } + const pp = parent.parent; + a = pp.a; + b = pp.b; + c = pp.c; + d = pp.d; + let id = 1 / (a * d - b * c), + x = cwx - pp.worldX, + y = cwy - pp.worldY; + const dx = (x * d - y * b) * id - px, + dy = (y * a - x * c) * id - py; + let l1 = Math.sqrt(dx * dx + dy * dy), + l2 = child.data.length * csx, + a1, + a2; + if (l1 < 0.0001) { + this.apply1(parent, targetX, targetY, false, stretch, false, alpha); + child.updateWorldTransformWith(cx, cy, 0, child.ascaleX, child.ascaleY, child.ashearX, child.ashearY); + return; + } + x = targetX - pp.worldX; + y = targetY - pp.worldY; + let tx = (x * d - y * b) * id - px, + ty = (y * a - x * c) * id - py; + let dd = tx * tx + ty * ty; + if (softness != 0) { + softness *= (psx * (csx + 1)) / 2; + const td = Math.sqrt(dd), + sd = td - l1 - l2 * psx + softness; + if (sd > 0) { + let p = Math.min(1, sd / (softness * 2)) - 1; + p = (sd - softness * (1 - p * p)) / td; + tx -= p * tx; + ty -= p * ty; + dd = tx * tx + ty * ty; + } + } + outer: if (u) { + l2 *= psx; + let cos = (dd - l1 * l1 - l2 * l2) / (2 * l1 * l2); + if (cos < -1) cos = -1; + else if (cos > 1) { + cos = 1; + if (stretch) sx *= (Math.sqrt(dd) / (l1 + l2) - 1) * alpha + 1; + } + a2 = Math.acos(cos) * bendDir; + a = l1 + l2 * cos; + b = l2 * Math.sin(a2); + a1 = Math.atan2(ty * a - tx * b, tx * a + ty * b); + } else { + a = psx * l2; + b = psy * l2; + const aa = a * a, + bb = b * b, + ta = Math.atan2(ty, tx); + c = bb * l1 * l1 + aa * dd - aa * bb; + const c1 = -2 * bb * l1, + c2 = bb - aa; + d = c1 * c1 - 4 * c2 * c; + if (d >= 0) { + let q = Math.sqrt(d); + if (c1 < 0) q = -q; + q = -(c1 + q) / 2; + const r0 = q / c2, + r1 = c / q; + const r = Math.abs(r0) < Math.abs(r1) ? r0 : r1; + if (r * r <= dd) { + y = Math.sqrt(dd - r * r) * bendDir; + a1 = ta - Math.atan2(y, r); + a2 = Math.atan2(y / psy, (r - l1) / psx); + break outer; + } + } + let minAngle = MathUtils.PI, + minX = l1 - a, + minDist = minX * minX, + minY = 0; + let maxAngle = 0, + maxX = l1 + a, + maxDist = maxX * maxX, + maxY = 0; + c = (-a * l1) / (aa - bb); + if (c >= -1 && c <= 1) { + c = Math.acos(c); + x = a * Math.cos(c) + l1; + y = b * Math.sin(c); + d = x * x + y * y; + if (d < minDist) { + minAngle = c; + minDist = d; + minX = x; + minY = y; + } + if (d > maxDist) { + maxAngle = c; + maxDist = d; + maxX = x; + maxY = y; + } + } + if (dd <= (minDist + maxDist) / 2) { + a1 = ta - Math.atan2(minY * bendDir, minX); + a2 = minAngle * bendDir; + } else { + a1 = ta - Math.atan2(maxY * bendDir, maxX); + a2 = maxAngle * bendDir; + } + } + const os = Math.atan2(cy, cx) * s2; + let rotation = parent.arotation; + a1 = (a1 - os) * MathUtils.radDeg + os1 - rotation; + if (a1 > 180) a1 -= 360; + else if (a1 < -180) a1 += 360; + parent.updateWorldTransformWith(px, py, rotation + a1 * alpha, sx, parent.ascaleY, 0, 0); + rotation = child.arotation; + a2 = ((a2 + os) * MathUtils.radDeg - child.ashearX) * s2 + os2 - rotation; + if (a2 > 180) a2 -= 360; + else if (a2 < -180) a2 += 360; + child.updateWorldTransformWith( + cx, + cy, + rotation + a2 * alpha, + child.ascaleX, + child.ascaleY, + child.ashearX, + child.ashearY + ); + } +} diff --git a/packages/spine-core-3.8/src/spine-core/IkConstraintData.ts b/packages/spine-core-3.8/src/spine-core/IkConstraintData.ts new file mode 100644 index 0000000000..200085b990 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/IkConstraintData.ts @@ -0,0 +1,37 @@ +import { ConstraintData } from "./ConstraintData"; +import { BoneData } from "./BoneData"; + +/** Stores the setup pose for an {@link IkConstraint}. + *

+ * See [IK constraints](http://esotericsoftware.com/spine-ik-constraints) in the Spine User Guide. */ +export class IkConstraintData extends ConstraintData { + /** The bones that are constrained by this IK constraint. */ + bones = new Array(); + + /** The bone that is the IK target. */ + target: BoneData; + + /** Controls the bend direction of the IK bones, either 1 or -1. */ + bendDirection = 1; + + /** When true and only a single bone is being constrained, if the target is too close, the bone is scaled to reach it. */ + compress = false; + + /** When true, if the target is out of range, the parent bone is scaled to reach it. If more than one bone is being constrained + * and the parent bone has local nonuniform scale, stretch is not applied. */ + stretch = false; + + /** When true, only a single bone is being constrained, and {@link #getCompress()} or {@link #getStretch()} is used, the bone + * is scaled on both the X and Y axes. */ + uniform = false; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + mix = 1; + + /** For two bone IK, the distance from the maximum reach of the bones that rotation will slow. */ + softness = 0; + + constructor(name: string) { + super(name, 0, false); + } +} diff --git a/packages/spine-core-3.8/src/spine-core/PathConstraint.ts b/packages/spine-core-3.8/src/spine-core/PathConstraint.ts new file mode 100644 index 0000000000..057ebd1f20 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/PathConstraint.ts @@ -0,0 +1,500 @@ +import { Updatable } from "./Updatable"; +import { PathConstraintData, SpacingMode, RotateMode, PositionMode } from "./PathConstraintData"; +import { Bone } from "./Bone"; +import { Slot } from "./Slot"; +import { Skeleton } from "./Skeleton"; +import { PathAttachment } from "./attachments/PathAttachment"; +import { Utils, MathUtils } from "./Utils"; + +/** Stores the current pose for a path constraint. A path constraint adjusts the rotation, translation, and scale of the + * constrained bones so they follow a {@link PathAttachment}. + * + * See [Path constraints](http://esotericsoftware.com/spine-path-constraints) in the Spine User Guide. */ +export class PathConstraint implements Updatable { + static NONE = -1; + static BEFORE = -2; + static AFTER = -3; + static epsilon = 0.00001; + + /** The path constraint's setup pose data. */ + data: PathConstraintData; + + /** The bones that will be modified by this path constraint. */ + bones: Array; + + /** The slot whose path attachment will be used to constrained the bones. */ + target: Slot; + + /** The position along the path. */ + position = 0; + + /** The spacing between bones. */ + spacing = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + rotateMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained translations. */ + translateMix = 0; + + spaces = new Array(); + positions = new Array(); + world = new Array(); + curves = new Array(); + lengths = new Array(); + segments = new Array(); + + active = false; + + constructor(data: PathConstraintData, skeleton: Skeleton) { + if (data == null) throw new Error("data cannot be null."); + if (skeleton == null) throw new Error("skeleton cannot be null."); + this.data = data; + this.bones = new Array(); + for (let i = 0, n = data.bones.length; i < n; i++) this.bones.push(skeleton.findBone(data.bones[i].name)); + this.target = skeleton.findSlot(data.target.name); + this.position = data.position; + this.spacing = data.spacing; + this.rotateMix = data.rotateMix; + this.translateMix = data.translateMix; + } + + isActive() { + return this.active; + } + + /** Applies the constraint to the constrained bones. */ + apply() { + this.update(); + } + + update() { + const attachment = this.target.getAttachment(); + if (!(attachment instanceof PathAttachment)) return; + + const rotateMix = this.rotateMix, + translateMix = this.translateMix; + const translate = translateMix > 0, + rotate = rotateMix > 0; + if (!translate && !rotate) return; + + const data = this.data; + const percentSpacing = data.spacingMode == SpacingMode.Percent; + const rotateMode = data.rotateMode; + const tangents = rotateMode == RotateMode.Tangent, + scale = rotateMode == RotateMode.ChainScale; + const boneCount = this.bones.length, + spacesCount = tangents ? boneCount : boneCount + 1; + const bones = this.bones; + let spaces = Utils.setArraySize(this.spaces, spacesCount), + lengths: Array = null; + const spacing = this.spacing; + if (scale || !percentSpacing) { + if (scale) lengths = Utils.setArraySize(this.lengths, boneCount); + const lengthSpacing = data.spacingMode == SpacingMode.Length; + for (let i = 0, n = spacesCount - 1; i < n; ) { + const bone = bones[i]; + const setupLength = bone.data.length; + if (setupLength < PathConstraint.epsilon) { + if (scale) lengths[i] = 0; + spaces[++i] = 0; + } else if (percentSpacing) { + if (scale) { + const x = setupLength * bone.a, + y = setupLength * bone.c; + const length = Math.sqrt(x * x + y * y); + lengths[i] = length; + } + spaces[++i] = spacing; + } else { + const x = setupLength * bone.a, + y = setupLength * bone.c; + const length = Math.sqrt(x * x + y * y); + if (scale) lengths[i] = length; + spaces[++i] = ((lengthSpacing ? setupLength + spacing : spacing) * length) / setupLength; + } + } + } else { + for (let i = 1; i < spacesCount; i++) spaces[i] = spacing; + } + + const positions = this.computeWorldPositions( + attachment, + spacesCount, + tangents, + data.positionMode == PositionMode.Percent, + percentSpacing + ); + let boneX = positions[0], + boneY = positions[1], + offsetRotation = data.offsetRotation; + let tip = false; + if (offsetRotation == 0) tip = rotateMode == RotateMode.Chain; + else { + tip = false; + const p = this.target.bone; + offsetRotation *= p.a * p.d - p.b * p.c > 0 ? MathUtils.degRad : -MathUtils.degRad; + } + for (let i = 0, p = 3; i < boneCount; i++, p += 3) { + const bone = bones[i]; + bone.worldX += (boneX - bone.worldX) * translateMix; + bone.worldY += (boneY - bone.worldY) * translateMix; + const x = positions[p], + y = positions[p + 1], + dx = x - boneX, + dy = y - boneY; + if (scale) { + const length = lengths[i]; + if (length != 0) { + const s = (Math.sqrt(dx * dx + dy * dy) / length - 1) * rotateMix + 1; + bone.a *= s; + bone.c *= s; + } + } + boneX = x; + boneY = y; + if (rotate) { + let a = bone.a, + b = bone.b, + c = bone.c, + d = bone.d, + r = 0, + cos = 0, + sin = 0; + if (tangents) r = positions[p - 1]; + else if (spaces[i + 1] == 0) r = positions[p + 2]; + else r = Math.atan2(dy, dx); + r -= Math.atan2(c, a); + if (tip) { + cos = Math.cos(r); + sin = Math.sin(r); + const length = bone.data.length; + boneX += (length * (cos * a - sin * c) - dx) * rotateMix; + boneY += (length * (sin * a + cos * c) - dy) * rotateMix; + } else { + r += offsetRotation; + } + if (r > MathUtils.PI) r -= MathUtils.PI2; + else if (r < -MathUtils.PI) + // + r += MathUtils.PI2; + r *= rotateMix; + cos = Math.cos(r); + sin = Math.sin(r); + bone.a = cos * a - sin * c; + bone.b = cos * b - sin * d; + bone.c = sin * a + cos * c; + bone.d = sin * b + cos * d; + } + bone.appliedValid = false; + } + } + + computeWorldPositions( + path: PathAttachment, + spacesCount: number, + tangents: boolean, + percentPosition: boolean, + percentSpacing: boolean + ) { + const target = this.target; + let position = this.position; + let spaces = this.spaces, + out = Utils.setArraySize(this.positions, spacesCount * 3 + 2), + world: Array = null; + const closed = path.closed; + let verticesLength = path.worldVerticesLength, + curveCount = verticesLength / 6, + prevCurve = PathConstraint.NONE; + + if (!path.constantSpeed) { + const lengths = path.lengths; + curveCount -= closed ? 1 : 2; + const pathLength = lengths[curveCount]; + if (percentPosition) position *= pathLength; + if (percentSpacing) { + for (let i = 1; i < spacesCount; i++) spaces[i] *= pathLength; + } + world = Utils.setArraySize(this.world, 8); + for (let i = 0, o = 0, curve = 0; i < spacesCount; i++, o += 3) { + const space = spaces[i]; + position += space; + let p = position; + + if (closed) { + p %= pathLength; + if (p < 0) p += pathLength; + curve = 0; + } else if (p < 0) { + if (prevCurve != PathConstraint.BEFORE) { + prevCurve = PathConstraint.BEFORE; + path.computeWorldVertices(target, 2, 4, world, 0, 2); + } + this.addBeforePosition(p, world, 0, out, o); + continue; + } else if (p > pathLength) { + if (prevCurve != PathConstraint.AFTER) { + prevCurve = PathConstraint.AFTER; + path.computeWorldVertices(target, verticesLength - 6, 4, world, 0, 2); + } + this.addAfterPosition(p - pathLength, world, 0, out, o); + continue; + } + + // Determine curve containing position. + for (; ; curve++) { + const length = lengths[curve]; + if (p > length) continue; + if (curve == 0) p /= length; + else { + const prev = lengths[curve - 1]; + p = (p - prev) / (length - prev); + } + break; + } + if (curve != prevCurve) { + prevCurve = curve; + if (closed && curve == curveCount) { + path.computeWorldVertices(target, verticesLength - 4, 4, world, 0, 2); + path.computeWorldVertices(target, 0, 4, world, 4, 2); + } else path.computeWorldVertices(target, curve * 6 + 2, 8, world, 0, 2); + } + this.addCurvePosition( + p, + world[0], + world[1], + world[2], + world[3], + world[4], + world[5], + world[6], + world[7], + out, + o, + tangents || (i > 0 && space == 0) + ); + } + return out; + } + + // World vertices. + if (closed) { + verticesLength += 2; + world = Utils.setArraySize(this.world, verticesLength); + path.computeWorldVertices(target, 2, verticesLength - 4, world, 0, 2); + path.computeWorldVertices(target, 0, 2, world, verticesLength - 4, 2); + world[verticesLength - 2] = world[0]; + world[verticesLength - 1] = world[1]; + } else { + curveCount--; + verticesLength -= 4; + world = Utils.setArraySize(this.world, verticesLength); + path.computeWorldVertices(target, 2, verticesLength, world, 0, 2); + } + + // Curve lengths. + const curves = Utils.setArraySize(this.curves, curveCount); + let pathLength = 0; + let x1 = world[0], + y1 = world[1], + cx1 = 0, + cy1 = 0, + cx2 = 0, + cy2 = 0, + x2 = 0, + y2 = 0; + let tmpx = 0, + tmpy = 0, + dddfx = 0, + dddfy = 0, + ddfx = 0, + ddfy = 0, + dfx = 0, + dfy = 0; + for (let i = 0, w = 2; i < curveCount; i++, w += 6) { + cx1 = world[w]; + cy1 = world[w + 1]; + cx2 = world[w + 2]; + cy2 = world[w + 3]; + x2 = world[w + 4]; + y2 = world[w + 5]; + tmpx = (x1 - cx1 * 2 + cx2) * 0.1875; + tmpy = (y1 - cy1 * 2 + cy2) * 0.1875; + dddfx = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375; + dddfy = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375; + ddfx = tmpx * 2 + dddfx; + ddfy = tmpy * 2 + dddfy; + dfx = (cx1 - x1) * 0.75 + tmpx + dddfx * 0.16666667; + dfy = (cy1 - y1) * 0.75 + tmpy + dddfy * 0.16666667; + pathLength += Math.sqrt(dfx * dfx + dfy * dfy); + dfx += ddfx; + dfy += ddfy; + ddfx += dddfx; + ddfy += dddfy; + pathLength += Math.sqrt(dfx * dfx + dfy * dfy); + dfx += ddfx; + dfy += ddfy; + pathLength += Math.sqrt(dfx * dfx + dfy * dfy); + dfx += ddfx + dddfx; + dfy += ddfy + dddfy; + pathLength += Math.sqrt(dfx * dfx + dfy * dfy); + curves[i] = pathLength; + x1 = x2; + y1 = y2; + } + if (percentPosition) position *= pathLength; + else position *= pathLength / path.lengths[curveCount - 1]; + if (percentSpacing) { + for (let i = 1; i < spacesCount; i++) spaces[i] *= pathLength; + } + + const segments = this.segments; + let curveLength = 0; + for (let i = 0, o = 0, curve = 0, segment = 0; i < spacesCount; i++, o += 3) { + const space = spaces[i]; + position += space; + let p = position; + + if (closed) { + p %= pathLength; + if (p < 0) p += pathLength; + curve = 0; + } else if (p < 0) { + this.addBeforePosition(p, world, 0, out, o); + continue; + } else if (p > pathLength) { + this.addAfterPosition(p - pathLength, world, verticesLength - 4, out, o); + continue; + } + + // Determine curve containing position. + for (; ; curve++) { + const length = curves[curve]; + if (p > length) continue; + if (curve == 0) p /= length; + else { + const prev = curves[curve - 1]; + p = (p - prev) / (length - prev); + } + break; + } + + // Curve segment lengths. + if (curve != prevCurve) { + prevCurve = curve; + let ii = curve * 6; + x1 = world[ii]; + y1 = world[ii + 1]; + cx1 = world[ii + 2]; + cy1 = world[ii + 3]; + cx2 = world[ii + 4]; + cy2 = world[ii + 5]; + x2 = world[ii + 6]; + y2 = world[ii + 7]; + tmpx = (x1 - cx1 * 2 + cx2) * 0.03; + tmpy = (y1 - cy1 * 2 + cy2) * 0.03; + dddfx = ((cx1 - cx2) * 3 - x1 + x2) * 0.006; + dddfy = ((cy1 - cy2) * 3 - y1 + y2) * 0.006; + ddfx = tmpx * 2 + dddfx; + ddfy = tmpy * 2 + dddfy; + dfx = (cx1 - x1) * 0.3 + tmpx + dddfx * 0.16666667; + dfy = (cy1 - y1) * 0.3 + tmpy + dddfy * 0.16666667; + curveLength = Math.sqrt(dfx * dfx + dfy * dfy); + segments[0] = curveLength; + for (ii = 1; ii < 8; ii++) { + dfx += ddfx; + dfy += ddfy; + ddfx += dddfx; + ddfy += dddfy; + curveLength += Math.sqrt(dfx * dfx + dfy * dfy); + segments[ii] = curveLength; + } + dfx += ddfx; + dfy += ddfy; + curveLength += Math.sqrt(dfx * dfx + dfy * dfy); + segments[8] = curveLength; + dfx += ddfx + dddfx; + dfy += ddfy + dddfy; + curveLength += Math.sqrt(dfx * dfx + dfy * dfy); + segments[9] = curveLength; + segment = 0; + } + + // Weight by segment length. + p *= curveLength; + for (; ; segment++) { + const length = segments[segment]; + if (p > length) continue; + if (segment == 0) p /= length; + else { + const prev = segments[segment - 1]; + p = segment + (p - prev) / (length - prev); + } + break; + } + this.addCurvePosition(p * 0.1, x1, y1, cx1, cy1, cx2, cy2, x2, y2, out, o, tangents || (i > 0 && space == 0)); + } + return out; + } + + addBeforePosition(p: number, temp: Array, i: number, out: Array, o: number) { + const x1 = temp[i], + y1 = temp[i + 1], + dx = temp[i + 2] - x1, + dy = temp[i + 3] - y1, + r = Math.atan2(dy, dx); + out[o] = x1 + p * Math.cos(r); + out[o + 1] = y1 + p * Math.sin(r); + out[o + 2] = r; + } + + addAfterPosition(p: number, temp: Array, i: number, out: Array, o: number) { + const x1 = temp[i + 2], + y1 = temp[i + 3], + dx = x1 - temp[i], + dy = y1 - temp[i + 1], + r = Math.atan2(dy, dx); + out[o] = x1 + p * Math.cos(r); + out[o + 1] = y1 + p * Math.sin(r); + out[o + 2] = r; + } + + addCurvePosition( + p: number, + x1: number, + y1: number, + cx1: number, + cy1: number, + cx2: number, + cy2: number, + x2: number, + y2: number, + out: Array, + o: number, + tangents: boolean + ) { + if (p == 0 || isNaN(p)) { + out[o] = x1; + out[o + 1] = y1; + out[o + 2] = Math.atan2(cy1 - y1, cx1 - x1); + return; + } + const tt = p * p, + ttt = tt * p, + u = 1 - p, + uu = u * u, + uuu = uu * u; + const ut = u * p, + ut3 = ut * 3, + uut3 = u * ut3, + utt3 = ut3 * p; + const x = x1 * uuu + cx1 * uut3 + cx2 * utt3 + x2 * ttt, + y = y1 * uuu + cy1 * uut3 + cy2 * utt3 + y2 * ttt; + out[o] = x; + out[o + 1] = y; + if (tangents) { + if (p < 0.001) out[o + 2] = Math.atan2(cy1 - y1, cx1 - x1); + else out[o + 2] = Math.atan2(y - (y1 * uu + cy1 * ut * 2 + cy2 * tt), x - (x1 * uu + cx1 * ut * 2 + cx2 * tt)); + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/PathConstraintData.ts b/packages/spine-core-3.8/src/spine-core/PathConstraintData.ts new file mode 100644 index 0000000000..32f5bafe50 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/PathConstraintData.ts @@ -0,0 +1,68 @@ +import { ConstraintData } from "./ConstraintData"; +import { BoneData } from "./BoneData"; +import { SlotData } from "./SlotData"; + +/** Stores the setup pose for a {@link PathConstraint}. + * + * See [Path constraints](http://esotericsoftware.com/spine-path-constraints) in the Spine User Guide. */ +export class PathConstraintData extends ConstraintData { + /** The bones that will be modified by this path constraint. */ + bones = new Array(); + + /** The slot whose path attachment will be used to constrained the bones. */ + target: SlotData; + + /** The mode for positioning the first bone on the path. */ + positionMode: PositionMode; + + /** The mode for positioning the bones after the first bone on the path. */ + spacingMode: SpacingMode; + + /** The mode for adjusting the rotation of the bones. */ + rotateMode: RotateMode; + + /** An offset added to the constrained bone rotation. */ + offsetRotation: number; + + /** The position along the path. */ + position: number; + + /** The spacing between bones. */ + spacing: number; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + rotateMix: number; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained translations. */ + translateMix: number; + + constructor(name: string) { + super(name, 0, false); + } +} + +/** Controls how the first bone is positioned along the path. + * + * See [Position mode](http://esotericsoftware.com/spine-path-constraints#Position-mode) in the Spine User Guide. */ +export enum PositionMode { + Fixed, + Percent +} + +/** Controls how bones after the first bone are positioned along the path. + * + * [Spacing mode](http://esotericsoftware.com/spine-path-constraints#Spacing-mode) in the Spine User Guide. */ +export enum SpacingMode { + Length, + Fixed, + Percent +} + +/** Controls how bones are rotated, translated, and scaled to match the path. + * + * [Rotate mode](http://esotericsoftware.com/spine-path-constraints#Rotate-mod) in the Spine User Guide. */ +export enum RotateMode { + Tangent, + Chain, + ChainScale +} diff --git a/packages/spine-core-3.8/src/spine-core/Skeleton.ts b/packages/spine-core-3.8/src/spine-core/Skeleton.ts new file mode 100644 index 0000000000..2573e25dd9 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Skeleton.ts @@ -0,0 +1,592 @@ +import { SkeletonData } from "./SkeletonData"; +import { Bone } from "./Bone"; +import { Slot } from "./Slot"; +import { IkConstraint } from "./IkConstraint"; +import { TransformConstraint } from "./TransformConstraint"; +import { PathConstraint } from "./PathConstraint"; +import { Updatable } from "./Updatable"; +import { Skin } from "./Skin"; +import { Color, Utils, Vector2 } from "./Utils"; +import { PathAttachment } from "./attachments/PathAttachment"; +import { Attachment } from "./attachments/Attachment"; +import { RegionAttachment } from "./attachments/RegionAttachment"; +import { MeshAttachment } from "./attachments/MeshAttachment"; + +/** Stores the current pose for a skeleton. + * + * See [Instance objects](http://esotericsoftware.com/spine-runtime-architecture#Instance-objects) in the Spine Runtimes Guide. */ +export class Skeleton { + /** The skeleton's setup pose data. */ + data: SkeletonData; + + /** The skeleton's bones, sorted parent first. The root bone is always the first bone. */ + bones: Array; + + /** The skeleton's slots. */ + slots: Array; + + /** The skeleton's slots in the order they should be drawn. The returned array may be modified to change the draw order. */ + drawOrder: Array; + + /** The skeleton's IK constraints. */ + ikConstraints: Array; + + /** The skeleton's transform constraints. */ + transformConstraints: Array; + + /** The skeleton's path constraints. */ + pathConstraints: Array; + + /** The list of bones and constraints, sorted in the order they should be updated, as computed by {@link #updateCache()}. */ + _updateCache = new Array(); + updateCacheReset = new Array(); + + /** The skeleton's current skin. May be null. */ + skin: Skin; + + /** The color to tint all the skeleton's attachments. */ + color: Color; + + /** Returns the skeleton's time. This can be used for tracking, such as with Slot {@link Slot#attachmentTime}. + *

+ * See {@link #update()}. */ + time = 0; + + /** Scales the entire skeleton on the X axis. This affects all bones, even if the bone's transform mode disallows scale + * inheritance. */ + scaleX = 1; + + /** Scales the entire skeleton on the Y axis. This affects all bones, even if the bone's transform mode disallows scale + * inheritance. */ + scaleY = 1; + + /** Sets the skeleton X position, which is added to the root bone worldX position. */ + x = 0; + + /** Sets the skeleton Y position, which is added to the root bone worldY position. */ + y = 0; + + constructor(data: SkeletonData) { + if (data == null) throw new Error("data cannot be null."); + this.data = data; + + this.bones = new Array(); + for (let i = 0; i < data.bones.length; i++) { + const boneData = data.bones[i]; + let bone: Bone; + if (boneData.parent == null) bone = new Bone(boneData, this, null); + else { + const parent = this.bones[boneData.parent.index]; + bone = new Bone(boneData, this, parent); + parent.children.push(bone); + } + this.bones.push(bone); + } + + this.slots = new Array(); + this.drawOrder = new Array(); + for (let i = 0; i < data.slots.length; i++) { + const slotData = data.slots[i]; + const bone = this.bones[slotData.boneData.index]; + const slot = new Slot(slotData, bone); + this.slots.push(slot); + this.drawOrder.push(slot); + } + + this.ikConstraints = new Array(); + for (let i = 0; i < data.ikConstraints.length; i++) { + const ikConstraintData = data.ikConstraints[i]; + this.ikConstraints.push(new IkConstraint(ikConstraintData, this)); + } + + this.transformConstraints = new Array(); + for (let i = 0; i < data.transformConstraints.length; i++) { + const transformConstraintData = data.transformConstraints[i]; + this.transformConstraints.push(new TransformConstraint(transformConstraintData, this)); + } + + this.pathConstraints = new Array(); + for (let i = 0; i < data.pathConstraints.length; i++) { + const pathConstraintData = data.pathConstraints[i]; + this.pathConstraints.push(new PathConstraint(pathConstraintData, this)); + } + + this.color = new Color(1, 1, 1, 1); + this.updateCache(); + } + + /** Caches information about bones and constraints. Must be called if the {@link #getSkin()} is modified or if bones, + * constraints, or weighted path attachments are added or removed. */ + updateCache() { + const updateCache = this._updateCache; + updateCache.length = 0; + this.updateCacheReset.length = 0; + + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + bone.sorted = bone.data.skinRequired; + bone.active = !bone.sorted; + } + + if (this.skin != null) { + const skinBones = this.skin.bones; + for (let i = 0, n = this.skin.bones.length; i < n; i++) { + let bone = this.bones[skinBones[i].index]; + do { + bone.sorted = false; + bone.active = true; + bone = bone.parent; + } while (bone != null); + } + } + + // IK first, lowest hierarchy depth first. + const ikConstraints = this.ikConstraints; + const transformConstraints = this.transformConstraints; + const pathConstraints = this.pathConstraints; + const ikCount = ikConstraints.length, + transformCount = transformConstraints.length, + pathCount = pathConstraints.length; + const constraintCount = ikCount + transformCount + pathCount; + + outer: for (let i = 0; i < constraintCount; i++) { + for (let ii = 0; ii < ikCount; ii++) { + const constraint = ikConstraints[ii]; + if (constraint.data.order == i) { + this.sortIkConstraint(constraint); + continue outer; + } + } + for (let ii = 0; ii < transformCount; ii++) { + const constraint = transformConstraints[ii]; + if (constraint.data.order == i) { + this.sortTransformConstraint(constraint); + continue outer; + } + } + for (let ii = 0; ii < pathCount; ii++) { + const constraint = pathConstraints[ii]; + if (constraint.data.order == i) { + this.sortPathConstraint(constraint); + continue outer; + } + } + } + + for (let i = 0, n = bones.length; i < n; i++) this.sortBone(bones[i]); + } + + sortIkConstraint(constraint: IkConstraint) { + constraint.active = + constraint.target.isActive() && + (!constraint.data.skinRequired || + (this.skin != null && Utils.contains(this.skin.constraints, constraint.data, true))); + if (!constraint.active) return; + + const target = constraint.target; + this.sortBone(target); + + const constrained = constraint.bones; + const parent = constrained[0]; + this.sortBone(parent); + + if (constrained.length > 1) { + const child = constrained[constrained.length - 1]; + if (!(this._updateCache.indexOf(child) > -1)) this.updateCacheReset.push(child); + } + + this._updateCache.push(constraint); + + this.sortReset(parent.children); + constrained[constrained.length - 1].sorted = true; + } + + sortPathConstraint(constraint: PathConstraint) { + constraint.active = + constraint.target.bone.isActive() && + (!constraint.data.skinRequired || + (this.skin != null && Utils.contains(this.skin.constraints, constraint.data, true))); + if (!constraint.active) return; + + const slot = constraint.target; + const slotIndex = slot.data.index; + const slotBone = slot.bone; + if (this.skin != null) this.sortPathConstraintAttachment(this.skin, slotIndex, slotBone); + if (this.data.defaultSkin != null && this.data.defaultSkin != this.skin) + this.sortPathConstraintAttachment(this.data.defaultSkin, slotIndex, slotBone); + for (let i = 0, n = this.data.skins.length; i < n; i++) + this.sortPathConstraintAttachment(this.data.skins[i], slotIndex, slotBone); + + const attachment = slot.getAttachment(); + if (attachment instanceof PathAttachment) this.sortPathConstraintAttachmentWith(attachment, slotBone); + + const constrained = constraint.bones; + const boneCount = constrained.length; + for (let i = 0; i < boneCount; i++) this.sortBone(constrained[i]); + + this._updateCache.push(constraint); + + for (let i = 0; i < boneCount; i++) this.sortReset(constrained[i].children); + for (let i = 0; i < boneCount; i++) constrained[i].sorted = true; + } + + sortTransformConstraint(constraint: TransformConstraint) { + constraint.active = + constraint.target.isActive() && + (!constraint.data.skinRequired || + (this.skin != null && Utils.contains(this.skin.constraints, constraint.data, true))); + if (!constraint.active) return; + + this.sortBone(constraint.target); + + const constrained = constraint.bones; + const boneCount = constrained.length; + if (constraint.data.local) { + for (let i = 0; i < boneCount; i++) { + const child = constrained[i]; + this.sortBone(child.parent); + if (!(this._updateCache.indexOf(child) > -1)) this.updateCacheReset.push(child); + } + } else { + for (let i = 0; i < boneCount; i++) { + this.sortBone(constrained[i]); + } + } + + this._updateCache.push(constraint); + + for (let ii = 0; ii < boneCount; ii++) this.sortReset(constrained[ii].children); + for (let ii = 0; ii < boneCount; ii++) constrained[ii].sorted = true; + } + + sortPathConstraintAttachment(skin: Skin, slotIndex: number, slotBone: Bone) { + const attachments = skin.attachments[slotIndex]; + if (!attachments) return; + for (const key in attachments) { + this.sortPathConstraintAttachmentWith(attachments[key], slotBone); + } + } + + sortPathConstraintAttachmentWith(attachment: Attachment, slotBone: Bone) { + if (!(attachment instanceof PathAttachment)) return; + const pathBones = (attachment).bones; + if (pathBones == null) this.sortBone(slotBone); + else { + const bones = this.bones; + let i = 0; + while (i < pathBones.length) { + const boneCount = pathBones[i++]; + for (let n = i + boneCount; i < n; i++) { + const boneIndex = pathBones[i]; + this.sortBone(bones[boneIndex]); + } + } + } + } + + sortBone(bone: Bone) { + if (bone.sorted) return; + const parent = bone.parent; + if (parent != null) this.sortBone(parent); + bone.sorted = true; + this._updateCache.push(bone); + } + + sortReset(bones: Array) { + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + if (!bone.active) continue; + if (bone.sorted) this.sortReset(bone.children); + bone.sorted = false; + } + } + + /** Updates the world transform for each bone and applies all constraints. + * + * See [World transforms](http://esotericsoftware.com/spine-runtime-skeletons#World-transforms) in the Spine + * Runtimes Guide. */ + updateWorldTransform() { + const updateCacheReset = this.updateCacheReset; + for (let i = 0, n = updateCacheReset.length; i < n; i++) { + const bone = updateCacheReset[i] as Bone; + bone.ax = bone.x; + bone.ay = bone.y; + bone.arotation = bone.rotation; + bone.ascaleX = bone.scaleX; + bone.ascaleY = bone.scaleY; + bone.ashearX = bone.shearX; + bone.ashearY = bone.shearY; + bone.appliedValid = true; + } + const updateCache = this._updateCache; + for (let i = 0, n = updateCache.length; i < n; i++) updateCache[i].update(); + } + + /** Sets the bones, constraints, and slots to their setup pose values. */ + setToSetupPose() { + this.setBonesToSetupPose(); + this.setSlotsToSetupPose(); + } + + /** Sets the bones and constraints to their setup pose values. */ + setBonesToSetupPose() { + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) bones[i].setToSetupPose(); + + const ikConstraints = this.ikConstraints; + for (let i = 0, n = ikConstraints.length; i < n; i++) { + const constraint = ikConstraints[i]; + constraint.mix = constraint.data.mix; + constraint.softness = constraint.data.softness; + constraint.bendDirection = constraint.data.bendDirection; + constraint.compress = constraint.data.compress; + constraint.stretch = constraint.data.stretch; + } + + const transformConstraints = this.transformConstraints; + for (let i = 0, n = transformConstraints.length; i < n; i++) { + const constraint = transformConstraints[i]; + const data = constraint.data; + constraint.rotateMix = data.rotateMix; + constraint.translateMix = data.translateMix; + constraint.scaleMix = data.scaleMix; + constraint.shearMix = data.shearMix; + } + + const pathConstraints = this.pathConstraints; + for (let i = 0, n = pathConstraints.length; i < n; i++) { + const constraint = pathConstraints[i]; + const data = constraint.data; + constraint.position = data.position; + constraint.spacing = data.spacing; + constraint.rotateMix = data.rotateMix; + constraint.translateMix = data.translateMix; + } + } + + /** Sets the slots and draw order to their setup pose values. */ + setSlotsToSetupPose() { + const slots = this.slots; + Utils.arrayCopy(slots, 0, this.drawOrder, 0, slots.length); + for (let i = 0, n = slots.length; i < n; i++) slots[i].setToSetupPose(); + } + + /** @returns May return null. */ + getRootBone() { + if (this.bones.length == 0) return null; + return this.bones[0]; + } + + /** @returns May be null. */ + findBone(boneName: string) { + if (boneName == null) throw new Error("boneName cannot be null."); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + if (bone.data.name == boneName) return bone; + } + return null; + } + + /** @returns -1 if the bone was not found. */ + findBoneIndex(boneName: string) { + if (boneName == null) throw new Error("boneName cannot be null."); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) if (bones[i].data.name == boneName) return i; + return -1; + } + + /** Finds a slot by comparing each slot's name. It is more efficient to cache the results of this method than to call it + * repeatedly. + * @returns May be null. */ + findSlot(slotName: string) { + if (slotName == null) throw new Error("slotName cannot be null."); + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) { + const slot = slots[i]; + if (slot.data.name == slotName) return slot; + } + return null; + } + + /** @returns -1 if the bone was not found. */ + findSlotIndex(slotName: string) { + if (slotName == null) throw new Error("slotName cannot be null."); + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) if (slots[i].data.name == slotName) return i; + return -1; + } + + /** Sets a skin by name. + * + * See {@link #setSkin()}. */ + setSkinByName(skinName: string) { + const skin = this.data.findSkin(skinName); + if (skin == null) throw new Error("Skin not found: " + skinName); + this.setSkin(skin); + } + + /** Sets the skin used to look up attachments before looking in the {@link SkeletonData#defaultSkin default skin}. If the + * skin is changed, {@link #updateCache()} is called. + * + * Attachments from the new skin are attached if the corresponding attachment from the old skin was attached. If there was no + * old skin, each slot's setup mode attachment is attached from the new skin. + * + * After changing the skin, the visible attachments can be reset to those attached in the setup pose by calling + * {@link #setSlotsToSetupPose()}. Also, often {@link AnimationState#apply()} is called before the next time the + * skeleton is rendered to allow any attachment keys in the current animation(s) to hide or show attachments from the new skin. + * @param newSkin May be null. */ + setSkin(newSkin: Skin) { + if (newSkin == this.skin) return; + if (newSkin != null) { + if (this.skin != null) newSkin.attachAll(this, this.skin); + else { + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) { + const slot = slots[i]; + const name = slot.data.attachmentName; + if (name != null) { + const attachment: Attachment = newSkin.getAttachment(i, name); + if (attachment != null) slot.setAttachment(attachment); + } + } + } + } + this.skin = newSkin; + this.updateCache(); + } + + /** Finds an attachment by looking in the {@link #skin} and {@link SkeletonData#defaultSkin} using the slot name and attachment + * name. + * + * See {@link #getAttachment()}. + * @returns May be null. */ + getAttachmentByName(slotName: string, attachmentName: string): Attachment { + return this.getAttachment(this.data.findSlotIndex(slotName), attachmentName); + } + + /** Finds an attachment by looking in the {@link #skin} and {@link SkeletonData#defaultSkin} using the slot index and + * attachment name. First the skin is checked and if the attachment was not found, the default skin is checked. + * + * See [Runtime skins](http://esotericsoftware.com/spine-runtime-skins) in the Spine Runtimes Guide. + * @returns May be null. */ + getAttachment(slotIndex: number, attachmentName: string): Attachment { + if (attachmentName == null) throw new Error("attachmentName cannot be null."); + if (this.skin != null) { + const attachment: Attachment = this.skin.getAttachment(slotIndex, attachmentName); + if (attachment != null) return attachment; + } + if (this.data.defaultSkin != null) return this.data.defaultSkin.getAttachment(slotIndex, attachmentName); + return null; + } + + /** A convenience method to set an attachment by finding the slot with {@link #findSlot()}, finding the attachment with + * {@link #getAttachment()}, then setting the slot's {@link Slot#attachment}. + * @param attachmentName May be null to clear the slot's attachment. */ + setAttachment(slotName: string, attachmentName: string) { + if (slotName == null) throw new Error("slotName cannot be null."); + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) { + const slot = slots[i]; + if (slot.data.name == slotName) { + let attachment: Attachment = null; + if (attachmentName != null) { + attachment = this.getAttachment(i, attachmentName); + if (attachment == null) + throw new Error("Attachment not found: " + attachmentName + ", for slot: " + slotName); + } + slot.setAttachment(attachment); + return; + } + } + throw new Error("Slot not found: " + slotName); + } + + /** Finds an IK constraint by comparing each IK constraint's name. It is more efficient to cache the results of this method + * than to call it repeatedly. + * @return May be null. */ + findIkConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const ikConstraints = this.ikConstraints; + for (let i = 0, n = ikConstraints.length; i < n; i++) { + const ikConstraint = ikConstraints[i]; + if (ikConstraint.data.name == constraintName) return ikConstraint; + } + return null; + } + + /** Finds a transform constraint by comparing each transform constraint's name. It is more efficient to cache the results of + * this method than to call it repeatedly. + * @return May be null. */ + findTransformConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const transformConstraints = this.transformConstraints; + for (let i = 0, n = transformConstraints.length; i < n; i++) { + const constraint = transformConstraints[i]; + if (constraint.data.name == constraintName) return constraint; + } + return null; + } + + /** Finds a path constraint by comparing each path constraint's name. It is more efficient to cache the results of this method + * than to call it repeatedly. + * @return May be null. */ + findPathConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const pathConstraints = this.pathConstraints; + for (let i = 0, n = pathConstraints.length; i < n; i++) { + const constraint = pathConstraints[i]; + if (constraint.data.name == constraintName) return constraint; + } + return null; + } + + /** Returns the axis aligned bounding box (AABB) of the region and mesh attachments for the current pose. + * @param offset An output value, the distance from the skeleton origin to the bottom left corner of the AABB. + * @param size An output value, the width and height of the AABB. + * @param temp Working memory to temporarily store attachments' computed world vertices. */ + getBounds(offset: Vector2, size: Vector2, temp: Array = new Array(2)) { + if (offset == null) throw new Error("offset cannot be null."); + if (size == null) throw new Error("size cannot be null."); + const drawOrder = this.drawOrder; + let minX = Number.POSITIVE_INFINITY, + minY = Number.POSITIVE_INFINITY, + maxX = Number.NEGATIVE_INFINITY, + maxY = Number.NEGATIVE_INFINITY; + for (let i = 0, n = drawOrder.length; i < n; i++) { + const slot = drawOrder[i]; + if (!slot.bone.active) continue; + let verticesLength = 0; + let vertices: ArrayLike = null; + const attachment = slot.getAttachment(); + if (attachment instanceof RegionAttachment) { + verticesLength = 8; + vertices = Utils.setArraySize(temp, verticesLength, 0); + (attachment).computeWorldVertices(slot.bone, vertices, 0, 2); + } else if (attachment instanceof MeshAttachment) { + const mesh = attachment; + verticesLength = mesh.worldVerticesLength; + vertices = Utils.setArraySize(temp, verticesLength, 0); + mesh.computeWorldVertices(slot, 0, verticesLength, vertices, 0, 2); + } + if (vertices != null) { + for (let ii = 0, nn = vertices.length; ii < nn; ii += 2) { + const x = vertices[ii], + y = vertices[ii + 1]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + offset.set(minX, minY); + size.set(maxX - minX, maxY - minY); + } + + /** Increments the skeleton's {@link #time}. */ + update(delta: number) { + this.time += delta; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/SkeletonBinary.ts b/packages/spine-core-3.8/src/spine-core/SkeletonBinary.ts new file mode 100644 index 0000000000..86ec2f722c --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SkeletonBinary.ts @@ -0,0 +1,942 @@ +import { TransformMode, BoneData } from "./BoneData"; +import { PositionMode, SpacingMode, RotateMode, PathConstraintData } from "./PathConstraintData"; +import { BlendMode } from "./BlendMode"; +import { AttachmentLoader } from "./attachments/AttachmentLoader"; +import { SkeletonData } from "./SkeletonData"; +import { Color, Utils } from "./Utils"; +import { SlotData } from "./SlotData"; +import { IkConstraintData } from "./IkConstraintData"; +import { TransformConstraintData } from "./TransformConstraintData"; +import { VertexAttachment, Attachment } from "./attachments/Attachment"; +import { MeshAttachment } from "./attachments/MeshAttachment"; +import { EventData } from "./EventData"; +import { Skin } from "./Skin"; +import { AttachmentType } from "./attachments/AttachmentType"; +import { + Timeline, + AttachmentTimeline, + ColorTimeline, + TwoColorTimeline, + RotateTimeline, + ScaleTimeline, + ShearTimeline, + TranslateTimeline, + IkConstraintTimeline, + TransformConstraintTimeline, + PathConstraintSpacingTimeline, + PathConstraintPositionTimeline, + PathConstraintMixTimeline, + DeformTimeline, + DrawOrderTimeline, + EventTimeline, + CurveTimeline +} from "./Animation"; +import { Animation } from "./Animation"; +import { Event } from "./Event"; + +/** Loads skeleton data in the Spine binary format. + * + * See [Spine binary format](http://esotericsoftware.com/spine-binary-format) and + * [JSON and binary data](http://esotericsoftware.com/spine-loading-skeleton-data#JSON-and-binary-data) in the Spine + * Runtimes Guide. */ +export class SkeletonBinary { + static AttachmentTypeValues = [ + 0 /*AttachmentType.Region*/, 1 /*AttachmentType.BoundingBox*/, 2 /*AttachmentType.Mesh*/, + 3 /*AttachmentType.LinkedMesh*/, 4 /*AttachmentType.Path*/, 5 /*AttachmentType.Point*/, + 6 /*AttachmentType.Clipping*/ + ]; + static TransformModeValues = [ + TransformMode.Normal, + TransformMode.OnlyTranslation, + TransformMode.NoRotationOrReflection, + TransformMode.NoScale, + TransformMode.NoScaleOrReflection + ]; + static PositionModeValues = [PositionMode.Fixed, PositionMode.Percent]; + static SpacingModeValues = [SpacingMode.Length, SpacingMode.Fixed, SpacingMode.Percent]; + static RotateModeValues = [RotateMode.Tangent, RotateMode.Chain, RotateMode.ChainScale]; + static BlendModeValues = [BlendMode.Normal, BlendMode.Additive, BlendMode.Multiply, BlendMode.Screen]; + + static BONE_ROTATE = 0; + static BONE_TRANSLATE = 1; + static BONE_SCALE = 2; + static BONE_SHEAR = 3; + + static SLOT_ATTACHMENT = 0; + static SLOT_COLOR = 1; + static SLOT_TWO_COLOR = 2; + + static PATH_POSITION = 0; + static PATH_SPACING = 1; + static PATH_MIX = 2; + + static CURVE_LINEAR = 0; + static CURVE_STEPPED = 1; + static CURVE_BEZIER = 2; + + /** Scales bone positions, image sizes, and translations as they are loaded. This allows different size images to be used at + * runtime than were used in Spine. + * + * See [Scaling](http://esotericsoftware.com/spine-loading-skeleton-data#Scaling) in the Spine Runtimes Guide. */ + scale = 1; + + attachmentLoader: AttachmentLoader; + private linkedMeshes = new Array(); + + constructor(attachmentLoader: AttachmentLoader) { + this.attachmentLoader = attachmentLoader; + } + + readSkeletonData(binary: Uint8Array): SkeletonData { + const scale = this.scale; + + const skeletonData = new SkeletonData(); + skeletonData.name = ""; // BOZO + + const input = new BinaryInput(binary); + + skeletonData.hash = input.readString(); + skeletonData.version = input.readString(); + if ("3.8.75" == skeletonData.version) + throw new Error("Unsupported skeleton data, please export with a newer version of Spine."); + skeletonData.x = input.readFloat(); + skeletonData.y = input.readFloat(); + skeletonData.width = input.readFloat(); + skeletonData.height = input.readFloat(); + + const nonessential = input.readBoolean(); + if (nonessential) { + skeletonData.fps = input.readFloat(); + + skeletonData.imagesPath = input.readString(); + skeletonData.audioPath = input.readString(); + } + + let n = 0; + // Strings. + n = input.readInt(true); + for (let i = 0; i < n; i++) input.strings.push(input.readString()); + + // Bones. + n = input.readInt(true); + for (let i = 0; i < n; i++) { + const name = input.readString(); + const parent = i == 0 ? null : skeletonData.bones[input.readInt(true)]; + const data = new BoneData(i, name, parent); + data.rotation = input.readFloat(); + data.x = input.readFloat() * scale; + data.y = input.readFloat() * scale; + data.scaleX = input.readFloat(); + data.scaleY = input.readFloat(); + data.shearX = input.readFloat(); + data.shearY = input.readFloat(); + data.length = input.readFloat() * scale; + data.transformMode = SkeletonBinary.TransformModeValues[input.readInt(true)]; + data.skinRequired = input.readBoolean(); + if (nonessential) Color.rgba8888ToColor(data.color, input.readInt32()); + skeletonData.bones.push(data); + } + + // Slots. + n = input.readInt(true); + for (let i = 0; i < n; i++) { + const slotName = input.readString(); + const boneData = skeletonData.bones[input.readInt(true)]; + const data = new SlotData(i, slotName, boneData); + Color.rgba8888ToColor(data.color, input.readInt32()); + + const darkColor = input.readInt32(); + if (darkColor != -1) Color.rgb888ToColor((data.darkColor = new Color()), darkColor); + + data.attachmentName = input.readStringRef(); + data.blendMode = SkeletonBinary.BlendModeValues[input.readInt(true)]; + skeletonData.slots.push(data); + } + + // IK constraints. + n = input.readInt(true); + for (let i = 0, nn; i < n; i++) { + const data = new IkConstraintData(input.readString()); + data.order = input.readInt(true); + data.skinRequired = input.readBoolean(); + nn = input.readInt(true); + for (let ii = 0; ii < nn; ii++) data.bones.push(skeletonData.bones[input.readInt(true)]); + data.target = skeletonData.bones[input.readInt(true)]; + data.mix = input.readFloat(); + data.softness = input.readFloat() * scale; + data.bendDirection = input.readByte(); + data.compress = input.readBoolean(); + data.stretch = input.readBoolean(); + data.uniform = input.readBoolean(); + skeletonData.ikConstraints.push(data); + } + + // Transform constraints. + n = input.readInt(true); + for (let i = 0, nn; i < n; i++) { + const data = new TransformConstraintData(input.readString()); + data.order = input.readInt(true); + data.skinRequired = input.readBoolean(); + nn = input.readInt(true); + for (let ii = 0; ii < nn; ii++) data.bones.push(skeletonData.bones[input.readInt(true)]); + data.target = skeletonData.bones[input.readInt(true)]; + data.local = input.readBoolean(); + data.relative = input.readBoolean(); + data.offsetRotation = input.readFloat(); + data.offsetX = input.readFloat() * scale; + data.offsetY = input.readFloat() * scale; + data.offsetScaleX = input.readFloat(); + data.offsetScaleY = input.readFloat(); + data.offsetShearY = input.readFloat(); + data.rotateMix = input.readFloat(); + data.translateMix = input.readFloat(); + data.scaleMix = input.readFloat(); + data.shearMix = input.readFloat(); + skeletonData.transformConstraints.push(data); + } + + // Path constraints. + n = input.readInt(true); + for (let i = 0, nn; i < n; i++) { + const data = new PathConstraintData(input.readString()); + data.order = input.readInt(true); + data.skinRequired = input.readBoolean(); + nn = input.readInt(true); + for (let ii = 0; ii < nn; ii++) data.bones.push(skeletonData.bones[input.readInt(true)]); + data.target = skeletonData.slots[input.readInt(true)]; + data.positionMode = SkeletonBinary.PositionModeValues[input.readInt(true)]; + data.spacingMode = SkeletonBinary.SpacingModeValues[input.readInt(true)]; + data.rotateMode = SkeletonBinary.RotateModeValues[input.readInt(true)]; + data.offsetRotation = input.readFloat(); + data.position = input.readFloat(); + if (data.positionMode == PositionMode.Fixed) data.position *= scale; + data.spacing = input.readFloat(); + if (data.spacingMode == SpacingMode.Length || data.spacingMode == SpacingMode.Fixed) data.spacing *= scale; + data.rotateMix = input.readFloat(); + data.translateMix = input.readFloat(); + skeletonData.pathConstraints.push(data); + } + + // Default skin. + const defaultSkin = this.readSkin(input, skeletonData, true, nonessential); + if (defaultSkin != null) { + skeletonData.defaultSkin = defaultSkin; + skeletonData.skins.push(defaultSkin); + } + + // Skins. + { + let i = skeletonData.skins.length; + Utils.setArraySize(skeletonData.skins, (n = i + input.readInt(true))); + for (; i < n; i++) skeletonData.skins[i] = this.readSkin(input, skeletonData, false, nonessential); + } + + // Linked meshes. + n = this.linkedMeshes.length; + for (let i = 0; i < n; i++) { + const linkedMesh = this.linkedMeshes[i]; + const skin = linkedMesh.skin == null ? skeletonData.defaultSkin : skeletonData.findSkin(linkedMesh.skin); + if (skin == null) throw new Error("Skin not found: " + linkedMesh.skin); + const parent = skin.getAttachment(linkedMesh.slotIndex, linkedMesh.parent); + if (parent == null) throw new Error("Parent mesh not found: " + linkedMesh.parent); + linkedMesh.mesh.deformAttachment = linkedMesh.inheritDeform ? (parent as VertexAttachment) : linkedMesh.mesh; + linkedMesh.mesh.setParentMesh(parent as MeshAttachment); + linkedMesh.mesh.updateUVs(); + } + this.linkedMeshes.length = 0; + + // Events. + n = input.readInt(true); + for (let i = 0; i < n; i++) { + const data = new EventData(input.readStringRef()); + data.intValue = input.readInt(false); + data.floatValue = input.readFloat(); + data.stringValue = input.readString(); + data.audioPath = input.readString(); + if (data.audioPath != null) { + data.volume = input.readFloat(); + data.balance = input.readFloat(); + } + skeletonData.events.push(data); + } + + // Animations. + n = input.readInt(true); + for (let i = 0; i < n; i++) + skeletonData.animations.push(this.readAnimation(input, input.readString(), skeletonData)); + return skeletonData; + } + + private readSkin(input: BinaryInput, skeletonData: SkeletonData, defaultSkin: boolean, nonessential: boolean): Skin { + let skin = null; + let slotCount = 0; + + if (defaultSkin) { + slotCount = input.readInt(true); + if (slotCount == 0) return null; + skin = new Skin("default"); + } else { + skin = new Skin(input.readStringRef()); + skin.bones.length = input.readInt(true); + for (let i = 0, n = skin.bones.length; i < n; i++) skin.bones[i] = skeletonData.bones[input.readInt(true)]; + + for (let i = 0, n = input.readInt(true); i < n; i++) + skin.constraints.push(skeletonData.ikConstraints[input.readInt(true)]); + for (let i = 0, n = input.readInt(true); i < n; i++) + skin.constraints.push(skeletonData.transformConstraints[input.readInt(true)]); + for (let i = 0, n = input.readInt(true); i < n; i++) + skin.constraints.push(skeletonData.pathConstraints[input.readInt(true)]); + + slotCount = input.readInt(true); + } + + for (let i = 0; i < slotCount; i++) { + const slotIndex = input.readInt(true); + for (let ii = 0, nn = input.readInt(true); ii < nn; ii++) { + const name = input.readStringRef(); + const attachment = this.readAttachment(input, skeletonData, skin, slotIndex, name, nonessential); + if (attachment != null) skin.setAttachment(slotIndex, name, attachment); + } + } + return skin; + } + + private readAttachment( + input: BinaryInput, + skeletonData: SkeletonData, + skin: Skin, + slotIndex: number, + attachmentName: string, + nonessential: boolean + ): Attachment { + const scale = this.scale; + + let name = input.readStringRef(); + if (name == null) name = attachmentName; + + const typeIndex = input.readByte(); + const type = SkeletonBinary.AttachmentTypeValues[typeIndex]; + switch (type) { + case AttachmentType.Region: { + let path = input.readStringRef(); + const rotation = input.readFloat(); + const x = input.readFloat(); + const y = input.readFloat(); + const scaleX = input.readFloat(); + const scaleY = input.readFloat(); + const width = input.readFloat(); + const height = input.readFloat(); + const color = input.readInt32(); + + if (path == null) path = name; + const region = this.attachmentLoader.newRegionAttachment(skin, name, path); + if (region == null) return null; + region.path = path; + region.x = x * scale; + region.y = y * scale; + region.scaleX = scaleX; + region.scaleY = scaleY; + region.rotation = rotation; + region.width = width * scale; + region.height = height * scale; + Color.rgba8888ToColor(region.color, color); + region.updateOffset(); + return region; + } + case AttachmentType.BoundingBox: { + const vertexCount = input.readInt(true); + const vertices = this.readVertices(input, vertexCount); + const color = nonessential ? input.readInt32() : 0; + + const box = this.attachmentLoader.newBoundingBoxAttachment(skin, name); + if (box == null) return null; + box.worldVerticesLength = vertexCount << 1; + box.vertices = vertices.vertices; + box.bones = vertices.bones; + if (nonessential) Color.rgba8888ToColor(box.color, color); + return box; + } + case AttachmentType.Mesh: { + let path = input.readStringRef(); + const color = input.readInt32(); + const vertexCount = input.readInt(true); + const uvs = this.readFloatArray(input, vertexCount << 1, 1); + const triangles = this.readShortArray(input); + const vertices = this.readVertices(input, vertexCount); + const hullLength = input.readInt(true); + let edges = null; + let width = 0, + height = 0; + if (nonessential) { + edges = this.readShortArray(input); + width = input.readFloat(); + height = input.readFloat(); + } + + if (path == null) path = name; + const mesh = this.attachmentLoader.newMeshAttachment(skin, name, path); + if (mesh == null) return null; + mesh.path = path; + Color.rgba8888ToColor(mesh.color, color); + mesh.bones = vertices.bones; + mesh.vertices = vertices.vertices; + mesh.worldVerticesLength = vertexCount << 1; + mesh.triangles = triangles; + mesh.regionUVs = uvs; + mesh.updateUVs(); + mesh.hullLength = hullLength << 1; + if (nonessential) { + mesh.edges = edges; + mesh.width = width * scale; + mesh.height = height * scale; + } + return mesh; + } + case AttachmentType.LinkedMesh: { + let path = input.readStringRef(); + const color = input.readInt32(); + const skinName = input.readStringRef(); + const parent = input.readStringRef(); + const inheritDeform = input.readBoolean(); + let width = 0, + height = 0; + if (nonessential) { + width = input.readFloat(); + height = input.readFloat(); + } + + if (path == null) path = name; + const mesh = this.attachmentLoader.newMeshAttachment(skin, name, path); + if (mesh == null) return null; + mesh.path = path; + Color.rgba8888ToColor(mesh.color, color); + if (nonessential) { + mesh.width = width * scale; + mesh.height = height * scale; + } + this.linkedMeshes.push(new LinkedMesh(mesh, skinName, slotIndex, parent, inheritDeform)); + return mesh; + } + case AttachmentType.Path: { + const closed = input.readBoolean(); + const constantSpeed = input.readBoolean(); + const vertexCount = input.readInt(true); + const vertices = this.readVertices(input, vertexCount); + const lengths = Utils.newArray(vertexCount / 3, 0); + for (let i = 0, n = lengths.length; i < n; i++) lengths[i] = input.readFloat() * scale; + const color = nonessential ? input.readInt32() : 0; + + const path = this.attachmentLoader.newPathAttachment(skin, name); + if (path == null) return null; + path.closed = closed; + path.constantSpeed = constantSpeed; + path.worldVerticesLength = vertexCount << 1; + path.vertices = vertices.vertices; + path.bones = vertices.bones; + path.lengths = lengths; + if (nonessential) Color.rgba8888ToColor(path.color, color); + return path; + } + case AttachmentType.Point: { + const rotation = input.readFloat(); + const x = input.readFloat(); + const y = input.readFloat(); + const color = nonessential ? input.readInt32() : 0; + + const point = this.attachmentLoader.newPointAttachment(skin, name); + if (point == null) return null; + point.x = x * scale; + point.y = y * scale; + point.rotation = rotation; + if (nonessential) Color.rgba8888ToColor(point.color, color); + return point; + } + case AttachmentType.Clipping: { + const endSlotIndex = input.readInt(true); + const vertexCount = input.readInt(true); + const vertices = this.readVertices(input, vertexCount); + const color = nonessential ? input.readInt32() : 0; + + const clip = this.attachmentLoader.newClippingAttachment(skin, name); + if (clip == null) return null; + clip.endSlot = skeletonData.slots[endSlotIndex]; + clip.worldVerticesLength = vertexCount << 1; + clip.vertices = vertices.vertices; + clip.bones = vertices.bones; + if (nonessential) Color.rgba8888ToColor(clip.color, color); + return clip; + } + } + return null; + } + + private readVertices(input: BinaryInput, vertexCount: number): Vertices { + const verticesLength = vertexCount << 1; + const vertices = new Vertices(); + const scale = this.scale; + if (!input.readBoolean()) { + vertices.vertices = this.readFloatArray(input, verticesLength, scale); + return vertices; + } + const weights = new Array(); + const bonesArray = new Array(); + for (let i = 0; i < vertexCount; i++) { + const boneCount = input.readInt(true); + bonesArray.push(boneCount); + for (let ii = 0; ii < boneCount; ii++) { + bonesArray.push(input.readInt(true)); + weights.push(input.readFloat() * scale); + weights.push(input.readFloat() * scale); + weights.push(input.readFloat()); + } + } + vertices.vertices = Utils.toFloatArray(weights); + vertices.bones = bonesArray; + return vertices; + } + + private readFloatArray(input: BinaryInput, n: number, scale: number): number[] { + const array = new Array(n); + if (scale == 1) { + for (let i = 0; i < n; i++) array[i] = input.readFloat(); + } else { + for (let i = 0; i < n; i++) array[i] = input.readFloat() * scale; + } + return array; + } + + private readShortArray(input: BinaryInput): number[] { + const n = input.readInt(true); + const array = new Array(n); + for (let i = 0; i < n; i++) array[i] = input.readShort(); + return array; + } + + private readAnimation(input: BinaryInput, name: string, skeletonData: SkeletonData): Animation { + const timelines = new Array(); + const scale = this.scale; + let duration = 0; + const tempColor1 = new Color(); + const tempColor2 = new Color(); + + // Slot timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const slotIndex = input.readInt(true); + for (let ii = 0, nn = input.readInt(true); ii < nn; ii++) { + const timelineType = input.readByte(); + const frameCount = input.readInt(true); + switch (timelineType) { + case SkeletonBinary.SLOT_ATTACHMENT: { + const timeline = new AttachmentTimeline(frameCount); + timeline.slotIndex = slotIndex; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) + timeline.setFrame(frameIndex, input.readFloat(), input.readStringRef()); + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[frameCount - 1]); + break; + } + case SkeletonBinary.SLOT_COLOR: { + const timeline = new ColorTimeline(frameCount); + timeline.slotIndex = slotIndex; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + const time = input.readFloat(); + Color.rgba8888ToColor(tempColor1, input.readInt32()); + timeline.setFrame(frameIndex, time, tempColor1.r, tempColor1.g, tempColor1.b, tempColor1.a); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * ColorTimeline.ENTRIES]); + break; + } + case SkeletonBinary.SLOT_TWO_COLOR: { + const timeline = new TwoColorTimeline(frameCount); + timeline.slotIndex = slotIndex; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + const time = input.readFloat(); + Color.rgba8888ToColor(tempColor1, input.readInt32()); + Color.rgb888ToColor(tempColor2, input.readInt32()); + timeline.setFrame( + frameIndex, + time, + tempColor1.r, + tempColor1.g, + tempColor1.b, + tempColor1.a, + tempColor2.r, + tempColor2.g, + tempColor2.b + ); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * TwoColorTimeline.ENTRIES]); + break; + } + } + } + } + + // Bone timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const boneIndex = input.readInt(true); + for (let ii = 0, nn = input.readInt(true); ii < nn; ii++) { + const timelineType = input.readByte(); + const frameCount = input.readInt(true); + switch (timelineType) { + case SkeletonBinary.BONE_ROTATE: { + const timeline = new RotateTimeline(frameCount); + timeline.boneIndex = boneIndex; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame(frameIndex, input.readFloat(), input.readFloat()); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * RotateTimeline.ENTRIES]); + break; + } + case SkeletonBinary.BONE_TRANSLATE: + case SkeletonBinary.BONE_SCALE: + case SkeletonBinary.BONE_SHEAR: { + let timeline; + let timelineScale = 1; + if (timelineType == SkeletonBinary.BONE_SCALE) timeline = new ScaleTimeline(frameCount); + else if (timelineType == SkeletonBinary.BONE_SHEAR) timeline = new ShearTimeline(frameCount); + else { + timeline = new TranslateTimeline(frameCount); + timelineScale = scale; + } + timeline.boneIndex = boneIndex; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame( + frameIndex, + input.readFloat(), + input.readFloat() * timelineScale, + input.readFloat() * timelineScale + ); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * TranslateTimeline.ENTRIES]); + break; + } + } + } + } + + // IK constraint timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const index = input.readInt(true); + const frameCount = input.readInt(true); + const timeline = new IkConstraintTimeline(frameCount); + timeline.ikConstraintIndex = index; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame( + frameIndex, + input.readFloat(), + input.readFloat(), + input.readFloat() * scale, + input.readByte(), + input.readBoolean(), + input.readBoolean() + ); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * IkConstraintTimeline.ENTRIES]); + } + + // Transform constraint timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const index = input.readInt(true); + const frameCount = input.readInt(true); + const timeline = new TransformConstraintTimeline(frameCount); + timeline.transformConstraintIndex = index; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame( + frameIndex, + input.readFloat(), + input.readFloat(), + input.readFloat(), + input.readFloat(), + input.readFloat() + ); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * TransformConstraintTimeline.ENTRIES]); + } + + // Path constraint timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const index = input.readInt(true); + const data = skeletonData.pathConstraints[index]; + for (let ii = 0, nn = input.readInt(true); ii < nn; ii++) { + const timelineType = input.readByte(); + const frameCount = input.readInt(true); + switch (timelineType) { + case SkeletonBinary.PATH_POSITION: + case SkeletonBinary.PATH_SPACING: { + let timeline; + let timelineScale = 1; + if (timelineType == SkeletonBinary.PATH_SPACING) { + timeline = new PathConstraintSpacingTimeline(frameCount); + if (data.spacingMode == SpacingMode.Length || data.spacingMode == SpacingMode.Fixed) + timelineScale = scale; + } else { + timeline = new PathConstraintPositionTimeline(frameCount); + if (data.positionMode == PositionMode.Fixed) timelineScale = scale; + } + timeline.pathConstraintIndex = index; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame(frameIndex, input.readFloat(), input.readFloat() * timelineScale); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * PathConstraintPositionTimeline.ENTRIES]); + break; + } + case SkeletonBinary.PATH_MIX: { + const timeline = new PathConstraintMixTimeline(frameCount); + timeline.pathConstraintIndex = index; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + timeline.setFrame(frameIndex, input.readFloat(), input.readFloat(), input.readFloat()); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(frameCount - 1) * PathConstraintMixTimeline.ENTRIES]); + break; + } + } + } + } + + // Deform timelines. + for (let i = 0, n = input.readInt(true); i < n; i++) { + const skin = skeletonData.skins[input.readInt(true)]; + for (let ii = 0, nn = input.readInt(true); ii < nn; ii++) { + const slotIndex = input.readInt(true); + for (let iii = 0, nnn = input.readInt(true); iii < nnn; iii++) { + const attachment = skin.getAttachment(slotIndex, input.readStringRef()) as VertexAttachment; + const weighted = attachment.bones != null; + const vertices = attachment.vertices; + const deformLength = weighted ? (vertices.length / 3) * 2 : vertices.length; + + const frameCount = input.readInt(true); + const timeline = new DeformTimeline(frameCount); + timeline.slotIndex = slotIndex; + timeline.attachment = attachment; + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + const time = input.readFloat(); + let deform; + let end = input.readInt(true); + if (end == 0) deform = weighted ? Utils.newFloatArray(deformLength) : vertices; + else { + deform = Utils.newFloatArray(deformLength); + const start = input.readInt(true); + end += start; + if (scale == 1) { + for (let v = start; v < end; v++) deform[v] = input.readFloat(); + } else { + for (let v = start; v < end; v++) deform[v] = input.readFloat() * scale; + } + if (!weighted) { + for (let v = 0, vn = deform.length; v < vn; v++) deform[v] += vertices[v]; + } + } + + timeline.setFrame(frameIndex, time, deform); + if (frameIndex < frameCount - 1) this.readCurve(input, frameIndex, timeline); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[frameCount - 1]); + } + } + } + + // Draw order timeline. + const drawOrderCount = input.readInt(true); + if (drawOrderCount > 0) { + const timeline = new DrawOrderTimeline(drawOrderCount); + const slotCount = skeletonData.slots.length; + for (let i = 0; i < drawOrderCount; i++) { + const time = input.readFloat(); + const offsetCount = input.readInt(true); + const drawOrder = Utils.newArray(slotCount, 0); + for (let ii = slotCount - 1; ii >= 0; ii--) drawOrder[ii] = -1; + const unchanged = Utils.newArray(slotCount - offsetCount, 0); + let originalIndex = 0, + unchangedIndex = 0; + for (let ii = 0; ii < offsetCount; ii++) { + const slotIndex = input.readInt(true); + // Collect unchanged items. + while (originalIndex != slotIndex) unchanged[unchangedIndex++] = originalIndex++; + // Set changed items. + drawOrder[originalIndex + input.readInt(true)] = originalIndex++; + } + // Collect remaining unchanged items. + while (originalIndex < slotCount) unchanged[unchangedIndex++] = originalIndex++; + // Fill in unchanged items. + for (let ii = slotCount - 1; ii >= 0; ii--) + if (drawOrder[ii] == -1) drawOrder[ii] = unchanged[--unchangedIndex]; + timeline.setFrame(i, time, drawOrder); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[drawOrderCount - 1]); + } + + // Event timeline. + const eventCount = input.readInt(true); + if (eventCount > 0) { + const timeline = new EventTimeline(eventCount); + for (let i = 0; i < eventCount; i++) { + const time = input.readFloat(); + const eventData = skeletonData.events[input.readInt(true)]; + const event = new Event(time, eventData); + event.intValue = input.readInt(false); + event.floatValue = input.readFloat(); + event.stringValue = input.readBoolean() ? input.readString() : eventData.stringValue; + if (event.data.audioPath != null) { + event.volume = input.readFloat(); + event.balance = input.readFloat(); + } + timeline.setFrame(i, event); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[eventCount - 1]); + } + + return new Animation(name, timelines, duration); + } + + private readCurve(input: BinaryInput, frameIndex: number, timeline: CurveTimeline) { + switch (input.readByte()) { + case SkeletonBinary.CURVE_STEPPED: + timeline.setStepped(frameIndex); + break; + case SkeletonBinary.CURVE_BEZIER: + this.setCurve(timeline, frameIndex, input.readFloat(), input.readFloat(), input.readFloat(), input.readFloat()); + break; + } + } + + setCurve(timeline: CurveTimeline, frameIndex: number, cx1: number, cy1: number, cx2: number, cy2: number) { + timeline.setCurve(frameIndex, cx1, cy1, cx2, cy2); + } +} + +class BinaryInput { + constructor( + data: Uint8Array, + public strings = new Array(), + private index: number = 0, + private buffer = new DataView(data.buffer) + ) {} + + readByte(): number { + return this.buffer.getInt8(this.index++); + } + + readShort(): number { + const value = this.buffer.getInt16(this.index); + this.index += 2; + return value; + } + + readInt32(): number { + const value = this.buffer.getInt32(this.index); + this.index += 4; + return value; + } + + readInt(optimizePositive: boolean) { + let b = this.readByte(); + let result = b & 0x7f; + if ((b & 0x80) != 0) { + b = this.readByte(); + result |= (b & 0x7f) << 7; + if ((b & 0x80) != 0) { + b = this.readByte(); + result |= (b & 0x7f) << 14; + if ((b & 0x80) != 0) { + b = this.readByte(); + result |= (b & 0x7f) << 21; + if ((b & 0x80) != 0) { + b = this.readByte(); + result |= (b & 0x7f) << 28; + } + } + } + } + return optimizePositive ? result : (result >>> 1) ^ -(result & 1); + } + + readStringRef(): string { + const index = this.readInt(true); + return index == 0 ? null : this.strings[index - 1]; + } + + readString(): string { + let byteCount = this.readInt(true); + switch (byteCount) { + case 0: + return null; + case 1: + return ""; + } + byteCount--; + let chars = ""; + const charCount = 0; + for (let i = 0; i < byteCount; ) { + const b = this.readByte(); + switch (b >> 4) { + case 12: + case 13: + chars += String.fromCharCode(((b & 0x1f) << 6) | (this.readByte() & 0x3f)); + i += 2; + break; + case 14: + chars += String.fromCharCode(((b & 0x0f) << 12) | ((this.readByte() & 0x3f) << 6) | (this.readByte() & 0x3f)); + i += 3; + break; + default: + chars += String.fromCharCode(b); + i++; + } + } + return chars; + } + + readFloat(): number { + const value = this.buffer.getFloat32(this.index); + this.index += 4; + return value; + } + + readBoolean(): boolean { + return this.readByte() != 0; + } +} + +class LinkedMesh { + parent: string; + skin: string; + slotIndex: number; + mesh: MeshAttachment; + inheritDeform: boolean; + + constructor(mesh: MeshAttachment, skin: string, slotIndex: number, parent: string, inheritDeform: boolean) { + this.mesh = mesh; + this.skin = skin; + this.slotIndex = slotIndex; + this.parent = parent; + this.inheritDeform = inheritDeform; + } +} + +class Vertices { + constructor( + public bones: Array = null, + public vertices: Array | Float32Array = null + ) {} +} diff --git a/packages/spine-core-3.8/src/spine-core/SkeletonBounds.ts b/packages/spine-core-3.8/src/spine-core/SkeletonBounds.ts new file mode 100644 index 0000000000..6c7323ba77 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SkeletonBounds.ts @@ -0,0 +1,215 @@ +import { BoundingBoxAttachment } from "./attachments/BoundingBoxAttachment"; +import { Pool, Utils } from "./Utils"; +import { Skeleton } from "./Skeleton"; + +/** Collects each visible {@link BoundingBoxAttachment} and computes the world vertices for its polygon. The polygon vertices are + * provided along with convenience methods for doing hit detection. */ +export class SkeletonBounds { + /** The left edge of the axis aligned bounding box. */ + minX = 0; + + /** The bottom edge of the axis aligned bounding box. */ + minY = 0; + + /** The right edge of the axis aligned bounding box. */ + maxX = 0; + + /** The top edge of the axis aligned bounding box. */ + maxY = 0; + + /** The visible bounding boxes. */ + boundingBoxes = new Array(); + + /** The world vertices for the bounding box polygons. */ + polygons = new Array>(); + + private polygonPool = new Pool>(() => { + return Utils.newFloatArray(16); + }); + + /** Clears any previous polygons, finds all visible bounding box attachments, and computes the world vertices for each bounding + * box's polygon. + * @param updateAabb If true, the axis aligned bounding box containing all the polygons is computed. If false, the + * SkeletonBounds AABB methods will always return true. */ + update(skeleton: Skeleton, updateAabb: boolean) { + if (skeleton == null) throw new Error("skeleton cannot be null."); + const boundingBoxes = this.boundingBoxes; + const polygons = this.polygons; + const polygonPool = this.polygonPool; + const slots = skeleton.slots; + const slotCount = slots.length; + + boundingBoxes.length = 0; + polygonPool.freeAll(polygons); + polygons.length = 0; + + for (let i = 0; i < slotCount; i++) { + const slot = slots[i]; + if (!slot.bone.active) continue; + const attachment = slot.getAttachment(); + if (attachment instanceof BoundingBoxAttachment) { + const boundingBox = attachment as BoundingBoxAttachment; + boundingBoxes.push(boundingBox); + + let polygon = polygonPool.obtain(); + if (polygon.length != boundingBox.worldVerticesLength) { + polygon = Utils.newFloatArray(boundingBox.worldVerticesLength); + } + polygons.push(polygon); + boundingBox.computeWorldVertices(slot, 0, boundingBox.worldVerticesLength, polygon, 0, 2); + } + } + + if (updateAabb) { + this.aabbCompute(); + } else { + this.minX = Number.POSITIVE_INFINITY; + this.minY = Number.POSITIVE_INFINITY; + this.maxX = Number.NEGATIVE_INFINITY; + this.maxY = Number.NEGATIVE_INFINITY; + } + } + + aabbCompute() { + let minX = Number.POSITIVE_INFINITY, + minY = Number.POSITIVE_INFINITY, + maxX = Number.NEGATIVE_INFINITY, + maxY = Number.NEGATIVE_INFINITY; + const polygons = this.polygons; + for (let i = 0, n = polygons.length; i < n; i++) { + const polygon = polygons[i]; + const vertices = polygon; + for (let ii = 0, nn = polygon.length; ii < nn; ii += 2) { + const x = vertices[ii]; + const y = vertices[ii + 1]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + this.minX = minX; + this.minY = minY; + this.maxX = maxX; + this.maxY = maxY; + } + + /** Returns true if the axis aligned bounding box contains the point. */ + aabbContainsPoint(x: number, y: number) { + return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY; + } + + /** Returns true if the axis aligned bounding box intersects the line segment. */ + aabbIntersectsSegment(x1: number, y1: number, x2: number, y2: number) { + const minX = this.minX; + const minY = this.minY; + const maxX = this.maxX; + const maxY = this.maxY; + if ( + (x1 <= minX && x2 <= minX) || + (y1 <= minY && y2 <= minY) || + (x1 >= maxX && x2 >= maxX) || + (y1 >= maxY && y2 >= maxY) + ) + return false; + const m = (y2 - y1) / (x2 - x1); + let y = m * (minX - x1) + y1; + if (y > minY && y < maxY) return true; + y = m * (maxX - x1) + y1; + if (y > minY && y < maxY) return true; + let x = (minY - y1) / m + x1; + if (x > minX && x < maxX) return true; + x = (maxY - y1) / m + x1; + if (x > minX && x < maxX) return true; + return false; + } + + /** Returns true if the axis aligned bounding box intersects the axis aligned bounding box of the specified bounds. */ + aabbIntersectsSkeleton(bounds: SkeletonBounds) { + return this.minX < bounds.maxX && this.maxX > bounds.minX && this.minY < bounds.maxY && this.maxY > bounds.minY; + } + + /** Returns the first bounding box attachment that contains the point, or null. When doing many checks, it is usually more + * efficient to only call this method if {@link #aabbContainsPoint(float, float)} returns true. */ + containsPoint(x: number, y: number): BoundingBoxAttachment { + const polygons = this.polygons; + for (let i = 0, n = polygons.length; i < n; i++) + if (this.containsPointPolygon(polygons[i], x, y)) return this.boundingBoxes[i]; + return null; + } + + /** Returns true if the polygon contains the point. */ + containsPointPolygon(polygon: ArrayLike, x: number, y: number) { + const vertices = polygon; + const nn = polygon.length; + + let prevIndex = nn - 2; + let inside = false; + for (let ii = 0; ii < nn; ii += 2) { + const vertexY = vertices[ii + 1]; + const prevY = vertices[prevIndex + 1]; + if ((vertexY < y && prevY >= y) || (prevY < y && vertexY >= y)) { + const vertexX = vertices[ii]; + if (vertexX + ((y - vertexY) / (prevY - vertexY)) * (vertices[prevIndex] - vertexX) < x) inside = !inside; + } + prevIndex = ii; + } + return inside; + } + + /** Returns the first bounding box attachment that contains any part of the line segment, or null. When doing many checks, it + * is usually more efficient to only call this method if {@link #aabbIntersectsSegment()} returns + * true. */ + intersectsSegment(x1: number, y1: number, x2: number, y2: number) { + const polygons = this.polygons; + for (let i = 0, n = polygons.length; i < n; i++) + if (this.intersectsSegmentPolygon(polygons[i], x1, y1, x2, y2)) return this.boundingBoxes[i]; + return null; + } + + /** Returns true if the polygon contains any part of the line segment. */ + intersectsSegmentPolygon(polygon: ArrayLike, x1: number, y1: number, x2: number, y2: number) { + const vertices = polygon; + const nn = polygon.length; + + const width12 = x1 - x2, + height12 = y1 - y2; + const det1 = x1 * y2 - y1 * x2; + let x3 = vertices[nn - 2], + y3 = vertices[nn - 1]; + for (let ii = 0; ii < nn; ii += 2) { + const x4 = vertices[ii], + y4 = vertices[ii + 1]; + const det2 = x3 * y4 - y3 * x4; + const width34 = x3 - x4, + height34 = y3 - y4; + const det3 = width12 * height34 - height12 * width34; + const x = (det1 * width34 - width12 * det2) / det3; + if (((x >= x3 && x <= x4) || (x >= x4 && x <= x3)) && ((x >= x1 && x <= x2) || (x >= x2 && x <= x1))) { + const y = (det1 * height34 - height12 * det2) / det3; + if (((y >= y3 && y <= y4) || (y >= y4 && y <= y3)) && ((y >= y1 && y <= y2) || (y >= y2 && y <= y1))) + return true; + } + x3 = x4; + y3 = y4; + } + return false; + } + + /** Returns the polygon for the specified bounding box, or null. */ + getPolygon(boundingBox: BoundingBoxAttachment) { + if (boundingBox == null) throw new Error("boundingBox cannot be null."); + const index = this.boundingBoxes.indexOf(boundingBox); + return index == -1 ? null : this.polygons[index]; + } + + /** The width of the axis aligned bounding box. */ + getWidth() { + return this.maxX - this.minX; + } + + /** The height of the axis aligned bounding box. */ + getHeight() { + return this.maxY - this.minY; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/SkeletonClipping.ts b/packages/spine-core-3.8/src/spine-core/SkeletonClipping.ts new file mode 100644 index 0000000000..563a93c1f6 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SkeletonClipping.ts @@ -0,0 +1,363 @@ +import { Triangulator } from "./Triangulator"; +import { ClippingAttachment } from "./attachments/ClippingAttachment"; +import { Slot } from "./Slot"; +import { Utils, Color, ArrayLike } from "./Utils"; + +export class SkeletonClipping { + private triangulator = new Triangulator(); + private clippingPolygon = new Array(); + private clipOutput = new Array(); + clippedVertices = new Array(); + clippedTriangles = new Array(); + private scratch = new Array(); + + private clipAttachment: ClippingAttachment; + private clippingPolygons: Array>; + + clipStart(slot: Slot, clip: ClippingAttachment): number { + if (this.clipAttachment != null) return 0; + this.clipAttachment = clip; + + const n = clip.worldVerticesLength; + const vertices = Utils.setArraySize(this.clippingPolygon, n); + clip.computeWorldVertices(slot, 0, n, vertices, 0, 2); + const clippingPolygon = this.clippingPolygon; + SkeletonClipping.makeClockwise(clippingPolygon); + const clippingPolygons = (this.clippingPolygons = this.triangulator.decompose( + clippingPolygon, + this.triangulator.triangulate(clippingPolygon) + )); + for (let i = 0, n = clippingPolygons.length; i < n; i++) { + const polygon = clippingPolygons[i]; + SkeletonClipping.makeClockwise(polygon); + polygon.push(polygon[0]); + polygon.push(polygon[1]); + } + + return clippingPolygons.length; + } + + clipEndWithSlot(slot: Slot) { + if (this.clipAttachment != null && this.clipAttachment.endSlot == slot.data) this.clipEnd(); + } + + clipEnd() { + if (this.clipAttachment == null) return; + this.clipAttachment = null; + this.clippingPolygons = null; + this.clippedVertices.length = 0; + this.clippedTriangles.length = 0; + this.clippingPolygon.length = 0; + } + + isClipping(): boolean { + return this.clipAttachment != null; + } + + clipTriangles( + vertices: ArrayLike, + verticesLength: number, + triangles: ArrayLike, + trianglesLength: number, + uvs: ArrayLike, + light: Color, + dark: Color, + twoColor: boolean + ) { + const clipOutput = this.clipOutput, + clippedVertices = this.clippedVertices; + const clippedTriangles = this.clippedTriangles; + const polygons = this.clippingPolygons; + const polygonsCount = this.clippingPolygons.length; + const vertexSize = twoColor ? 12 : 8; + + let index = 0; + clippedVertices.length = 0; + clippedTriangles.length = 0; + outer: for (let i = 0; i < trianglesLength; i += 3) { + let vertexOffset = triangles[i] << 1; + const x1 = vertices[vertexOffset], + y1 = vertices[vertexOffset + 1]; + const u1 = uvs[vertexOffset], + v1 = uvs[vertexOffset + 1]; + + vertexOffset = triangles[i + 1] << 1; + const x2 = vertices[vertexOffset], + y2 = vertices[vertexOffset + 1]; + const u2 = uvs[vertexOffset], + v2 = uvs[vertexOffset + 1]; + + vertexOffset = triangles[i + 2] << 1; + const x3 = vertices[vertexOffset], + y3 = vertices[vertexOffset + 1]; + const u3 = uvs[vertexOffset], + v3 = uvs[vertexOffset + 1]; + + for (let p = 0; p < polygonsCount; p++) { + let s = clippedVertices.length; + if (this.clip(x1, y1, x2, y2, x3, y3, polygons[p], clipOutput)) { + const clipOutputLength = clipOutput.length; + if (clipOutputLength == 0) continue; + const d0 = y2 - y3, + d1 = x3 - x2, + d2 = x1 - x3, + d4 = y3 - y1; + const d = 1 / (d0 * d2 + d1 * (y1 - y3)); + + let clipOutputCount = clipOutputLength >> 1; + const clipOutputItems = this.clipOutput; + const clippedVerticesItems = Utils.setArraySize(clippedVertices, s + clipOutputCount * vertexSize); + for (let ii = 0; ii < clipOutputLength; ii += 2) { + const x = clipOutputItems[ii], + y = clipOutputItems[ii + 1]; + clippedVerticesItems[s] = x; + clippedVerticesItems[s + 1] = y; + clippedVerticesItems[s + 2] = light.r; + clippedVerticesItems[s + 3] = light.g; + clippedVerticesItems[s + 4] = light.b; + clippedVerticesItems[s + 5] = light.a; + const c0 = x - x3, + c1 = y - y3; + const a = (d0 * c0 + d1 * c1) * d; + const b = (d4 * c0 + d2 * c1) * d; + const c = 1 - a - b; + clippedVerticesItems[s + 6] = u1 * a + u2 * b + u3 * c; + clippedVerticesItems[s + 7] = v1 * a + v2 * b + v3 * c; + if (twoColor) { + clippedVerticesItems[s + 8] = dark.r; + clippedVerticesItems[s + 9] = dark.g; + clippedVerticesItems[s + 10] = dark.b; + clippedVerticesItems[s + 11] = dark.a; + } + s += vertexSize; + } + + s = clippedTriangles.length; + const clippedTrianglesItems = Utils.setArraySize(clippedTriangles, s + 3 * (clipOutputCount - 2)); + clipOutputCount--; + for (let ii = 1; ii < clipOutputCount; ii++) { + clippedTrianglesItems[s] = index; + clippedTrianglesItems[s + 1] = index + ii; + clippedTrianglesItems[s + 2] = index + ii + 1; + s += 3; + } + index += clipOutputCount + 1; + } else { + const clippedVerticesItems = Utils.setArraySize(clippedVertices, s + 3 * vertexSize); + clippedVerticesItems[s] = x1; + clippedVerticesItems[s + 1] = y1; + clippedVerticesItems[s + 2] = light.r; + clippedVerticesItems[s + 3] = light.g; + clippedVerticesItems[s + 4] = light.b; + clippedVerticesItems[s + 5] = light.a; + if (!twoColor) { + clippedVerticesItems[s + 6] = u1; + clippedVerticesItems[s + 7] = v1; + + clippedVerticesItems[s + 8] = x2; + clippedVerticesItems[s + 9] = y2; + clippedVerticesItems[s + 10] = light.r; + clippedVerticesItems[s + 11] = light.g; + clippedVerticesItems[s + 12] = light.b; + clippedVerticesItems[s + 13] = light.a; + clippedVerticesItems[s + 14] = u2; + clippedVerticesItems[s + 15] = v2; + + clippedVerticesItems[s + 16] = x3; + clippedVerticesItems[s + 17] = y3; + clippedVerticesItems[s + 18] = light.r; + clippedVerticesItems[s + 19] = light.g; + clippedVerticesItems[s + 20] = light.b; + clippedVerticesItems[s + 21] = light.a; + clippedVerticesItems[s + 22] = u3; + clippedVerticesItems[s + 23] = v3; + } else { + clippedVerticesItems[s + 6] = u1; + clippedVerticesItems[s + 7] = v1; + clippedVerticesItems[s + 8] = dark.r; + clippedVerticesItems[s + 9] = dark.g; + clippedVerticesItems[s + 10] = dark.b; + clippedVerticesItems[s + 11] = dark.a; + + clippedVerticesItems[s + 12] = x2; + clippedVerticesItems[s + 13] = y2; + clippedVerticesItems[s + 14] = light.r; + clippedVerticesItems[s + 15] = light.g; + clippedVerticesItems[s + 16] = light.b; + clippedVerticesItems[s + 17] = light.a; + clippedVerticesItems[s + 18] = u2; + clippedVerticesItems[s + 19] = v2; + clippedVerticesItems[s + 20] = dark.r; + clippedVerticesItems[s + 21] = dark.g; + clippedVerticesItems[s + 22] = dark.b; + clippedVerticesItems[s + 23] = dark.a; + + clippedVerticesItems[s + 24] = x3; + clippedVerticesItems[s + 25] = y3; + clippedVerticesItems[s + 26] = light.r; + clippedVerticesItems[s + 27] = light.g; + clippedVerticesItems[s + 28] = light.b; + clippedVerticesItems[s + 29] = light.a; + clippedVerticesItems[s + 30] = u3; + clippedVerticesItems[s + 31] = v3; + clippedVerticesItems[s + 32] = dark.r; + clippedVerticesItems[s + 33] = dark.g; + clippedVerticesItems[s + 34] = dark.b; + clippedVerticesItems[s + 35] = dark.a; + } + + s = clippedTriangles.length; + const clippedTrianglesItems = Utils.setArraySize(clippedTriangles, s + 3); + clippedTrianglesItems[s] = index; + clippedTrianglesItems[s + 1] = index + 1; + clippedTrianglesItems[s + 2] = index + 2; + index += 3; + continue outer; + } + } + } + } + + /** Clips the input triangle against the convex, clockwise clipping area. If the triangle lies entirely within the clipping + * area, false is returned. The clipping area must duplicate the first vertex at the end of the vertices list. */ + clip( + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + clippingArea: Array, + output: Array + ) { + const originalOutput = output; + let clipped = false; + + // Avoid copy at the end. + let input: Array = null; + if (clippingArea.length % 4 >= 2) { + input = output; + output = this.scratch; + } else input = this.scratch; + + input.length = 0; + input.push(x1); + input.push(y1); + input.push(x2); + input.push(y2); + input.push(x3); + input.push(y3); + input.push(x1); + input.push(y1); + output.length = 0; + + const clippingVertices = clippingArea; + const clippingVerticesLast = clippingArea.length - 4; + for (let i = 0; ; i += 2) { + const edgeX = clippingVertices[i], + edgeY = clippingVertices[i + 1]; + const edgeX2 = clippingVertices[i + 2], + edgeY2 = clippingVertices[i + 3]; + const deltaX = edgeX - edgeX2, + deltaY = edgeY - edgeY2; + + const inputVertices = input; + const inputVerticesLength = input.length - 2, + outputStart = output.length; + for (let ii = 0; ii < inputVerticesLength; ii += 2) { + const inputX = inputVertices[ii], + inputY = inputVertices[ii + 1]; + const inputX2 = inputVertices[ii + 2], + inputY2 = inputVertices[ii + 3]; + const side2 = deltaX * (inputY2 - edgeY2) - deltaY * (inputX2 - edgeX2) > 0; + if (deltaX * (inputY - edgeY2) - deltaY * (inputX - edgeX2) > 0) { + if (side2) { + // v1 inside, v2 inside + output.push(inputX2); + output.push(inputY2); + continue; + } + // v1 inside, v2 outside + const c0 = inputY2 - inputY, + c2 = inputX2 - inputX; + const s = c0 * (edgeX2 - edgeX) - c2 * (edgeY2 - edgeY); + if (Math.abs(s) > 0.000001) { + const ua = (c2 * (edgeY - inputY) - c0 * (edgeX - inputX)) / s; + output.push(edgeX + (edgeX2 - edgeX) * ua); + output.push(edgeY + (edgeY2 - edgeY) * ua); + } else { + output.push(edgeX); + output.push(edgeY); + } + } else if (side2) { + // v1 outside, v2 inside + const c0 = inputY2 - inputY, + c2 = inputX2 - inputX; + const s = c0 * (edgeX2 - edgeX) - c2 * (edgeY2 - edgeY); + if (Math.abs(s) > 0.000001) { + const ua = (c2 * (edgeY - inputY) - c0 * (edgeX - inputX)) / s; + output.push(edgeX + (edgeX2 - edgeX) * ua); + output.push(edgeY + (edgeY2 - edgeY) * ua); + } else { + output.push(edgeX); + output.push(edgeY); + } + output.push(inputX2); + output.push(inputY2); + } + clipped = true; + } + + if (outputStart == output.length) { + // All edges outside. + originalOutput.length = 0; + return true; + } + + output.push(output[0]); + output.push(output[1]); + + if (i == clippingVerticesLast) break; + const temp = output; + output = input; + output.length = 0; + input = temp; + } + + if (originalOutput != output) { + originalOutput.length = 0; + for (let i = 0, n = output.length - 2; i < n; i++) originalOutput[i] = output[i]; + } else originalOutput.length = originalOutput.length - 2; + + return clipped; + } + + public static makeClockwise(polygon: ArrayLike) { + const vertices = polygon; + const verticeslength = polygon.length; + + let area = vertices[verticeslength - 2] * vertices[1] - vertices[0] * vertices[verticeslength - 1], + p1x = 0, + p1y = 0, + p2x = 0, + p2y = 0; + for (let i = 0, n = verticeslength - 3; i < n; i += 2) { + p1x = vertices[i]; + p1y = vertices[i + 1]; + p2x = vertices[i + 2]; + p2y = vertices[i + 3]; + area += p1x * p2y - p2x * p1y; + } + if (area < 0) return; + + for (let i = 0, lastX = verticeslength - 2, n = verticeslength >> 1; i < n; i += 2) { + const x = vertices[i], + y = vertices[i + 1]; + const other = lastX - i; + vertices[i] = vertices[other]; + vertices[i + 1] = vertices[other + 1]; + vertices[other] = x; + vertices[other + 1] = y; + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/SkeletonData.ts b/packages/spine-core-3.8/src/spine-core/SkeletonData.ts new file mode 100644 index 0000000000..67c4f657bb --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SkeletonData.ts @@ -0,0 +1,198 @@ +import { BoneData } from "./BoneData"; +import { SlotData } from "./SlotData"; +import { Skin } from "./Skin"; +import { EventData } from "./EventData"; +import { IkConstraintData } from "./IkConstraintData"; +import { TransformConstraintData } from "./TransformConstraintData"; +import { PathConstraintData } from "./PathConstraintData"; +import { Animation } from "./Animation"; + +/** Stores the setup pose and all of the stateless data for a skeleton. + * + * See [Data objects](http://esotericsoftware.com/spine-runtime-architecture#Data-objects) in the Spine Runtimes + * Guide. */ +export class SkeletonData { + /** The skeleton's name, which by default is the name of the skeleton data file, if possible. May be null. */ + name: string; + + /** The skeleton's bones, sorted parent first. The root bone is always the first bone. */ + bones = new Array(); // Ordered parents first. + + /** The skeleton's slots. */ + slots = new Array(); // Setup pose draw order. + skins = new Array(); + + /** The skeleton's default skin. By default this skin contains all attachments that were not in a skin in Spine. + * + * See {@link Skeleton#getAttachmentByName()}. + * May be null. */ + defaultSkin: Skin; + + /** The skeleton's events. */ + events = new Array(); + + /** The skeleton's animations. */ + animations = new Array(); + + /** The skeleton's IK constraints. */ + ikConstraints = new Array(); + + /** The skeleton's transform constraints. */ + transformConstraints = new Array(); + + /** The skeleton's path constraints. */ + pathConstraints = new Array(); + + /** The X coordinate of the skeleton's axis aligned bounding box in the setup pose. */ + x: number; + + /** The Y coordinate of the skeleton's axis aligned bounding box in the setup pose. */ + y: number; + + /** The width of the skeleton's axis aligned bounding box in the setup pose. */ + width: number; + + /** The height of the skeleton's axis aligned bounding box in the setup pose. */ + height: number; + + /** The Spine version used to export the skeleton data, or null. */ + version: string; + + /** The skeleton data hash. This value will change if any of the skeleton data has changed. May be null. */ + hash: string; + + // Nonessential + /** The dopesheet FPS in Spine. Available only when nonessential data was exported. */ + fps = 0; + + /** The path to the images directory as defined in Spine. Available only when nonessential data was exported. May be null. */ + imagesPath: string; + + /** The path to the audio directory as defined in Spine. Available only when nonessential data was exported. May be null. */ + audioPath: string; + + /** Finds a bone by comparing each bone's name. It is more efficient to cache the results of this method than to call it + * multiple times. + * @returns May be null. */ + findBone(boneName: string) { + if (boneName == null) throw new Error("boneName cannot be null."); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + if (bone.name == boneName) return bone; + } + return null; + } + + findBoneIndex(boneName: string) { + if (boneName == null) throw new Error("boneName cannot be null."); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) if (bones[i].name == boneName) return i; + return -1; + } + + /** Finds a slot by comparing each slot's name. It is more efficient to cache the results of this method than to call it + * multiple times. + * @returns May be null. */ + findSlot(slotName: string) { + if (slotName == null) throw new Error("slotName cannot be null."); + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) { + const slot = slots[i]; + if (slot.name == slotName) return slot; + } + return null; + } + + findSlotIndex(slotName: string) { + if (slotName == null) throw new Error("slotName cannot be null."); + const slots = this.slots; + for (let i = 0, n = slots.length; i < n; i++) if (slots[i].name == slotName) return i; + return -1; + } + + /** Finds a skin by comparing each skin's name. It is more efficient to cache the results of this method than to call it + * multiple times. + * @returns May be null. */ + findSkin(skinName: string) { + if (skinName == null) throw new Error("skinName cannot be null."); + const skins = this.skins; + for (let i = 0, n = skins.length; i < n; i++) { + const skin = skins[i]; + if (skin.name == skinName) return skin; + } + return null; + } + + /** Finds an event by comparing each events's name. It is more efficient to cache the results of this method than to call it + * multiple times. + * @returns May be null. */ + findEvent(eventDataName: string) { + if (eventDataName == null) throw new Error("eventDataName cannot be null."); + const events = this.events; + for (let i = 0, n = events.length; i < n; i++) { + const event = events[i]; + if (event.name == eventDataName) return event; + } + return null; + } + + /** Finds an animation by comparing each animation's name. It is more efficient to cache the results of this method than to + * call it multiple times. + * @returns May be null. */ + findAnimation(animationName: string) { + if (animationName == null) throw new Error("animationName cannot be null."); + const animations = this.animations; + for (let i = 0, n = animations.length; i < n; i++) { + const animation = animations[i]; + if (animation.name == animationName) return animation; + } + return null; + } + + /** Finds an IK constraint by comparing each IK constraint's name. It is more efficient to cache the results of this method + * than to call it multiple times. + * @return May be null. */ + findIkConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const ikConstraints = this.ikConstraints; + for (let i = 0, n = ikConstraints.length; i < n; i++) { + const constraint = ikConstraints[i]; + if (constraint.name == constraintName) return constraint; + } + return null; + } + + /** Finds a transform constraint by comparing each transform constraint's name. It is more efficient to cache the results of + * this method than to call it multiple times. + * @return May be null. */ + findTransformConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const transformConstraints = this.transformConstraints; + for (let i = 0, n = transformConstraints.length; i < n; i++) { + const constraint = transformConstraints[i]; + if (constraint.name == constraintName) return constraint; + } + return null; + } + + /** Finds a path constraint by comparing each path constraint's name. It is more efficient to cache the results of this method + * than to call it multiple times. + * @return May be null. */ + findPathConstraint(constraintName: string) { + if (constraintName == null) throw new Error("constraintName cannot be null."); + const pathConstraints = this.pathConstraints; + for (let i = 0, n = pathConstraints.length; i < n; i++) { + const constraint = pathConstraints[i]; + if (constraint.name == constraintName) return constraint; + } + return null; + } + + findPathConstraintIndex(pathConstraintName: string) { + if (pathConstraintName == null) throw new Error("pathConstraintName cannot be null."); + const pathConstraints = this.pathConstraints; + for (let i = 0, n = pathConstraints.length; i < n; i++) if (pathConstraints[i].name == pathConstraintName) return i; + return -1; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/SkeletonJson.ts b/packages/spine-core-3.8/src/spine-core/SkeletonJson.ts new file mode 100644 index 0000000000..9c55054752 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SkeletonJson.ts @@ -0,0 +1,906 @@ +import { AttachmentLoader } from "./attachments/AttachmentLoader"; +import { SkeletonData } from "./SkeletonData"; +import { BoneData, TransformMode } from "./BoneData"; +import { SlotData } from "./SlotData"; +import { Color, Utils, ArrayLike } from "./Utils"; +import { IkConstraintData } from "./IkConstraintData"; +import { TransformConstraintData } from "./TransformConstraintData"; +import { PathConstraintData, PositionMode, SpacingMode, RotateMode } from "./PathConstraintData"; +import { Skin } from "./Skin"; +import { VertexAttachment, Attachment } from "./attachments/Attachment"; +import { MeshAttachment } from "./attachments/MeshAttachment"; +import { EventData } from "./EventData"; +import { + Timeline, + AttachmentTimeline, + ColorTimeline, + TwoColorTimeline, + RotateTimeline, + TranslateTimeline, + ScaleTimeline, + ShearTimeline, + IkConstraintTimeline, + TransformConstraintTimeline, + PathConstraintPositionTimeline, + PathConstraintSpacingTimeline, + PathConstraintMixTimeline, + DeformTimeline, + DrawOrderTimeline, + EventTimeline, + CurveTimeline +} from "./Animation"; +import { BlendMode } from "./BlendMode"; +import { Event } from "./Event"; +import { Animation } from "./Animation"; + +/** Loads skeleton data in the Spine JSON format. + * + * See [Spine JSON format](http://esotericsoftware.com/spine-json-format) and + * [JSON and binary data](http://esotericsoftware.com/spine-loading-skeleton-data#JSON-and-binary-data) in the Spine + * Runtimes Guide. */ +export class SkeletonJson { + attachmentLoader: AttachmentLoader; + + /** Scales bone positions, image sizes, and translations as they are loaded. This allows different size images to be used at + * runtime than were used in Spine. + * + * See [Scaling](http://esotericsoftware.com/spine-loading-skeleton-data#Scaling) in the Spine Runtimes Guide. */ + scale = 1; + private linkedMeshes = new Array(); + + constructor(attachmentLoader: AttachmentLoader) { + this.attachmentLoader = attachmentLoader; + } + + readSkeletonData(json: string | any): SkeletonData { + const scale = this.scale; + const skeletonData = new SkeletonData(); + const root = typeof json === "string" ? JSON.parse(json) : json; + + // Skeleton + const skeletonMap = root.skeleton; + if (skeletonMap != null) { + skeletonData.hash = skeletonMap.hash; + skeletonData.version = skeletonMap.spine; + if ("3.8.75" == skeletonData.version) + throw new Error("Unsupported skeleton data, please export with a newer version of Spine."); + skeletonData.x = skeletonMap.x; + skeletonData.y = skeletonMap.y; + skeletonData.width = skeletonMap.width; + skeletonData.height = skeletonMap.height; + skeletonData.fps = skeletonMap.fps; + skeletonData.imagesPath = skeletonMap.images; + } + + // Bones + if (root.bones) { + for (let i = 0; i < root.bones.length; i++) { + const boneMap = root.bones[i]; + + let parent: BoneData = null; + const parentName: string = this.getValue(boneMap, "parent", null); + if (parentName != null) { + parent = skeletonData.findBone(parentName); + if (parent == null) throw new Error("Parent bone not found: " + parentName); + } + const data = new BoneData(skeletonData.bones.length, boneMap.name, parent); + data.length = this.getValue(boneMap, "length", 0) * scale; + data.x = this.getValue(boneMap, "x", 0) * scale; + data.y = this.getValue(boneMap, "y", 0) * scale; + data.rotation = this.getValue(boneMap, "rotation", 0); + data.scaleX = this.getValue(boneMap, "scaleX", 1); + data.scaleY = this.getValue(boneMap, "scaleY", 1); + data.shearX = this.getValue(boneMap, "shearX", 0); + data.shearY = this.getValue(boneMap, "shearY", 0); + data.transformMode = SkeletonJson.transformModeFromString(this.getValue(boneMap, "transform", "normal")); + data.skinRequired = this.getValue(boneMap, "skin", false); + + skeletonData.bones.push(data); + } + } + + // Slots. + if (root.slots) { + for (let i = 0; i < root.slots.length; i++) { + const slotMap = root.slots[i]; + const slotName: string = slotMap.name; + const boneName: string = slotMap.bone; + const boneData = skeletonData.findBone(boneName); + if (boneData == null) throw new Error("Slot bone not found: " + boneName); + const data = new SlotData(skeletonData.slots.length, slotName, boneData); + + const color: string = this.getValue(slotMap, "color", null); + if (color != null) data.color.setFromString(color); + + const dark: string = this.getValue(slotMap, "dark", null); + if (dark != null) { + data.darkColor = new Color(1, 1, 1, 1); + data.darkColor.setFromString(dark); + } + + data.attachmentName = this.getValue(slotMap, "attachment", null); + data.blendMode = SkeletonJson.blendModeFromString(this.getValue(slotMap, "blend", "normal")); + skeletonData.slots.push(data); + } + } + + // IK constraints + if (root.ik) { + for (let i = 0; i < root.ik.length; i++) { + const constraintMap = root.ik[i]; + const data = new IkConstraintData(constraintMap.name); + data.order = this.getValue(constraintMap, "order", 0); + data.skinRequired = this.getValue(constraintMap, "skin", false); + + for (let j = 0; j < constraintMap.bones.length; j++) { + const boneName = constraintMap.bones[j]; + const bone = skeletonData.findBone(boneName); + if (bone == null) throw new Error("IK bone not found: " + boneName); + data.bones.push(bone); + } + + const targetName: string = constraintMap.target; + data.target = skeletonData.findBone(targetName); + if (data.target == null) throw new Error("IK target bone not found: " + targetName); + + data.mix = this.getValue(constraintMap, "mix", 1); + data.softness = this.getValue(constraintMap, "softness", 0) * scale; + data.bendDirection = this.getValue(constraintMap, "bendPositive", true) ? 1 : -1; + data.compress = this.getValue(constraintMap, "compress", false); + data.stretch = this.getValue(constraintMap, "stretch", false); + data.uniform = this.getValue(constraintMap, "uniform", false); + + skeletonData.ikConstraints.push(data); + } + } + + // Transform constraints. + if (root.transform) { + for (let i = 0; i < root.transform.length; i++) { + const constraintMap = root.transform[i]; + const data = new TransformConstraintData(constraintMap.name); + data.order = this.getValue(constraintMap, "order", 0); + data.skinRequired = this.getValue(constraintMap, "skin", false); + + for (let j = 0; j < constraintMap.bones.length; j++) { + const boneName = constraintMap.bones[j]; + const bone = skeletonData.findBone(boneName); + if (bone == null) throw new Error("Transform constraint bone not found: " + boneName); + data.bones.push(bone); + } + + const targetName: string = constraintMap.target; + data.target = skeletonData.findBone(targetName); + if (data.target == null) throw new Error("Transform constraint target bone not found: " + targetName); + + data.local = this.getValue(constraintMap, "local", false); + data.relative = this.getValue(constraintMap, "relative", false); + data.offsetRotation = this.getValue(constraintMap, "rotation", 0); + data.offsetX = this.getValue(constraintMap, "x", 0) * scale; + data.offsetY = this.getValue(constraintMap, "y", 0) * scale; + data.offsetScaleX = this.getValue(constraintMap, "scaleX", 0); + data.offsetScaleY = this.getValue(constraintMap, "scaleY", 0); + data.offsetShearY = this.getValue(constraintMap, "shearY", 0); + + data.rotateMix = this.getValue(constraintMap, "rotateMix", 1); + data.translateMix = this.getValue(constraintMap, "translateMix", 1); + data.scaleMix = this.getValue(constraintMap, "scaleMix", 1); + data.shearMix = this.getValue(constraintMap, "shearMix", 1); + + skeletonData.transformConstraints.push(data); + } + } + + // Path constraints. + if (root.path) { + for (let i = 0; i < root.path.length; i++) { + const constraintMap = root.path[i]; + const data = new PathConstraintData(constraintMap.name); + data.order = this.getValue(constraintMap, "order", 0); + data.skinRequired = this.getValue(constraintMap, "skin", false); + + for (let j = 0; j < constraintMap.bones.length; j++) { + const boneName = constraintMap.bones[j]; + const bone = skeletonData.findBone(boneName); + if (bone == null) throw new Error("Transform constraint bone not found: " + boneName); + data.bones.push(bone); + } + + const targetName: string = constraintMap.target; + data.target = skeletonData.findSlot(targetName); + if (data.target == null) throw new Error("Path target slot not found: " + targetName); + + data.positionMode = SkeletonJson.positionModeFromString( + this.getValue(constraintMap, "positionMode", "percent") + ); + data.spacingMode = SkeletonJson.spacingModeFromString(this.getValue(constraintMap, "spacingMode", "length")); + data.rotateMode = SkeletonJson.rotateModeFromString(this.getValue(constraintMap, "rotateMode", "tangent")); + data.offsetRotation = this.getValue(constraintMap, "rotation", 0); + data.position = this.getValue(constraintMap, "position", 0); + if (data.positionMode == PositionMode.Fixed) data.position *= scale; + data.spacing = this.getValue(constraintMap, "spacing", 0); + if (data.spacingMode == SpacingMode.Length || data.spacingMode == SpacingMode.Fixed) data.spacing *= scale; + data.rotateMix = this.getValue(constraintMap, "rotateMix", 1); + data.translateMix = this.getValue(constraintMap, "translateMix", 1); + + skeletonData.pathConstraints.push(data); + } + } + + // Skins. + if (root.skins) { + for (let i = 0; i < root.skins.length; i++) { + const skinMap = root.skins[i]; + const skin = new Skin(skinMap.name); + + if (skinMap.bones) { + for (let ii = 0; ii < skinMap.bones.length; ii++) { + const bone = skeletonData.findBone(skinMap.bones[ii]); + if (bone == null) throw new Error("Skin bone not found: " + skinMap.bones[i]); + skin.bones.push(bone); + } + } + + if (skinMap.ik) { + for (let ii = 0; ii < skinMap.ik.length; ii++) { + const constraint = skeletonData.findIkConstraint(skinMap.ik[ii]); + if (constraint == null) throw new Error("Skin IK constraint not found: " + skinMap.ik[i]); + skin.constraints.push(constraint); + } + } + + if (skinMap.transform) { + for (let ii = 0; ii < skinMap.transform.length; ii++) { + const constraint = skeletonData.findTransformConstraint(skinMap.transform[ii]); + if (constraint == null) throw new Error("Skin transform constraint not found: " + skinMap.transform[i]); + skin.constraints.push(constraint); + } + } + + if (skinMap.path) { + for (let ii = 0; ii < skinMap.path.length; ii++) { + const constraint = skeletonData.findPathConstraint(skinMap.path[ii]); + if (constraint == null) throw new Error("Skin path constraint not found: " + skinMap.path[i]); + skin.constraints.push(constraint); + } + } + + for (const slotName in skinMap.attachments) { + const slot = skeletonData.findSlot(slotName); + if (slot == null) throw new Error("Slot not found: " + slotName); + const slotMap = skinMap.attachments[slotName]; + for (const entryName in slotMap) { + const attachment = this.readAttachment(slotMap[entryName], skin, slot.index, entryName, skeletonData); + if (attachment != null) skin.setAttachment(slot.index, entryName, attachment); + } + } + skeletonData.skins.push(skin); + if (skin.name == "default") skeletonData.defaultSkin = skin; + } + } + + // Linked meshes. + for (let i = 0, n = this.linkedMeshes.length; i < n; i++) { + const linkedMesh = this.linkedMeshes[i]; + const skin = linkedMesh.skin == null ? skeletonData.defaultSkin : skeletonData.findSkin(linkedMesh.skin); + if (skin == null) throw new Error("Skin not found: " + linkedMesh.skin); + const parent = skin.getAttachment(linkedMesh.slotIndex, linkedMesh.parent); + if (parent == null) throw new Error("Parent mesh not found: " + linkedMesh.parent); + linkedMesh.mesh.deformAttachment = linkedMesh.inheritDeform + ? parent + : linkedMesh.mesh; + linkedMesh.mesh.setParentMesh(parent); + linkedMesh.mesh.updateUVs(); + } + this.linkedMeshes.length = 0; + + // Events. + if (root.events) { + for (const eventName in root.events) { + const eventMap = root.events[eventName]; + const data = new EventData(eventName); + data.intValue = this.getValue(eventMap, "int", 0); + data.floatValue = this.getValue(eventMap, "float", 0); + data.stringValue = this.getValue(eventMap, "string", ""); + data.audioPath = this.getValue(eventMap, "audio", null); + if (data.audioPath != null) { + data.volume = this.getValue(eventMap, "volume", 1); + data.balance = this.getValue(eventMap, "balance", 0); + } + skeletonData.events.push(data); + } + } + + // Animations. + if (root.animations) { + for (const animationName in root.animations) { + const animationMap = root.animations[animationName]; + this.readAnimation(animationMap, animationName, skeletonData); + } + } + + return skeletonData; + } + + readAttachment(map: any, skin: Skin, slotIndex: number, name: string, skeletonData: SkeletonData): Attachment { + const scale = this.scale; + name = this.getValue(map, "name", name); + + const type = this.getValue(map, "type", "region"); + + switch (type) { + case "region": { + const path = this.getValue(map, "path", name); + const region = this.attachmentLoader.newRegionAttachment(skin, name, path); + if (region == null) return null; + region.path = path; + region.x = this.getValue(map, "x", 0) * scale; + region.y = this.getValue(map, "y", 0) * scale; + region.scaleX = this.getValue(map, "scaleX", 1); + region.scaleY = this.getValue(map, "scaleY", 1); + region.rotation = this.getValue(map, "rotation", 0); + region.width = map.width * scale; + region.height = map.height * scale; + + const color: string = this.getValue(map, "color", null); + if (color != null) region.color.setFromString(color); + + region.updateOffset(); + return region; + } + case "boundingbox": { + const box = this.attachmentLoader.newBoundingBoxAttachment(skin, name); + if (box == null) return null; + this.readVertices(map, box, map.vertexCount << 1); + const color: string = this.getValue(map, "color", null); + if (color != null) box.color.setFromString(color); + return box; + } + case "mesh": + case "linkedmesh": { + const path = this.getValue(map, "path", name); + const mesh = this.attachmentLoader.newMeshAttachment(skin, name, path); + if (mesh == null) return null; + mesh.path = path; + + const color = this.getValue(map, "color", null); + if (color != null) mesh.color.setFromString(color); + + mesh.width = this.getValue(map, "width", 0) * scale; + mesh.height = this.getValue(map, "height", 0) * scale; + + const parent: string = this.getValue(map, "parent", null); + if (parent != null) { + this.linkedMeshes.push( + new LinkedMesh( + mesh, + this.getValue(map, "skin", null), + slotIndex, + parent, + this.getValue(map, "deform", true) + ) + ); + return mesh; + } + + const uvs: Array = map.uvs; + this.readVertices(map, mesh, uvs.length); + mesh.triangles = map.triangles; + mesh.regionUVs = uvs; + mesh.updateUVs(); + + mesh.edges = this.getValue(map, "edges", null); + mesh.hullLength = this.getValue(map, "hull", 0) * 2; + return mesh; + } + case "path": { + const path = this.attachmentLoader.newPathAttachment(skin, name); + if (path == null) return null; + path.closed = this.getValue(map, "closed", false); + path.constantSpeed = this.getValue(map, "constantSpeed", true); + + const vertexCount = map.vertexCount; + this.readVertices(map, path, vertexCount << 1); + + const lengths: Array = Utils.newArray(vertexCount / 3, 0); + for (let i = 0; i < map.lengths.length; i++) lengths[i] = map.lengths[i] * scale; + path.lengths = lengths; + + const color: string = this.getValue(map, "color", null); + if (color != null) path.color.setFromString(color); + return path; + } + case "point": { + const point = this.attachmentLoader.newPointAttachment(skin, name); + if (point == null) return null; + point.x = this.getValue(map, "x", 0) * scale; + point.y = this.getValue(map, "y", 0) * scale; + point.rotation = this.getValue(map, "rotation", 0); + + const color = this.getValue(map, "color", null); + if (color != null) point.color.setFromString(color); + return point; + } + case "clipping": { + const clip = this.attachmentLoader.newClippingAttachment(skin, name); + if (clip == null) return null; + + const end = this.getValue(map, "end", null); + if (end != null) { + const slot = skeletonData.findSlot(end); + if (slot == null) throw new Error("Clipping end slot not found: " + end); + clip.endSlot = slot; + } + + const vertexCount = map.vertexCount; + this.readVertices(map, clip, vertexCount << 1); + + const color: string = this.getValue(map, "color", null); + if (color != null) clip.color.setFromString(color); + return clip; + } + } + return null; + } + + readVertices(map: any, attachment: VertexAttachment, verticesLength: number) { + const scale = this.scale; + attachment.worldVerticesLength = verticesLength; + const vertices: Array = map.vertices; + if (verticesLength == vertices.length) { + const scaledVertices = Utils.toFloatArray(vertices); + if (scale != 1) { + for (let i = 0, n = vertices.length; i < n; i++) scaledVertices[i] *= scale; + } + attachment.vertices = scaledVertices; + return; + } + const weights = new Array(); + const bones = new Array(); + for (let i = 0, n = vertices.length; i < n; ) { + const boneCount = vertices[i++]; + bones.push(boneCount); + for (let nn = i + boneCount * 4; i < nn; i += 4) { + bones.push(vertices[i]); + weights.push(vertices[i + 1] * scale); + weights.push(vertices[i + 2] * scale); + weights.push(vertices[i + 3]); + } + } + attachment.bones = bones; + attachment.vertices = Utils.toFloatArray(weights); + } + + readAnimation(map: any, name: string, skeletonData: SkeletonData) { + const scale = this.scale; + const timelines = new Array(); + let duration = 0; + + // Slot timelines. + if (map.slots) { + for (const slotName in map.slots) { + const slotMap = map.slots[slotName]; + const slotIndex = skeletonData.findSlotIndex(slotName); + if (slotIndex == -1) throw new Error("Slot not found: " + slotName); + for (const timelineName in slotMap) { + const timelineMap = slotMap[timelineName]; + if (timelineName == "attachment") { + const timeline = new AttachmentTimeline(timelineMap.length); + timeline.slotIndex = slotIndex; + + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + timeline.setFrame(frameIndex++, this.getValue(valueMap, "time", 0), valueMap.name); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[timeline.getFrameCount() - 1]); + } else if (timelineName == "color") { + const timeline = new ColorTimeline(timelineMap.length); + timeline.slotIndex = slotIndex; + + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + const color = new Color(); + color.setFromString(valueMap.color); + timeline.setFrame(frameIndex, this.getValue(valueMap, "time", 0), color.r, color.g, color.b, color.a); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(timeline.getFrameCount() - 1) * ColorTimeline.ENTRIES]); + } else if (timelineName == "twoColor") { + const timeline = new TwoColorTimeline(timelineMap.length); + timeline.slotIndex = slotIndex; + + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + const light = new Color(); + const dark = new Color(); + light.setFromString(valueMap.light); + dark.setFromString(valueMap.dark); + timeline.setFrame( + frameIndex, + this.getValue(valueMap, "time", 0), + light.r, + light.g, + light.b, + light.a, + dark.r, + dark.g, + dark.b + ); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(timeline.getFrameCount() - 1) * TwoColorTimeline.ENTRIES]); + } else throw new Error("Invalid timeline type for a slot: " + timelineName + " (" + slotName + ")"); + } + } + } + + // Bone timelines. + if (map.bones) { + for (const boneName in map.bones) { + const boneMap = map.bones[boneName]; + const boneIndex = skeletonData.findBoneIndex(boneName); + if (boneIndex == -1) throw new Error("Bone not found: " + boneName); + for (const timelineName in boneMap) { + const timelineMap = boneMap[timelineName]; + if (timelineName === "rotate") { + const timeline = new RotateTimeline(timelineMap.length); + timeline.boneIndex = boneIndex; + + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + timeline.setFrame(frameIndex, this.getValue(valueMap, "time", 0), this.getValue(valueMap, "angle", 0)); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(timeline.getFrameCount() - 1) * RotateTimeline.ENTRIES]); + } else if (timelineName === "translate" || timelineName === "scale" || timelineName === "shear") { + let timeline: TranslateTimeline = null; + let timelineScale = 1, + defaultValue = 0; + if (timelineName === "scale") { + timeline = new ScaleTimeline(timelineMap.length); + defaultValue = 1; + } else if (timelineName === "shear") timeline = new ShearTimeline(timelineMap.length); + else { + timeline = new TranslateTimeline(timelineMap.length); + timelineScale = scale; + } + timeline.boneIndex = boneIndex; + + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + const x = this.getValue(valueMap, "x", defaultValue), + y = this.getValue(valueMap, "y", defaultValue); + timeline.setFrame(frameIndex, this.getValue(valueMap, "time", 0), x * timelineScale, y * timelineScale); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(timeline.getFrameCount() - 1) * TranslateTimeline.ENTRIES]); + } else throw new Error("Invalid timeline type for a bone: " + timelineName + " (" + boneName + ")"); + } + } + } + + // IK constraint timelines. + if (map.ik) { + for (const constraintName in map.ik) { + const constraintMap = map.ik[constraintName]; + const constraint = skeletonData.findIkConstraint(constraintName); + const timeline = new IkConstraintTimeline(constraintMap.length); + timeline.ikConstraintIndex = skeletonData.ikConstraints.indexOf(constraint); + let frameIndex = 0; + for (let i = 0; i < constraintMap.length; i++) { + const valueMap = constraintMap[i]; + timeline.setFrame( + frameIndex, + this.getValue(valueMap, "time", 0), + this.getValue(valueMap, "mix", 1), + this.getValue(valueMap, "softness", 0) * scale, + this.getValue(valueMap, "bendPositive", true) ? 1 : -1, + this.getValue(valueMap, "compress", false), + this.getValue(valueMap, "stretch", false) + ); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[(timeline.getFrameCount() - 1) * IkConstraintTimeline.ENTRIES]); + } + } + + // Transform constraint timelines. + if (map.transform) { + for (const constraintName in map.transform) { + const constraintMap = map.transform[constraintName]; + const constraint = skeletonData.findTransformConstraint(constraintName); + const timeline = new TransformConstraintTimeline(constraintMap.length); + timeline.transformConstraintIndex = skeletonData.transformConstraints.indexOf(constraint); + let frameIndex = 0; + for (let i = 0; i < constraintMap.length; i++) { + const valueMap = constraintMap[i]; + timeline.setFrame( + frameIndex, + this.getValue(valueMap, "time", 0), + this.getValue(valueMap, "rotateMix", 1), + this.getValue(valueMap, "translateMix", 1), + this.getValue(valueMap, "scaleMix", 1), + this.getValue(valueMap, "shearMix", 1) + ); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max( + duration, + timeline.frames[(timeline.getFrameCount() - 1) * TransformConstraintTimeline.ENTRIES] + ); + } + } + + // Path constraint timelines. + if (map.path) { + for (const constraintName in map.path) { + const constraintMap = map.path[constraintName]; + const index = skeletonData.findPathConstraintIndex(constraintName); + if (index == -1) throw new Error("Path constraint not found: " + constraintName); + const data = skeletonData.pathConstraints[index]; + for (const timelineName in constraintMap) { + const timelineMap = constraintMap[timelineName]; + if (timelineName === "position" || timelineName === "spacing") { + let timeline: PathConstraintPositionTimeline = null; + let timelineScale = 1; + if (timelineName === "spacing") { + timeline = new PathConstraintSpacingTimeline(timelineMap.length); + if (data.spacingMode == SpacingMode.Length || data.spacingMode == SpacingMode.Fixed) + timelineScale = scale; + } else { + timeline = new PathConstraintPositionTimeline(timelineMap.length); + if (data.positionMode == PositionMode.Fixed) timelineScale = scale; + } + timeline.pathConstraintIndex = index; + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + timeline.setFrame( + frameIndex, + this.getValue(valueMap, "time", 0), + this.getValue(valueMap, timelineName, 0) * timelineScale + ); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max( + duration, + timeline.frames[(timeline.getFrameCount() - 1) * PathConstraintPositionTimeline.ENTRIES] + ); + } else if (timelineName === "mix") { + const timeline = new PathConstraintMixTimeline(timelineMap.length); + timeline.pathConstraintIndex = index; + let frameIndex = 0; + for (let i = 0; i < timelineMap.length; i++) { + const valueMap = timelineMap[i]; + timeline.setFrame( + frameIndex, + this.getValue(valueMap, "time", 0), + this.getValue(valueMap, "rotateMix", 1), + this.getValue(valueMap, "translateMix", 1) + ); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max( + duration, + timeline.frames[(timeline.getFrameCount() - 1) * PathConstraintMixTimeline.ENTRIES] + ); + } + } + } + } + + // Deform timelines. + if (map.deform) { + for (const deformName in map.deform) { + const deformMap = map.deform[deformName]; + const skin = skeletonData.findSkin(deformName); + if (skin == null) throw new Error("Skin not found: " + deformName); + for (const slotName in deformMap) { + const slotMap = deformMap[slotName]; + const slotIndex = skeletonData.findSlotIndex(slotName); + if (slotIndex == -1) throw new Error("Slot not found: " + slotMap.name); + for (const timelineName in slotMap) { + const timelineMap = slotMap[timelineName]; + const attachment = skin.getAttachment(slotIndex, timelineName); + if (attachment == null) throw new Error("Deform attachment not found: " + timelineMap.name); + const weighted = attachment.bones != null; + const vertices = attachment.vertices; + const deformLength = weighted ? (vertices.length / 3) * 2 : vertices.length; + + const timeline = new DeformTimeline(timelineMap.length); + timeline.slotIndex = slotIndex; + timeline.attachment = attachment; + + let frameIndex = 0; + for (let j = 0; j < timelineMap.length; j++) { + const valueMap = timelineMap[j]; + let deform: ArrayLike; + const verticesValue: Array = this.getValue(valueMap, "vertices", null); + if (verticesValue == null) deform = weighted ? Utils.newFloatArray(deformLength) : vertices; + else { + deform = Utils.newFloatArray(deformLength); + const start = this.getValue(valueMap, "offset", 0); + Utils.arrayCopy(verticesValue, 0, deform, start, verticesValue.length); + if (scale != 1) { + for (let i = start, n = i + verticesValue.length; i < n; i++) deform[i] *= scale; + } + if (!weighted) { + for (let i = 0; i < deformLength; i++) deform[i] += vertices[i]; + } + } + + timeline.setFrame(frameIndex, this.getValue(valueMap, "time", 0), deform); + this.readCurve(valueMap, timeline, frameIndex); + frameIndex++; + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[timeline.getFrameCount() - 1]); + } + } + } + } + + // Draw order timeline. + let drawOrderNode = map.drawOrder; + if (drawOrderNode == null) drawOrderNode = map.draworder; + if (drawOrderNode != null) { + const timeline = new DrawOrderTimeline(drawOrderNode.length); + const slotCount = skeletonData.slots.length; + let frameIndex = 0; + for (let j = 0; j < drawOrderNode.length; j++) { + const drawOrderMap = drawOrderNode[j]; + let drawOrder: Array = null; + const offsets = this.getValue(drawOrderMap, "offsets", null); + if (offsets != null) { + drawOrder = Utils.newArray(slotCount, -1); + const unchanged = Utils.newArray(slotCount - offsets.length, 0); + let originalIndex = 0, + unchangedIndex = 0; + for (let i = 0; i < offsets.length; i++) { + const offsetMap = offsets[i]; + const slotIndex = skeletonData.findSlotIndex(offsetMap.slot); + if (slotIndex == -1) throw new Error("Slot not found: " + offsetMap.slot); + // Collect unchanged items. + while (originalIndex != slotIndex) unchanged[unchangedIndex++] = originalIndex++; + // Set changed items. + drawOrder[originalIndex + offsetMap.offset] = originalIndex++; + } + // Collect remaining unchanged items. + while (originalIndex < slotCount) unchanged[unchangedIndex++] = originalIndex++; + // Fill in unchanged items. + for (let i = slotCount - 1; i >= 0; i--) if (drawOrder[i] == -1) drawOrder[i] = unchanged[--unchangedIndex]; + } + timeline.setFrame(frameIndex++, this.getValue(drawOrderMap, "time", 0), drawOrder); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[timeline.getFrameCount() - 1]); + } + + // Event timeline. + if (map.events) { + const timeline = new EventTimeline(map.events.length); + let frameIndex = 0; + for (let i = 0; i < map.events.length; i++) { + const eventMap = map.events[i]; + const eventData = skeletonData.findEvent(eventMap.name); + if (eventData == null) throw new Error("Event not found: " + eventMap.name); + const event = new Event(Utils.toSinglePrecision(this.getValue(eventMap, "time", 0)), eventData); + event.intValue = this.getValue(eventMap, "int", eventData.intValue); + event.floatValue = this.getValue(eventMap, "float", eventData.floatValue); + event.stringValue = this.getValue(eventMap, "string", eventData.stringValue); + if (event.data.audioPath != null) { + event.volume = this.getValue(eventMap, "volume", 1); + event.balance = this.getValue(eventMap, "balance", 0); + } + timeline.setFrame(frameIndex++, event); + } + timelines.push(timeline); + duration = Math.max(duration, timeline.frames[timeline.getFrameCount() - 1]); + } + + if (isNaN(duration)) { + throw new Error("Error while parsing animation, duration is NaN"); + } + + skeletonData.animations.push(new Animation(name, timelines, duration)); + } + + readCurve(map: any, timeline: CurveTimeline, frameIndex: number) { + if (!map.hasOwnProperty("curve")) return; + if (map.curve == "stepped") timeline.setStepped(frameIndex); + else { + const curve: number = map.curve; + timeline.setCurve( + frameIndex, + curve, + this.getValue(map, "c2", 0), + this.getValue(map, "c3", 1), + this.getValue(map, "c4", 1) + ); + } + } + + getValue(map: any, prop: string, defaultValue: any) { + return map[prop] !== undefined ? map[prop] : defaultValue; + } + + static blendModeFromString(str: string) { + str = str.toLowerCase(); + if (str == "normal") return BlendMode.Normal; + if (str == "additive") return BlendMode.Additive; + if (str == "multiply") return BlendMode.Multiply; + if (str == "screen") return BlendMode.Screen; + throw new Error(`Unknown blend mode: ${str}`); + } + + static positionModeFromString(str: string) { + str = str.toLowerCase(); + if (str == "fixed") return PositionMode.Fixed; + if (str == "percent") return PositionMode.Percent; + throw new Error(`Unknown position mode: ${str}`); + } + + static spacingModeFromString(str: string) { + str = str.toLowerCase(); + if (str == "length") return SpacingMode.Length; + if (str == "fixed") return SpacingMode.Fixed; + if (str == "percent") return SpacingMode.Percent; + throw new Error(`Unknown position mode: ${str}`); + } + + static rotateModeFromString(str: string) { + str = str.toLowerCase(); + if (str == "tangent") return RotateMode.Tangent; + if (str == "chain") return RotateMode.Chain; + if (str == "chainscale") return RotateMode.ChainScale; + throw new Error(`Unknown rotate mode: ${str}`); + } + + static transformModeFromString(str: string) { + str = str.toLowerCase(); + if (str == "normal") return TransformMode.Normal; + if (str == "onlytranslation") return TransformMode.OnlyTranslation; + if (str == "norotationorreflection") return TransformMode.NoRotationOrReflection; + if (str == "noscale") return TransformMode.NoScale; + if (str == "noscaleorreflection") return TransformMode.NoScaleOrReflection; + throw new Error(`Unknown transform mode: ${str}`); + } +} + +class LinkedMesh { + parent: string; + skin: string; + slotIndex: number; + mesh: MeshAttachment; + inheritDeform: boolean; + + constructor(mesh: MeshAttachment, skin: string, slotIndex: number, parent: string, inheritDeform: boolean) { + this.mesh = mesh; + this.skin = skin; + this.slotIndex = slotIndex; + this.parent = parent; + this.inheritDeform = inheritDeform; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/Skin.ts b/packages/spine-core-3.8/src/spine-core/Skin.ts new file mode 100644 index 0000000000..3081742665 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Skin.ts @@ -0,0 +1,182 @@ +import { Attachment } from "./attachments/Attachment"; +import { BoneData } from "./BoneData"; +import { ConstraintData } from "./ConstraintData"; +import { Skeleton } from "./Skeleton"; +import { MeshAttachment } from "./attachments/MeshAttachment"; +import { Map } from "./Utils"; + +/** Stores an entry in the skin consisting of the slot index, name, and attachment **/ +export class SkinEntry { + constructor( + public slotIndex: number, + public name: string, + public attachment: Attachment + ) {} +} + +/** Stores attachments by slot index and attachment name. + * + * See SkeletonData {@link SkeletonData#defaultSkin}, Skeleton {@link Skeleton#skin}, and + * [Runtime skins](http://esotericsoftware.com/spine-runtime-skins) in the Spine Runtimes Guide. */ +export class Skin { + /** The skin's name, which is unique across all skins in the skeleton. */ + name: string; + + attachments = new Array>(); + bones = Array(); + constraints = new Array(); + + constructor(name: string) { + if (name == null) throw new Error("name cannot be null."); + this.name = name; + } + + /** Adds an attachment to the skin for the specified slot index and name. */ + setAttachment(slotIndex: number, name: string, attachment: Attachment) { + if (attachment == null) throw new Error("attachment cannot be null."); + const attachments = this.attachments; + if (slotIndex >= attachments.length) attachments.length = slotIndex + 1; + if (!attachments[slotIndex]) attachments[slotIndex] = {}; + attachments[slotIndex][name] = attachment; + } + + /** Adds all attachments, bones, and constraints from the specified skin to this skin. */ + addSkin(skin: Skin) { + for (let i = 0; i < skin.bones.length; i++) { + const bone = skin.bones[i]; + let contained = false; + for (let j = 0; j < this.bones.length; j++) { + if (this.bones[j] == bone) { + contained = true; + break; + } + } + if (!contained) this.bones.push(bone); + } + + for (let i = 0; i < skin.constraints.length; i++) { + const constraint = skin.constraints[i]; + let contained = false; + for (let j = 0; j < this.constraints.length; j++) { + if (this.constraints[j] == constraint) { + contained = true; + break; + } + } + if (!contained) this.constraints.push(constraint); + } + + const attachments = skin.getAttachments(); + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + this.setAttachment(attachment.slotIndex, attachment.name, attachment.attachment); + } + } + + /** Adds all bones and constraints and copies of all attachments from the specified skin to this skin. Mesh attachments are not + * copied, instead a new linked mesh is created. The attachment copies can be modified without affecting the originals. */ + copySkin(skin: Skin) { + for (let i = 0; i < skin.bones.length; i++) { + const bone = skin.bones[i]; + let contained = false; + for (let j = 0; j < this.bones.length; j++) { + if (this.bones[j] == bone) { + contained = true; + break; + } + } + if (!contained) this.bones.push(bone); + } + + for (let i = 0; i < skin.constraints.length; i++) { + const constraint = skin.constraints[i]; + let contained = false; + for (let j = 0; j < this.constraints.length; j++) { + if (this.constraints[j] == constraint) { + contained = true; + break; + } + } + if (!contained) this.constraints.push(constraint); + } + + const attachments = skin.getAttachments(); + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i]; + if (attachment.attachment == null) continue; + if (attachment.attachment instanceof MeshAttachment) { + attachment.attachment = attachment.attachment.newLinkedMesh(); + this.setAttachment(attachment.slotIndex, attachment.name, attachment.attachment); + } else { + attachment.attachment = attachment.attachment.copy(); + this.setAttachment(attachment.slotIndex, attachment.name, attachment.attachment); + } + } + } + + /** Returns the attachment for the specified slot index and name, or null. */ + getAttachment(slotIndex: number, name: string): Attachment { + const dictionary = this.attachments[slotIndex]; + return dictionary ? dictionary[name] : null; + } + + /** Removes the attachment in the skin for the specified slot index and name, if any. */ + removeAttachment(slotIndex: number, name: string) { + const dictionary = this.attachments[slotIndex]; + if (dictionary) dictionary[name] = null; + } + + /** Returns all attachments in this skin. */ + getAttachments(): Array { + const entries = new Array(); + for (let i = 0; i < this.attachments.length; i++) { + const slotAttachments = this.attachments[i]; + if (slotAttachments) { + for (const name in slotAttachments) { + const attachment = slotAttachments[name]; + if (attachment) entries.push(new SkinEntry(i, name, attachment)); + } + } + } + return entries; + } + + /** Returns all attachments in this skin for the specified slot index. */ + getAttachmentsForSlot(slotIndex: number, attachments: Array) { + const slotAttachments = this.attachments[slotIndex]; + if (slotAttachments) { + for (const name in slotAttachments) { + const attachment = slotAttachments[name]; + if (attachment) attachments.push(new SkinEntry(slotIndex, name, attachment)); + } + } + } + + /** Clears all attachments, bones, and constraints. */ + clear() { + this.attachments.length = 0; + this.bones.length = 0; + this.constraints.length = 0; + } + + /** Attach each attachment in this skin if the corresponding attachment in the old skin is currently attached. */ + attachAll(skeleton: Skeleton, oldSkin: Skin) { + let slotIndex = 0; + for (let i = 0; i < skeleton.slots.length; i++) { + const slot = skeleton.slots[i]; + const slotAttachment = slot.getAttachment(); + if (slotAttachment && slotIndex < oldSkin.attachments.length) { + const dictionary = oldSkin.attachments[slotIndex]; + for (const key in dictionary) { + const skinAttachment: Attachment = dictionary[key]; + if (slotAttachment == skinAttachment) { + const attachment = this.getAttachment(slotIndex, key); + if (attachment != null) slot.setAttachment(attachment); + break; + } + } + } + slotIndex++; + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/Slot.ts b/packages/spine-core-3.8/src/spine-core/Slot.ts new file mode 100644 index 0000000000..5e4903193e --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Slot.ts @@ -0,0 +1,86 @@ +import { SlotData } from "./SlotData"; +import { Bone } from "./Bone"; +import { Color } from "./Utils"; +import { Attachment } from "./attachments/Attachment"; +import { Skeleton } from "./Skeleton"; + +/** Stores a slot's current pose. Slots organize attachments for {@link Skeleton#drawOrder} purposes and provide a place to store + * state for an attachment. State cannot be stored in an attachment itself because attachments are stateless and may be shared + * across multiple skeletons. */ +export class Slot { + /** The slot's setup pose data. */ + data: SlotData; + + /** The bone this slot belongs to. */ + bone: Bone; + + /** The color used to tint the slot's attachment. If {@link #getDarkColor()} is set, this is used as the light color for two + * color tinting. */ + color: Color; + + /** The dark color used to tint the slot's attachment for two color tinting, or null if two color tinting is not used. The dark + * color's alpha is not used. */ + darkColor: Color; + + attachment: Attachment; + + private attachmentTime: number; + + attachmentState: number; + + /** Values to deform the slot's attachment. For an unweighted mesh, the entries are local positions for each vertex. For a + * weighted mesh, the entries are an offset for each vertex which will be added to the mesh's local vertex positions. + * + * See {@link VertexAttachment#computeWorldVertices()} and {@link DeformTimeline}. */ + deform = new Array(); + + constructor(data: SlotData, bone: Bone) { + if (data == null) throw new Error("data cannot be null."); + if (bone == null) throw new Error("bone cannot be null."); + this.data = data; + this.bone = bone; + this.color = new Color(); + this.darkColor = data.darkColor == null ? null : new Color(); + this.setToSetupPose(); + } + + /** The skeleton this slot belongs to. */ + getSkeleton(): Skeleton { + return this.bone.skeleton; + } + + /** The current attachment for the slot, or null if the slot has no attachment. */ + getAttachment(): Attachment { + return this.attachment; + } + + /** Sets the slot's attachment and, if the attachment changed, resets {@link #attachmentTime} and clears {@link #deform}. + * @param attachment May be null. */ + setAttachment(attachment: Attachment) { + if (this.attachment == attachment) return; + this.attachment = attachment; + this.attachmentTime = this.bone.skeleton.time; + this.deform.length = 0; + } + + setAttachmentTime(time: number) { + this.attachmentTime = this.bone.skeleton.time - time; + } + + /** The time that has elapsed since the last time the attachment was set or cleared. Relies on Skeleton + * {@link Skeleton#time}. */ + getAttachmentTime(): number { + return this.bone.skeleton.time - this.attachmentTime; + } + + /** Sets this slot to the setup pose. */ + setToSetupPose() { + this.color.setFromColor(this.data.color); + if (this.darkColor != null) this.darkColor.setFromColor(this.data.darkColor); + if (this.data.attachmentName == null) this.attachment = null; + else { + this.attachment = null; + this.setAttachment(this.bone.skeleton.getAttachment(this.data.index, this.data.attachmentName)); + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/SlotData.ts b/packages/spine-core-3.8/src/spine-core/SlotData.ts new file mode 100644 index 0000000000..c5d3705b79 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/SlotData.ts @@ -0,0 +1,38 @@ +import { BoneData } from "./BoneData"; +import { Color } from "./Utils"; +import { BlendMode } from "./BlendMode"; + +/** Stores the setup pose for a {@link Slot}. */ +export class SlotData { + /** The index of the slot in {@link Skeleton#getSlots()}. */ + index: number; + + /** The name of the slot, which is unique across all slots in the skeleton. */ + name: string; + + /** The bone this slot belongs to. */ + boneData: BoneData; + + /** The color used to tint the slot's attachment. If {@link #getDarkColor()} is set, this is used as the light color for two + * color tinting. */ + color = new Color(1, 1, 1, 1); + + /** The dark color used to tint the slot's attachment for two color tinting, or null if two color tinting is not used. The dark + * color's alpha is not used. */ + darkColor: Color; + + /** The name of the attachment that is visible for this slot in the setup pose, or null if no attachment is visible. */ + attachmentName: string; + + /** The blend mode for drawing the slot's attachment. */ + blendMode: BlendMode; + + constructor(index: number, name: string, boneData: BoneData) { + if (index < 0) throw new Error("index must be >= 0."); + if (name == null) throw new Error("name cannot be null."); + if (boneData == null) throw new Error("boneData cannot be null."); + this.index = index; + this.name = name; + this.boneData = boneData; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/Texture.ts b/packages/spine-core-3.8/src/spine-core/Texture.ts new file mode 100644 index 0000000000..f911a5625c --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Texture.ts @@ -0,0 +1,96 @@ +import { Engine, Texture2D } from "@galacean/engine"; + +export abstract class Texture { + protected _texture: Texture2D; + + constructor(texture: Texture2D) { + this._texture = texture; + } + + get width(): number { + return this._texture.width; + } + + get height(): number { + return this._texture.height; + } + + get texture(): Texture2D { + return this._texture; + } + + abstract setFilters(minFilter: TextureFilter, magFilter: TextureFilter): void; + abstract setWraps(uWrap: TextureWrap, vWrap: TextureWrap): void; + abstract dispose(): void; + + public static filterFromString(text: string): TextureFilter { + switch (text.toLowerCase()) { + case "nearest": + return TextureFilter.Nearest; + case "linear": + return TextureFilter.Linear; + case "mipmap": + return TextureFilter.MipMap; + case "mipmapnearestnearest": + return TextureFilter.MipMapNearestNearest; + case "mipmaplinearnearest": + return TextureFilter.MipMapLinearNearest; + case "mipmapnearestlinear": + return TextureFilter.MipMapNearestLinear; + case "mipmaplinearlinear": + return TextureFilter.MipMapLinearLinear; + default: + throw new Error(`Unknown texture filter ${text}`); + } + } + + public static wrapFromString(text: string): TextureWrap { + switch (text.toLowerCase()) { + case "mirroredtepeat": + return TextureWrap.MirroredRepeat; + case "clamptoedge": + return TextureWrap.ClampToEdge; + case "repeat": + return TextureWrap.Repeat; + default: + throw new Error(`Unknown texture wrap ${text}`); + } + } +} + +export enum TextureFilter { + Nearest = 9728, // WebGLRenderingContext.NEAREST + Linear = 9729, // WebGLRenderingContext.LINEAR + MipMap = 9987, // WebGLRenderingContext.LINEAR_MIPMAP_LINEAR + MipMapNearestNearest = 9984, // WebGLRenderingContext.NEAREST_MIPMAP_NEAREST + MipMapLinearNearest = 9985, // WebGLRenderingContext.LINEAR_MIPMAP_NEAREST + MipMapNearestLinear = 9986, // WebGLRenderingContext.NEAREST_MIPMAP_LINEAR + MipMapLinearLinear = 9987 // WebGLRenderingContext.LINEAR_MIPMAP_LINEAR +} + +export enum TextureWrap { + MirroredRepeat = 33648, // WebGLRenderingContext.MIRRORED_REPEAT + ClampToEdge = 33071, // WebGLRenderingContext.CLAMP_TO_EDGE + Repeat = 10497 // WebGLRenderingContext.REPEAT +} + +export class TextureRegion { + renderObject: any; + u = 0; + v = 0; + u2 = 0; + v2 = 0; + width = 0; + height = 0; + rotate = false; + offsetX = 0; + offsetY = 0; + originalWidth = 0; + originalHeight = 0; +} + +export class FakeTexture extends Texture { + setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {} + setWraps(uWrap: TextureWrap, vWrap: TextureWrap) {} + dispose() {} +} diff --git a/packages/spine-core-3.8/src/spine-core/TextureAtlas.ts b/packages/spine-core-3.8/src/spine-core/TextureAtlas.ts new file mode 100644 index 0000000000..299e9f94be --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/TextureAtlas.ts @@ -0,0 +1,187 @@ +import { Disposable } from "./Utils"; +import { Texture, TextureFilter } from "./Texture"; +import { TextureWrap, TextureRegion } from "./Texture"; + +export class TextureAtlas implements Disposable { + pages = new Array(); + regions = new Array(); + + constructor(atlasText: string, textureLoader: (path: string, width?: number, height?: number) => any) { + this.load(atlasText, textureLoader); + } + + private load(atlasText: string, textureLoader: (path: string, width?: number, height?: number) => any) { + if (textureLoader == null) throw new Error("textureLoader cannot be null."); + + const reader = new TextureAtlasReader(atlasText); + const tuple = new Array(4); + let page: TextureAtlasPage = null; + while (true) { + let line = reader.readLine(); + if (line == null) break; + line = line.trim(); + if (line.length == 0) page = null; + else if (!page) { + page = new TextureAtlasPage(); + page.name = line; + + if (reader.readTuple(tuple) == 2) { + // size is only optional for an atlas packed with an old TexturePacker. + page.width = parseInt(tuple[0]); + page.height = parseInt(tuple[1]); + reader.readTuple(tuple); + } + // page.format = Format[tuple[0]]; we don't need format in WebGL + + reader.readTuple(tuple); + page.minFilter = Texture.filterFromString(tuple[0]); + page.magFilter = Texture.filterFromString(tuple[1]); + + const direction = reader.readValue(); + page.uWrap = TextureWrap.ClampToEdge; + page.vWrap = TextureWrap.ClampToEdge; + if (direction == "x") page.uWrap = TextureWrap.Repeat; + else if (direction == "y") page.vWrap = TextureWrap.Repeat; + else if (direction == "xy") page.uWrap = page.vWrap = TextureWrap.Repeat; + + page.texture = textureLoader(line); + page.texture.setFilters(page.minFilter, page.magFilter); + page.texture.setWraps(page.uWrap, page.vWrap); + page.width = page.texture.width; + page.height = page.texture.height; + this.pages.push(page); + } else { + const region: TextureAtlasRegion = new TextureAtlasRegion(); + region.name = line; + region.page = page; + + const rotateValue = reader.readValue(); + if (rotateValue.toLocaleLowerCase() == "true") { + region.degrees = 90; + } else if (rotateValue.toLocaleLowerCase() == "false") { + region.degrees = 0; + } else { + region.degrees = parseFloat(rotateValue); + } + region.rotate = region.degrees == 90; + + reader.readTuple(tuple); + const x = parseInt(tuple[0]); + const y = parseInt(tuple[1]); + + reader.readTuple(tuple); + const width = parseInt(tuple[0]); + const height = parseInt(tuple[1]); + + region.u = x / page.width; + region.v = y / page.height; + if (region.rotate) { + region.u2 = (x + height) / page.width; + region.v2 = (y + width) / page.height; + } else { + region.u2 = (x + width) / page.width; + region.v2 = (y + height) / page.height; + } + region.x = x; + region.y = y; + region.width = Math.abs(width); + region.height = Math.abs(height); + + if (reader.readTuple(tuple) == 4) { + // split is optional + // region.splits = new Vector.(parseInt(tuple[0]), parseInt(tuple[1]), parseInt(tuple[2]), parseInt(tuple[3])); + if (reader.readTuple(tuple) == 4) { + // pad is optional, but only present with splits + //region.pads = Vector.(parseInt(tuple[0]), parseInt(tuple[1]), parseInt(tuple[2]), parseInt(tuple[3])); + reader.readTuple(tuple); + } + } + + region.originalWidth = parseInt(tuple[0]); + region.originalHeight = parseInt(tuple[1]); + + reader.readTuple(tuple); + region.offsetX = parseInt(tuple[0]); + region.offsetY = parseInt(tuple[1]); + + region.index = parseInt(reader.readValue()); + + region.texture = page.texture; + this.regions.push(region); + } + } + } + + findRegion(name: string): TextureAtlasRegion { + for (let i = 0; i < this.regions.length; i++) { + if (this.regions[i].name == name) { + return this.regions[i]; + } + } + return null; + } + + dispose() { + for (let i = 0; i < this.pages.length; i++) { + this.pages[i].texture.dispose(); + } + } +} + +class TextureAtlasReader { + lines: Array; + index: number = 0; + + constructor(text: string) { + this.lines = text.split(/\r\n|\r|\n/); + } + + readLine(): string { + if (this.index >= this.lines.length) return null; + return this.lines[this.index++]; + } + + readValue(): string { + const line = this.readLine(); + const colon = line.indexOf(":"); + if (colon == -1) throw new Error("Invalid line: " + line); + return line.substring(colon + 1).trim(); + } + + readTuple(tuple: Array): number { + const line = this.readLine(); + const colon = line.indexOf(":"); + if (colon == -1) throw new Error("Invalid line: " + line); + let i = 0, + lastMatch = colon + 1; + for (; i < 3; i++) { + const comma = line.indexOf(",", lastMatch); + if (comma == -1) break; + tuple[i] = line.substr(lastMatch, comma - lastMatch).trim(); + lastMatch = comma + 1; + } + tuple[i] = line.substring(lastMatch).trim(); + return i + 1; + } +} + +export class TextureAtlasPage { + name: string; + minFilter: TextureFilter; + magFilter: TextureFilter; + uWrap: TextureWrap; + vWrap: TextureWrap; + texture: Texture; + width: number; + height: number; +} + +export class TextureAtlasRegion extends TextureRegion { + page: TextureAtlasPage; + name: string; + x: number; + y: number; + index: number; + degrees: number; + texture: Texture; +} diff --git a/packages/spine-core-3.8/src/spine-core/TransformConstraint.ts b/packages/spine-core-3.8/src/spine-core/TransformConstraint.ts new file mode 100644 index 0000000000..ab841eb62e --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/TransformConstraint.ts @@ -0,0 +1,296 @@ +import { Updatable } from "./Updatable"; +import { TransformConstraintData } from "./TransformConstraintData"; +import { Bone } from "./Bone"; +import { Skeleton } from "./Skeleton"; +import { MathUtils, Vector2 } from "./Utils"; + +/** Stores the current pose for a transform constraint. A transform constraint adjusts the world transform of the constrained + * bones to match that of the target bone. + * + * See [Transform constraints](http://esotericsoftware.com/spine-transform-constraints) in the Spine User Guide. */ +export class TransformConstraint implements Updatable { + /** The transform constraint's setup pose data. */ + data: TransformConstraintData; + + /** The bones that will be modified by this transform constraint. */ + bones: Array; + + /** The target bone whose world transform will be copied to the constrained bones. */ + target: Bone; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + rotateMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained translations. */ + translateMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained scales. */ + scaleMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained scales. */ + shearMix = 0; + + temp = new Vector2(); + active = false; + + constructor(data: TransformConstraintData, skeleton: Skeleton) { + if (data == null) throw new Error("data cannot be null."); + if (skeleton == null) throw new Error("skeleton cannot be null."); + this.data = data; + this.rotateMix = data.rotateMix; + this.translateMix = data.translateMix; + this.scaleMix = data.scaleMix; + this.shearMix = data.shearMix; + this.bones = new Array(); + for (let i = 0; i < data.bones.length; i++) this.bones.push(skeleton.findBone(data.bones[i].name)); + this.target = skeleton.findBone(data.target.name); + } + + isActive() { + return this.active; + } + + /** Applies the constraint to the constrained bones. */ + apply() { + this.update(); + } + + update() { + if (this.data.local) { + if (this.data.relative) this.applyRelativeLocal(); + else this.applyAbsoluteLocal(); + } else { + if (this.data.relative) this.applyRelativeWorld(); + else this.applyAbsoluteWorld(); + } + } + + applyAbsoluteWorld() { + const rotateMix = this.rotateMix, + translateMix = this.translateMix, + scaleMix = this.scaleMix, + shearMix = this.shearMix; + const target = this.target; + const ta = target.a, + tb = target.b, + tc = target.c, + td = target.d; + const degRadReflect = ta * td - tb * tc > 0 ? MathUtils.degRad : -MathUtils.degRad; + const offsetRotation = this.data.offsetRotation * degRadReflect; + const offsetShearY = this.data.offsetShearY * degRadReflect; + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + let modified = false; + + if (rotateMix != 0) { + const a = bone.a, + b = bone.b, + c = bone.c, + d = bone.d; + let r = Math.atan2(tc, ta) - Math.atan2(c, a) + offsetRotation; + if (r > MathUtils.PI) r -= MathUtils.PI2; + else if (r < -MathUtils.PI) r += MathUtils.PI2; + r *= rotateMix; + const cos = Math.cos(r), + sin = Math.sin(r); + bone.a = cos * a - sin * c; + bone.b = cos * b - sin * d; + bone.c = sin * a + cos * c; + bone.d = sin * b + cos * d; + modified = true; + } + + if (translateMix != 0) { + const temp = this.temp; + target.localToWorld(temp.set(this.data.offsetX, this.data.offsetY)); + bone.worldX += (temp.x - bone.worldX) * translateMix; + bone.worldY += (temp.y - bone.worldY) * translateMix; + modified = true; + } + + if (scaleMix > 0) { + let s = Math.sqrt(bone.a * bone.a + bone.c * bone.c); + let ts = Math.sqrt(ta * ta + tc * tc); + if (s > 0.00001) s = (s + (ts - s + this.data.offsetScaleX) * scaleMix) / s; + bone.a *= s; + bone.c *= s; + s = Math.sqrt(bone.b * bone.b + bone.d * bone.d); + ts = Math.sqrt(tb * tb + td * td); + if (s > 0.00001) s = (s + (ts - s + this.data.offsetScaleY) * scaleMix) / s; + bone.b *= s; + bone.d *= s; + modified = true; + } + + if (shearMix > 0) { + const b = bone.b, + d = bone.d; + const by = Math.atan2(d, b); + let r = Math.atan2(td, tb) - Math.atan2(tc, ta) - (by - Math.atan2(bone.c, bone.a)); + if (r > MathUtils.PI) r -= MathUtils.PI2; + else if (r < -MathUtils.PI) r += MathUtils.PI2; + r = by + (r + offsetShearY) * shearMix; + const s = Math.sqrt(b * b + d * d); + bone.b = Math.cos(r) * s; + bone.d = Math.sin(r) * s; + modified = true; + } + + if (modified) bone.appliedValid = false; + } + } + + applyRelativeWorld() { + const rotateMix = this.rotateMix, + translateMix = this.translateMix, + scaleMix = this.scaleMix, + shearMix = this.shearMix; + const target = this.target; + const ta = target.a, + tb = target.b, + tc = target.c, + td = target.d; + const degRadReflect = ta * td - tb * tc > 0 ? MathUtils.degRad : -MathUtils.degRad; + const offsetRotation = this.data.offsetRotation * degRadReflect, + offsetShearY = this.data.offsetShearY * degRadReflect; + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + let modified = false; + + if (rotateMix != 0) { + const a = bone.a, + b = bone.b, + c = bone.c, + d = bone.d; + let r = Math.atan2(tc, ta) + offsetRotation; + if (r > MathUtils.PI) r -= MathUtils.PI2; + else if (r < -MathUtils.PI) r += MathUtils.PI2; + r *= rotateMix; + const cos = Math.cos(r), + sin = Math.sin(r); + bone.a = cos * a - sin * c; + bone.b = cos * b - sin * d; + bone.c = sin * a + cos * c; + bone.d = sin * b + cos * d; + modified = true; + } + + if (translateMix != 0) { + const temp = this.temp; + target.localToWorld(temp.set(this.data.offsetX, this.data.offsetY)); + bone.worldX += temp.x * translateMix; + bone.worldY += temp.y * translateMix; + modified = true; + } + + if (scaleMix > 0) { + let s = (Math.sqrt(ta * ta + tc * tc) - 1 + this.data.offsetScaleX) * scaleMix + 1; + bone.a *= s; + bone.c *= s; + s = (Math.sqrt(tb * tb + td * td) - 1 + this.data.offsetScaleY) * scaleMix + 1; + bone.b *= s; + bone.d *= s; + modified = true; + } + + if (shearMix > 0) { + let r = Math.atan2(td, tb) - Math.atan2(tc, ta); + if (r > MathUtils.PI) r -= MathUtils.PI2; + else if (r < -MathUtils.PI) r += MathUtils.PI2; + const b = bone.b, + d = bone.d; + r = Math.atan2(d, b) + (r - MathUtils.PI / 2 + offsetShearY) * shearMix; + const s = Math.sqrt(b * b + d * d); + bone.b = Math.cos(r) * s; + bone.d = Math.sin(r) * s; + modified = true; + } + + if (modified) bone.appliedValid = false; + } + } + + applyAbsoluteLocal() { + const rotateMix = this.rotateMix, + translateMix = this.translateMix, + scaleMix = this.scaleMix, + shearMix = this.shearMix; + const target = this.target; + if (!target.appliedValid) target.updateAppliedTransform(); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + if (!bone.appliedValid) bone.updateAppliedTransform(); + + let rotation = bone.arotation; + if (rotateMix != 0) { + let r = target.arotation - rotation + this.data.offsetRotation; + r -= (16384 - ((16384.499999999996 - r / 360) | 0)) * 360; + rotation += r * rotateMix; + } + + let x = bone.ax, + y = bone.ay; + if (translateMix != 0) { + x += (target.ax - x + this.data.offsetX) * translateMix; + y += (target.ay - y + this.data.offsetY) * translateMix; + } + + let scaleX = bone.ascaleX, + scaleY = bone.ascaleY; + if (scaleMix != 0) { + if (scaleX > 0.00001) + scaleX = (scaleX + (target.ascaleX - scaleX + this.data.offsetScaleX) * scaleMix) / scaleX; + if (scaleY > 0.00001) + scaleY = (scaleY + (target.ascaleY - scaleY + this.data.offsetScaleY) * scaleMix) / scaleY; + } + + const shearY = bone.ashearY; + if (shearMix != 0) { + let r = target.ashearY - shearY + this.data.offsetShearY; + r -= (16384 - ((16384.499999999996 - r / 360) | 0)) * 360; + bone.shearY += r * shearMix; + } + + bone.updateWorldTransformWith(x, y, rotation, scaleX, scaleY, bone.ashearX, shearY); + } + } + + applyRelativeLocal() { + const rotateMix = this.rotateMix, + translateMix = this.translateMix, + scaleMix = this.scaleMix, + shearMix = this.shearMix; + const target = this.target; + if (!target.appliedValid) target.updateAppliedTransform(); + const bones = this.bones; + for (let i = 0, n = bones.length; i < n; i++) { + const bone = bones[i]; + if (!bone.appliedValid) bone.updateAppliedTransform(); + + let rotation = bone.arotation; + if (rotateMix != 0) rotation += (target.arotation + this.data.offsetRotation) * rotateMix; + + let x = bone.ax, + y = bone.ay; + if (translateMix != 0) { + x += (target.ax + this.data.offsetX) * translateMix; + y += (target.ay + this.data.offsetY) * translateMix; + } + + let scaleX = bone.ascaleX, + scaleY = bone.ascaleY; + if (scaleMix != 0) { + if (scaleX > 0.00001) scaleX *= (target.ascaleX - 1 + this.data.offsetScaleX) * scaleMix + 1; + if (scaleY > 0.00001) scaleY *= (target.ascaleY - 1 + this.data.offsetScaleY) * scaleMix + 1; + } + + let shearY = bone.ashearY; + if (shearMix != 0) shearY += (target.ashearY + this.data.offsetShearY) * shearMix; + + bone.updateWorldTransformWith(x, y, rotation, scaleX, scaleY, bone.ashearX, shearY); + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/TransformConstraintData.ts b/packages/spine-core-3.8/src/spine-core/TransformConstraintData.ts new file mode 100644 index 0000000000..c135697c08 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/TransformConstraintData.ts @@ -0,0 +1,50 @@ +import { ConstraintData } from "./ConstraintData"; +import { BoneData } from "./BoneData"; + +/** Stores the setup pose for a {@link TransformConstraint}. + * + * See [Transform constraints](http://esotericsoftware.com/spine-transform-constraints) in the Spine User Guide. */ +export class TransformConstraintData extends ConstraintData { + /** The bones that will be modified by this transform constraint. */ + bones = new Array(); + + /** The target bone whose world transform will be copied to the constrained bones. */ + target: BoneData; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained rotations. */ + rotateMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained translations. */ + translateMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained scales. */ + scaleMix = 0; + + /** A percentage (0-1) that controls the mix between the constrained and unconstrained shears. */ + shearMix = 0; + + /** An offset added to the constrained bone rotation. */ + offsetRotation = 0; + + /** An offset added to the constrained bone X translation. */ + offsetX = 0; + + /** An offset added to the constrained bone Y translation. */ + offsetY = 0; + + /** An offset added to the constrained bone scaleX. */ + offsetScaleX = 0; + + /** An offset added to the constrained bone scaleY. */ + offsetScaleY = 0; + + /** An offset added to the constrained bone shearY. */ + offsetShearY = 0; + + relative = false; + local = false; + + constructor(name: string) { + super(name, 0, false); + } +} diff --git a/packages/spine-core-3.8/src/spine-core/Triangulator.ts b/packages/spine-core-3.8/src/spine-core/Triangulator.ts new file mode 100644 index 0000000000..bd6efff41d --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Triangulator.ts @@ -0,0 +1,269 @@ +import { Pool } from "./Utils"; + +export class Triangulator { + private convexPolygons = new Array>(); + private convexPolygonsIndices = new Array>(); + + private indicesArray = new Array(); + private isConcaveArray = new Array(); + private triangles = new Array(); + + private polygonPool = new Pool>(() => { + return new Array(); + }); + + private polygonIndicesPool = new Pool>(() => { + return new Array(); + }); + + public triangulate(verticesArray: ArrayLike): Array { + const vertices = verticesArray; + let vertexCount = verticesArray.length >> 1; + + const indices = this.indicesArray; + indices.length = 0; + for (let i = 0; i < vertexCount; i++) indices[i] = i; + + const isConcave = this.isConcaveArray; + isConcave.length = 0; + for (let i = 0, n = vertexCount; i < n; ++i) + isConcave[i] = Triangulator.isConcave(i, vertexCount, vertices, indices); + + const triangles = this.triangles; + triangles.length = 0; + + while (vertexCount > 3) { + // Find ear tip. + let previous = vertexCount - 1, + i = 0, + next = 1; + while (true) { + outer: if (!isConcave[i]) { + const p1 = indices[previous] << 1, + p2 = indices[i] << 1, + p3 = indices[next] << 1; + const p1x = vertices[p1], + p1y = vertices[p1 + 1]; + const p2x = vertices[p2], + p2y = vertices[p2 + 1]; + const p3x = vertices[p3], + p3y = vertices[p3 + 1]; + for (let ii = (next + 1) % vertexCount; ii != previous; ii = (ii + 1) % vertexCount) { + if (!isConcave[ii]) continue; + const v = indices[ii] << 1; + const vx = vertices[v], + vy = vertices[v + 1]; + if (Triangulator.positiveArea(p3x, p3y, p1x, p1y, vx, vy)) { + if (Triangulator.positiveArea(p1x, p1y, p2x, p2y, vx, vy)) { + if (Triangulator.positiveArea(p2x, p2y, p3x, p3y, vx, vy)) break outer; + } + } + } + break; + } + + if (next == 0) { + do { + if (!isConcave[i]) break; + i--; + } while (i > 0); + break; + } + + previous = i; + i = next; + next = (next + 1) % vertexCount; + } + + // Cut ear tip. + triangles.push(indices[(vertexCount + i - 1) % vertexCount]); + triangles.push(indices[i]); + triangles.push(indices[(i + 1) % vertexCount]); + indices.splice(i, 1); + isConcave.splice(i, 1); + vertexCount--; + + const previousIndex = (vertexCount + i - 1) % vertexCount; + const nextIndex = i == vertexCount ? 0 : i; + isConcave[previousIndex] = Triangulator.isConcave(previousIndex, vertexCount, vertices, indices); + isConcave[nextIndex] = Triangulator.isConcave(nextIndex, vertexCount, vertices, indices); + } + + if (vertexCount == 3) { + triangles.push(indices[2]); + triangles.push(indices[0]); + triangles.push(indices[1]); + } + + return triangles; + } + + decompose(verticesArray: Array, triangles: Array): Array> { + const vertices = verticesArray; + const convexPolygons = this.convexPolygons; + this.polygonPool.freeAll(convexPolygons); + convexPolygons.length = 0; + + const convexPolygonsIndices = this.convexPolygonsIndices; + this.polygonIndicesPool.freeAll(convexPolygonsIndices); + convexPolygonsIndices.length = 0; + + let polygonIndices = this.polygonIndicesPool.obtain(); + polygonIndices.length = 0; + + let polygon = this.polygonPool.obtain(); + polygon.length = 0; + + // Merge subsequent triangles if they form a triangle fan. + let fanBaseIndex = -1, + lastWinding = 0; + for (let i = 0, n = triangles.length; i < n; i += 3) { + const t1 = triangles[i] << 1, + t2 = triangles[i + 1] << 1, + t3 = triangles[i + 2] << 1; + const x1 = vertices[t1], + y1 = vertices[t1 + 1]; + const x2 = vertices[t2], + y2 = vertices[t2 + 1]; + const x3 = vertices[t3], + y3 = vertices[t3 + 1]; + + // If the base of the last triangle is the same as this triangle, check if they form a convex polygon (triangle fan). + let merged = false; + if (fanBaseIndex == t1) { + const o = polygon.length - 4; + const winding1 = Triangulator.winding(polygon[o], polygon[o + 1], polygon[o + 2], polygon[o + 3], x3, y3); + const winding2 = Triangulator.winding(x3, y3, polygon[0], polygon[1], polygon[2], polygon[3]); + if (winding1 == lastWinding && winding2 == lastWinding) { + polygon.push(x3); + polygon.push(y3); + polygonIndices.push(t3); + merged = true; + } + } + + // Otherwise make this triangle the new base. + if (!merged) { + if (polygon.length > 0) { + convexPolygons.push(polygon); + convexPolygonsIndices.push(polygonIndices); + } else { + this.polygonPool.free(polygon); + this.polygonIndicesPool.free(polygonIndices); + } + polygon = this.polygonPool.obtain(); + polygon.length = 0; + polygon.push(x1); + polygon.push(y1); + polygon.push(x2); + polygon.push(y2); + polygon.push(x3); + polygon.push(y3); + polygonIndices = this.polygonIndicesPool.obtain(); + polygonIndices.length = 0; + polygonIndices.push(t1); + polygonIndices.push(t2); + polygonIndices.push(t3); + lastWinding = Triangulator.winding(x1, y1, x2, y2, x3, y3); + fanBaseIndex = t1; + } + } + + if (polygon.length > 0) { + convexPolygons.push(polygon); + convexPolygonsIndices.push(polygonIndices); + } + + // Go through the list of polygons and try to merge the remaining triangles with the found triangle fans. + for (let i = 0, n = convexPolygons.length; i < n; i++) { + polygonIndices = convexPolygonsIndices[i]; + if (polygonIndices.length == 0) continue; + const firstIndex = polygonIndices[0]; + const lastIndex = polygonIndices[polygonIndices.length - 1]; + + polygon = convexPolygons[i]; + const o = polygon.length - 4; + let prevPrevX = polygon[o], + prevPrevY = polygon[o + 1]; + let prevX = polygon[o + 2], + prevY = polygon[o + 3]; + const firstX = polygon[0], + firstY = polygon[1]; + const secondX = polygon[2], + secondY = polygon[3]; + const winding = Triangulator.winding(prevPrevX, prevPrevY, prevX, prevY, firstX, firstY); + + for (let ii = 0; ii < n; ii++) { + if (ii == i) continue; + const otherIndices = convexPolygonsIndices[ii]; + if (otherIndices.length != 3) continue; + const otherFirstIndex = otherIndices[0]; + const otherSecondIndex = otherIndices[1]; + const otherLastIndex = otherIndices[2]; + + const otherPoly = convexPolygons[ii]; + const x3 = otherPoly[otherPoly.length - 2], + y3 = otherPoly[otherPoly.length - 1]; + + if (otherFirstIndex != firstIndex || otherSecondIndex != lastIndex) continue; + const winding1 = Triangulator.winding(prevPrevX, prevPrevY, prevX, prevY, x3, y3); + const winding2 = Triangulator.winding(x3, y3, firstX, firstY, secondX, secondY); + if (winding1 == winding && winding2 == winding) { + otherPoly.length = 0; + otherIndices.length = 0; + polygon.push(x3); + polygon.push(y3); + polygonIndices.push(otherLastIndex); + prevPrevX = prevX; + prevPrevY = prevY; + prevX = x3; + prevY = y3; + ii = 0; + } + } + } + + // Remove empty polygons that resulted from the merge step above. + for (let i = convexPolygons.length - 1; i >= 0; i--) { + polygon = convexPolygons[i]; + if (polygon.length == 0) { + convexPolygons.splice(i, 1); + this.polygonPool.free(polygon); + polygonIndices = convexPolygonsIndices[i]; + convexPolygonsIndices.splice(i, 1); + this.polygonIndicesPool.free(polygonIndices); + } + } + + return convexPolygons; + } + + private static isConcave( + index: number, + vertexCount: number, + vertices: ArrayLike, + indices: ArrayLike + ): boolean { + const previous = indices[(vertexCount + index - 1) % vertexCount] << 1; + const current = indices[index] << 1; + const next = indices[(index + 1) % vertexCount] << 1; + return !this.positiveArea( + vertices[previous], + vertices[previous + 1], + vertices[current], + vertices[current + 1], + vertices[next], + vertices[next + 1] + ); + } + + private static positiveArea(p1x: number, p1y: number, p2x: number, p2y: number, p3x: number, p3y: number): boolean { + return p1x * (p3y - p2y) + p2x * (p1y - p3y) + p3x * (p2y - p1y) >= 0; + } + + private static winding(p1x: number, p1y: number, p2x: number, p2y: number, p3x: number, p3y: number): number { + const px = p2x - p1x, + py = p2y - p1y; + return p3x * py - p3y * px + px * p1y - p1x * py >= 0 ? 1 : -1; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/Updatable.ts b/packages/spine-core-3.8/src/spine-core/Updatable.ts new file mode 100644 index 0000000000..64e1965de1 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Updatable.ts @@ -0,0 +1,10 @@ +/** The interface for items updated by {@link Skeleton#updateWorldTransform()}. */ +export interface Updatable { + update(): void; + + /** Returns false when this item has not been updated because a skin is required and the {@link Skeleton#skin active skin} + * does not contain this item. + * @see Skin#getBones() + * @see Skin#getConstraints() */ + isActive(): boolean; +} diff --git a/packages/spine-core-3.8/src/spine-core/Utils.ts b/packages/spine-core-3.8/src/spine-core/Utils.ts new file mode 100644 index 0000000000..510e4cbba4 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/Utils.ts @@ -0,0 +1,417 @@ +import { MixBlend } from "./Animation"; +import { Skeleton } from "./Skeleton"; + +export interface Map { + [key: string]: T; +} + +export class IntSet { + array = new Array(); + + add(value: number): boolean { + const contains = this.contains(value); + this.array[value | 0] = value | 0; + return !contains; + } + + contains(value: number) { + return this.array[value | 0] != undefined; + } + + remove(value: number) { + this.array[value | 0] = undefined; + } + + clear() { + this.array.length = 0; + } +} + +export interface Disposable { + dispose(): void; +} + +export interface Restorable { + restore(): void; +} + +export class Color { + public static WHITE = new Color(1, 1, 1, 1); + public static RED = new Color(1, 0, 0, 1); + public static GREEN = new Color(0, 1, 0, 1); + public static BLUE = new Color(0, 0, 1, 1); + public static MAGENTA = new Color(1, 0, 1, 1); + + constructor( + public r: number = 0, + public g: number = 0, + public b: number = 0, + public a: number = 0 + ) {} + + set(r: number, g: number, b: number, a: number) { + this.r = r; + this.g = g; + this.b = b; + this.a = a; + this.clamp(); + return this; + } + + setFromColor(c: Color) { + this.r = c.r; + this.g = c.g; + this.b = c.b; + this.a = c.a; + return this; + } + + setFromString(hex: string) { + hex = hex.charAt(0) == "#" ? hex.substr(1) : hex; + this.r = parseInt(hex.substr(0, 2), 16) / 255.0; + this.g = parseInt(hex.substr(2, 2), 16) / 255.0; + this.b = parseInt(hex.substr(4, 2), 16) / 255.0; + this.a = (hex.length != 8 ? 255 : parseInt(hex.substr(6, 2), 16)) / 255.0; + return this; + } + + add(r: number, g: number, b: number, a: number) { + this.r += r; + this.g += g; + this.b += b; + this.a += a; + this.clamp(); + return this; + } + + clamp() { + if (this.r < 0) this.r = 0; + else if (this.r > 1) this.r = 1; + + if (this.g < 0) this.g = 0; + else if (this.g > 1) this.g = 1; + + if (this.b < 0) this.b = 0; + else if (this.b > 1) this.b = 1; + + if (this.a < 0) this.a = 0; + else if (this.a > 1) this.a = 1; + return this; + } + + static rgba8888ToColor(color: Color, value: number) { + color.r = ((value & 0xff000000) >>> 24) / 255; + color.g = ((value & 0x00ff0000) >>> 16) / 255; + color.b = ((value & 0x0000ff00) >>> 8) / 255; + color.a = (value & 0x000000ff) / 255; + } + + static rgb888ToColor(color: Color, value: number) { + color.r = ((value & 0x00ff0000) >>> 16) / 255; + color.g = ((value & 0x0000ff00) >>> 8) / 255; + color.b = (value & 0x000000ff) / 255; + } +} + +export class MathUtils { + static PI = 3.1415927; + static PI2 = MathUtils.PI * 2; + static radiansToDegrees = 180 / MathUtils.PI; + static radDeg = MathUtils.radiansToDegrees; + static degreesToRadians = MathUtils.PI / 180; + static degRad = MathUtils.degreesToRadians; + + static clamp(value: number, min: number, max: number) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + static cosDeg(degrees: number) { + return Math.cos(degrees * MathUtils.degRad); + } + + static sinDeg(degrees: number) { + return Math.sin(degrees * MathUtils.degRad); + } + + static signum(value: number): number { + return value > 0 ? 1 : value < 0 ? -1 : 0; + } + + static toInt(x: number) { + return x > 0 ? Math.floor(x) : Math.ceil(x); + } + + static cbrt(x: number) { + const y = Math.pow(Math.abs(x), 1 / 3); + return x < 0 ? -y : y; + } + + static randomTriangular(min: number, max: number): number { + return MathUtils.randomTriangularWith(min, max, (min + max) * 0.5); + } + + static randomTriangularWith(min: number, max: number, mode: number): number { + const u = Math.random(); + const d = max - min; + if (u <= (mode - min) / d) return min + Math.sqrt(u * d * (mode - min)); + return max - Math.sqrt((1 - u) * d * (max - mode)); + } +} + +export abstract class Interpolation { + protected abstract applyInternal(a: number): number; + apply(start: number, end: number, a: number): number { + return start + (end - start) * this.applyInternal(a); + } +} + +export class Pow extends Interpolation { + protected power = 2; + + constructor(power: number) { + super(); + this.power = power; + } + + applyInternal(a: number): number { + if (a <= 0.5) return Math.pow(a * 2, this.power) / 2; + return Math.pow((a - 1) * 2, this.power) / (this.power % 2 == 0 ? -2 : 2) + 1; + } +} + +export class PowOut extends Pow { + constructor(power: number) { + super(power); + } + + override applyInternal(a: number): number { + return Math.pow(a - 1, this.power) * (this.power % 2 == 0 ? -1 : 1) + 1; + } +} + +export class Utils { + static SUPPORTS_TYPED_ARRAYS = typeof Float32Array !== "undefined"; + + static arrayCopy( + source: ArrayLike, + sourceStart: number, + dest: ArrayLike, + destStart: number, + numElements: number + ) { + for (let i = sourceStart, j = destStart; i < sourceStart + numElements; i++, j++) { + dest[j] = source[i]; + } + } + + static setArraySize(array: Array, size: number, value: any = 0): Array { + const oldSize = array.length; + if (oldSize == size) return array; + array.length = size; + if (oldSize < size) { + for (let i = oldSize; i < size; i++) array[i] = value; + } + return array; + } + + static ensureArrayCapacity(array: Array, size: number, value: any = 0): Array { + if (array.length >= size) return array; + return Utils.setArraySize(array, size, value); + } + + static newArray(size: number, defaultValue: T): Array { + const array = new Array(size); + for (let i = 0; i < size; i++) array[i] = defaultValue; + return array; + } + + static newFloatArray(size: number): ArrayLike { + if (Utils.SUPPORTS_TYPED_ARRAYS) { + return new Float32Array(size); + } else { + const array = new Array(size); + for (let i = 0; i < array.length; i++) array[i] = 0; + return array; + } + } + + static newShortArray(size: number): ArrayLike { + if (Utils.SUPPORTS_TYPED_ARRAYS) { + return new Int16Array(size); + } else { + const array = new Array(size); + for (let i = 0; i < array.length; i++) array[i] = 0; + return array; + } + } + + static toFloatArray(array: Array) { + return Utils.SUPPORTS_TYPED_ARRAYS ? new Float32Array(array) : array; + } + + static toSinglePrecision(value: number) { + return Utils.SUPPORTS_TYPED_ARRAYS ? Math.fround(value) : value; + } + + // This function is used to fix WebKit 602 specific issue described at http://esotericsoftware.com/forum/iOS-10-disappearing-graphics-10109 + static webkit602BugfixHelper(alpha: number, blend: MixBlend) {} + + static contains(array: Array, element: T, identity = true) { + for (let i = 0; i < array.length; i++) { + if (array[i] == element) return true; + } + return false; + } +} + +export class DebugUtils { + static logBones(skeleton: Skeleton) { + for (let i = 0; i < skeleton.bones.length; i++) { + const bone = skeleton.bones[i]; + console.log( + bone.data.name + + ", " + + bone.a + + ", " + + bone.b + + ", " + + bone.c + + ", " + + bone.d + + ", " + + bone.worldX + + ", " + + bone.worldY + ); + } + } +} + +export class Pool { + private items = new Array(); + private instantiator: () => T; + + constructor(instantiator: () => T) { + this.instantiator = instantiator; + } + + obtain() { + return this.items.length > 0 ? this.items.pop() : this.instantiator(); + } + + free(item: T) { + if ((item as any).reset) (item as any).reset(); + this.items.push(item); + } + + freeAll(items: ArrayLike) { + for (let i = 0; i < items.length; i++) { + this.free(items[i]); + } + } + + clear() { + this.items.length = 0; + } +} + +export class Vector2 { + constructor( + public x = 0, + public y = 0 + ) {} + + set(x: number, y: number): Vector2 { + this.x = x; + this.y = y; + return this; + } + + length() { + const x = this.x; + const y = this.y; + return Math.sqrt(x * x + y * y); + } + + normalize() { + const len = this.length(); + if (len != 0) { + this.x /= len; + this.y /= len; + } + return this; + } +} + +export class TimeKeeper { + maxDelta = 0.064; + framesPerSecond = 0; + delta = 0; + totalTime = 0; + + private lastTime = Date.now() / 1000; + private frameCount = 0; + private frameTime = 0; + + update() { + const now = Date.now() / 1000; + this.delta = now - this.lastTime; + this.frameTime += this.delta; + this.totalTime += this.delta; + if (this.delta > this.maxDelta) this.delta = this.maxDelta; + this.lastTime = now; + + this.frameCount++; + if (this.frameTime > 1) { + this.framesPerSecond = this.frameCount / this.frameTime; + this.frameTime = 0; + this.frameCount = 0; + } + } +} + +export interface ArrayLike { + length: number; + [n: number]: T; +} + +export class WindowedMean { + values: Array; + addedValues = 0; + lastValue = 0; + mean = 0; + dirty = true; + + constructor(windowSize: number = 32) { + this.values = new Array(windowSize); + } + + hasEnoughData() { + return this.addedValues >= this.values.length; + } + + addValue(value: number) { + if (this.addedValues < this.values.length) this.addedValues++; + this.values[this.lastValue++] = value; + if (this.lastValue > this.values.length - 1) this.lastValue = 0; + this.dirty = true; + } + + getMean() { + if (this.hasEnoughData()) { + if (this.dirty) { + let mean = 0; + for (let i = 0; i < this.values.length; i++) { + mean += this.values[i]; + } + this.mean = mean / this.values.length; + this.dirty = false; + } + return this.mean; + } else { + return 0; + } + } +} diff --git a/packages/spine-core-3.8/src/spine-core/VertexEffect.ts b/packages/spine-core-3.8/src/spine-core/VertexEffect.ts new file mode 100644 index 0000000000..ae23cd16d2 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/VertexEffect.ts @@ -0,0 +1,8 @@ +import { Skeleton } from "./Skeleton"; +import { Color, Vector2 } from "./Utils"; + +export interface VertexEffect { + begin(skeleton: Skeleton): void; + transform(position: Vector2, uv: Vector2, light: Color, dark: Color): void; + end(): void; +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/Attachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/Attachment.ts new file mode 100644 index 0000000000..e13cf7b936 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/Attachment.ts @@ -0,0 +1,147 @@ +import { Slot } from "../Slot"; +import { Utils, ArrayLike } from "../Utils"; + +/** The base class for all attachments. */ +export abstract class Attachment { + name: string; + + constructor(name: string) { + if (name == null) throw new Error("name cannot be null."); + this.name = name; + } + + abstract copy(): Attachment; +} + +/** Base class for an attachment with vertices that are transformed by one or more bones and can be deformed by a slot's + * {@link Slot#deform}. */ +export abstract class VertexAttachment extends Attachment { + private static nextID = 0; + + /** The unique ID for this attachment. */ + id = (VertexAttachment.nextID++ & 65535) << 11; + + /** The bones which affect the {@link #getVertices()}. The array entries are, for each vertex, the number of bones affecting + * the vertex followed by that many bone indices, which is the index of the bone in {@link Skeleton#bones}. Will be null + * if this attachment has no weights. */ + bones: Array; + + /** The vertex positions in the bone's coordinate system. For a non-weighted attachment, the values are `x,y` + * entries for each vertex. For a weighted attachment, the values are `x,y,weight` entries for each bone affecting + * each vertex. */ + vertices: ArrayLike; + + /** The maximum number of world vertex values that can be output by + * {@link #computeWorldVertices()} using the `count` parameter. */ + worldVerticesLength = 0; + + /** Deform keys for the deform attachment are also applied to this attachment. May be null if no deform keys should be applied. */ + deformAttachment: VertexAttachment = this; + + constructor(name: string) { + super(name); + } + + /** Transforms the attachment's local {@link vertices} to world coordinates. If the slot's {@link Slot#deform} is + * not empty, it is used to deform the vertices. + * + * See [World transforms](http://esotericsoftware.com/spine-runtime-skeletons#World-transforms) in the Spine + * Runtimes Guide. + * @param start The index of the first {@link #vertices} value to transform. Each vertex has 2 values, x and y. + * @param count The number of world vertex values to output. Must be <= {@link #worldVerticesLength} - `start`. + * @param worldVertices The output world vertices. Must have a length >= `offset` + `count` * + * `stride` / 2. + * @param offset The `worldVertices` index to begin writing values. + * @param stride The number of `worldVertices` entries between the value pairs written. */ + computeWorldVertices( + slot: Slot, + start: number, + count: number, + worldVertices: ArrayLike, + offset: number, + stride: number + ) { + count = offset + (count >> 1) * stride; + const skeleton = slot.bone.skeleton; + const deformArray = slot.deform; + let vertices = this.vertices; + const bones = this.bones; + if (bones == null) { + if (deformArray.length > 0) vertices = deformArray; + const bone = slot.bone; + const x = bone.worldX; + const y = bone.worldY; + const a = bone.a, + b = bone.b, + c = bone.c, + d = bone.d; + for (let v = start, w = offset; w < count; v += 2, w += stride) { + const vx = vertices[v], + vy = vertices[v + 1]; + worldVertices[w] = vx * a + vy * b + x; + worldVertices[w + 1] = vx * c + vy * d + y; + } + return; + } + let v = 0, + skip = 0; + for (let i = 0; i < start; i += 2) { + const n = bones[v]; + v += n + 1; + skip += n; + } + const skeletonBones = skeleton.bones; + if (deformArray.length == 0) { + for (let w = offset, b = skip * 3; w < count; w += stride) { + let wx = 0, + wy = 0; + let n = bones[v++]; + n += v; + for (; v < n; v++, b += 3) { + const bone = skeletonBones[bones[v]]; + const vx = vertices[b], + vy = vertices[b + 1], + weight = vertices[b + 2]; + wx += (vx * bone.a + vy * bone.b + bone.worldX) * weight; + wy += (vx * bone.c + vy * bone.d + bone.worldY) * weight; + } + worldVertices[w] = wx; + worldVertices[w + 1] = wy; + } + } else { + const deform = deformArray; + for (let w = offset, b = skip * 3, f = skip << 1; w < count; w += stride) { + let wx = 0, + wy = 0; + let n = bones[v++]; + n += v; + for (; v < n; v++, b += 3, f += 2) { + const bone = skeletonBones[bones[v]]; + const vx = vertices[b] + deform[f], + vy = vertices[b + 1] + deform[f + 1], + weight = vertices[b + 2]; + wx += (vx * bone.a + vy * bone.b + bone.worldX) * weight; + wy += (vx * bone.c + vy * bone.d + bone.worldY) * weight; + } + worldVertices[w] = wx; + worldVertices[w + 1] = wy; + } + } + } + + /** Does not copy id (generated) or name (set on construction). **/ + copyTo(attachment: VertexAttachment) { + if (this.bones != null) { + attachment.bones = new Array(this.bones.length); + Utils.arrayCopy(this.bones, 0, attachment.bones, 0, this.bones.length); + } else attachment.bones = null; + + if (this.vertices != null) { + attachment.vertices = Utils.newFloatArray(this.vertices.length); + Utils.arrayCopy(this.vertices, 0, attachment.vertices, 0, this.vertices.length); + } else attachment.vertices = null; + + attachment.worldVerticesLength = this.worldVerticesLength; + attachment.deformAttachment = this.deformAttachment; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/AttachmentLoader.ts b/packages/spine-core-3.8/src/spine-core/attachments/AttachmentLoader.ts new file mode 100644 index 0000000000..7d14c9f22c --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/AttachmentLoader.ts @@ -0,0 +1,31 @@ +import { RegionAttachment } from "./RegionAttachment"; +import { Skin } from "../Skin"; +import { BoundingBoxAttachment } from "./BoundingBoxAttachment"; +import { PathAttachment } from "./PathAttachment"; +import { PointAttachment } from "./PointAttachment"; +import { ClippingAttachment } from "./ClippingAttachment"; +import { MeshAttachment } from "./MeshAttachment"; + +/** The interface which can be implemented to customize creating and populating attachments. + * + * See [Loading skeleton data](http://esotericsoftware.com/spine-loading-skeleton-data#AttachmentLoader) in the Spine + * Runtimes Guide. */ +export interface AttachmentLoader { + /** @return May be null to not load an attachment. */ + newRegionAttachment(skin: Skin, name: string, path: string): RegionAttachment; + + /** @return May be null to not load an attachment. */ + newMeshAttachment(skin: Skin, name: string, path: string): MeshAttachment; + + /** @return May be null to not load an attachment. */ + newBoundingBoxAttachment(skin: Skin, name: string): BoundingBoxAttachment; + + /** @return May be null to not load an attachment */ + newPathAttachment(skin: Skin, name: string): PathAttachment; + + /** @return May be null to not load an attachment */ + newPointAttachment(skin: Skin, name: string): PointAttachment; + + /** @return May be null to not load an attachment */ + newClippingAttachment(skin: Skin, name: string): ClippingAttachment; +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/AttachmentType.ts b/packages/spine-core-3.8/src/spine-core/attachments/AttachmentType.ts new file mode 100644 index 0000000000..193fa62007 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/AttachmentType.ts @@ -0,0 +1,9 @@ +export enum AttachmentType { + Region, + BoundingBox, + Mesh, + LinkedMesh, + Path, + Point, + Clipping +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/BoundingBoxAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/BoundingBoxAttachment.ts new file mode 100644 index 0000000000..b23cec9733 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/BoundingBoxAttachment.ts @@ -0,0 +1,22 @@ +import { VertexAttachment, Attachment } from "./Attachment"; +import { Color } from "../Utils"; + +/** An attachment with vertices that make up a polygon. Can be used for hit detection, creating physics bodies, spawning particle + * effects, and more. + * + * See {@link SkeletonBounds} and [Bounding Boxes](http://esotericsoftware.com/spine-bounding-boxes) in the Spine User + * Guide. */ +export class BoundingBoxAttachment extends VertexAttachment { + color = new Color(1, 1, 1, 1); + + constructor(name: string) { + super(name); + } + + copy(): Attachment { + const copy = new BoundingBoxAttachment(this.name); + this.copyTo(copy); + copy.color.setFromColor(this.color); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/ClippingAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/ClippingAttachment.ts new file mode 100644 index 0000000000..8ebc184346 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/ClippingAttachment.ts @@ -0,0 +1,27 @@ +import { VertexAttachment, Attachment } from "./Attachment"; +import { SlotData } from "../SlotData"; +import { Color } from "../Utils"; + +/** An attachment with vertices that make up a polygon used for clipping the rendering of other attachments. */ +export class ClippingAttachment extends VertexAttachment { + /** Clipping is performed between the clipping polygon's slot and the end slot. Returns null if clipping is done until the end of + * the skeleton's rendering. */ + endSlot: SlotData; + + // Nonessential. + /** The color of the clipping polygon as it was in Spine. Available only when nonessential data was exported. Clipping polygons + * are not usually rendered at runtime. */ + color = new Color(0.2275, 0.2275, 0.8078, 1); // ce3a3aff + + constructor(name: string) { + super(name); + } + + copy(): Attachment { + const copy = new ClippingAttachment(this.name); + this.copyTo(copy); + copy.endSlot = this.endSlot; + copy.color.setFromColor(this.color); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/MeshAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/MeshAttachment.ts new file mode 100644 index 0000000000..02deaa68ff --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/MeshAttachment.ts @@ -0,0 +1,175 @@ +import { VertexAttachment, Attachment } from "./Attachment"; +import { TextureRegion } from "../Texture"; +import { Color, Utils, ArrayLike } from "../Utils"; +import { TextureAtlasRegion } from "../TextureAtlas"; + +/** An attachment that displays a textured mesh. A mesh has hull vertices and internal vertices within the hull. Holes are not + * supported. Each vertex has UVs (texture coordinates) and triangles are used to map an image on to the mesh. + * + * See [Mesh attachments](http://esotericsoftware.com/spine-meshes) in the Spine User Guide. */ +export class MeshAttachment extends VertexAttachment { + region: TextureRegion; + + /** The name of the texture region for this attachment. */ + path: string; + + /** The UV pair for each vertex, normalized within the texture region. */ + regionUVs: ArrayLike; + + /** The UV pair for each vertex, normalized within the entire texture. + * + * See {@link #updateUVs}. */ + uvs: ArrayLike; + + /** Triplets of vertex indices which describe the mesh's triangulation. */ + triangles: Array; + + /** The color to tint the mesh. */ + color = new Color(1, 1, 1, 1); + + /** The width of the mesh's image. Available only when nonessential data was exported. */ + width: number; + + /** The height of the mesh's image. Available only when nonessential data was exported. */ + height: number; + + /** The number of entries at the beginning of {@link #vertices} that make up the mesh hull. */ + hullLength: number; + + /** Vertex index pairs describing edges for controling triangulation. Mesh triangles will never cross edges. Only available if + * nonessential data was exported. Triangulation is not performed at runtime. */ + edges: Array; + + private parentMesh: MeshAttachment; + tempColor = new Color(0, 0, 0, 0); + + constructor(name: string) { + super(name); + } + + /** Calculates {@link #uvs} using {@link #regionUVs} and the {@link #region}. Must be called after changing the region UVs or + * region. */ + updateUVs() { + const regionUVs = this.regionUVs; + if (this.uvs == null || this.uvs.length != regionUVs.length) this.uvs = Utils.newFloatArray(regionUVs.length); + const uvs = this.uvs; + const n = this.uvs.length; + let u = this.region.u, + v = this.region.v, + width = 0, + height = 0; + if (this.region instanceof TextureAtlasRegion) { + const region = this.region; + const textureWidth = region.texture.width, + textureHeight = region.texture.height; + switch (region.degrees) { + case 90: + u -= (region.originalHeight - region.offsetY - region.height) / textureWidth; + v -= (region.originalWidth - region.offsetX - region.width) / textureHeight; + width = region.originalHeight / textureWidth; + height = region.originalWidth / textureHeight; + for (let i = 0; i < n; i += 2) { + uvs[i] = u + regionUVs[i + 1] * width; + uvs[i + 1] = v + (1 - regionUVs[i]) * height; + } + return; + case 180: + u -= (region.originalWidth - region.offsetX - region.width) / textureWidth; + v -= region.offsetY / textureHeight; + width = region.originalWidth / textureWidth; + height = region.originalHeight / textureHeight; + for (let i = 0; i < n; i += 2) { + uvs[i] = u + (1 - regionUVs[i]) * width; + uvs[i + 1] = v + (1 - regionUVs[i + 1]) * height; + } + return; + case 270: + u -= region.offsetY / textureWidth; + v -= region.offsetX / textureHeight; + width = region.originalHeight / textureWidth; + height = region.originalWidth / textureHeight; + for (let i = 0; i < n; i += 2) { + uvs[i] = u + (1 - regionUVs[i + 1]) * width; + uvs[i + 1] = v + regionUVs[i] * height; + } + return; + } + u -= region.offsetX / textureWidth; + v -= (region.originalHeight - region.offsetY - region.height) / textureHeight; + width = region.originalWidth / textureWidth; + height = region.originalHeight / textureHeight; + } else if (this.region == null) { + u = v = 0; + width = height = 1; + } else { + width = this.region.u2 - u; + height = this.region.v2 - v; + } + + for (let i = 0; i < n; i += 2) { + uvs[i] = u + regionUVs[i] * width; + uvs[i + 1] = v + regionUVs[i + 1] * height; + } + } + + /** The parent mesh if this is a linked mesh, else null. A linked mesh shares the {@link #bones}, {@link #vertices}, + * {@link #regionUVs}, {@link #triangles}, {@link #hullLength}, {@link #edges}, {@link #width}, and {@link #height} with the + * parent mesh, but may have a different {@link #name} or {@link #path} (and therefore a different texture). */ + getParentMesh() { + return this.parentMesh; + } + + /** @param parentMesh May be null. */ + setParentMesh(parentMesh: MeshAttachment) { + this.parentMesh = parentMesh; + if (parentMesh != null) { + this.bones = parentMesh.bones; + this.vertices = parentMesh.vertices; + this.worldVerticesLength = parentMesh.worldVerticesLength; + this.regionUVs = parentMesh.regionUVs; + this.triangles = parentMesh.triangles; + this.hullLength = parentMesh.hullLength; + this.worldVerticesLength = parentMesh.worldVerticesLength; + } + } + + copy(): Attachment { + if (this.parentMesh != null) return this.newLinkedMesh(); + + const copy = new MeshAttachment(this.name); + copy.region = this.region; + copy.path = this.path; + copy.color.setFromColor(this.color); + + this.copyTo(copy); + copy.regionUVs = new Array(this.regionUVs.length); + Utils.arrayCopy(this.regionUVs, 0, copy.regionUVs, 0, this.regionUVs.length); + copy.uvs = new Array(this.uvs.length); + Utils.arrayCopy(this.uvs, 0, copy.uvs, 0, this.uvs.length); + copy.triangles = new Array(this.triangles.length); + Utils.arrayCopy(this.triangles, 0, copy.triangles, 0, this.triangles.length); + copy.hullLength = this.hullLength; + + // Nonessential. + if (this.edges != null) { + copy.edges = new Array(this.edges.length); + Utils.arrayCopy(this.edges, 0, copy.edges, 0, this.edges.length); + } + copy.width = this.width; + copy.height = this.height; + + return copy; + } + + /** Returns a new mesh with the {@link #parentMesh} set to this mesh's parent mesh, if any, else to this mesh. **/ + newLinkedMesh(): MeshAttachment { + const copy = new MeshAttachment(this.name); + copy.region = this.region; + copy.path = this.path; + copy.color.setFromColor(this.color); + copy.deformAttachment = this.deformAttachment; + copy.setParentMesh(this.parentMesh != null ? this.parentMesh : this); + copy.updateUVs(); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/PathAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/PathAttachment.ts new file mode 100644 index 0000000000..53562b7657 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/PathAttachment.ts @@ -0,0 +1,36 @@ +import { VertexAttachment, Attachment } from "./Attachment"; +import { Color, Utils } from "../Utils"; + +/** An attachment whose vertices make up a composite Bezier curve. + * + * See {@link PathConstraint} and [Paths](http://esotericsoftware.com/spine-paths) in the Spine User Guide. */ +export class PathAttachment extends VertexAttachment { + /** The lengths along the path in the setup pose from the start of the path to the end of each Bezier curve. */ + lengths: Array; + + /** If true, the start and end knots are connected. */ + closed = false; + + /** If true, additional calculations are performed to make calculating positions along the path more accurate. If false, fewer + * calculations are performed but calculating positions along the path is less accurate. */ + constantSpeed = false; + + /** The color of the path as it was in Spine. Available only when nonessential data was exported. Paths are not usually + * rendered at runtime. */ + color = new Color(1, 1, 1, 1); + + constructor(name: string) { + super(name); + } + + copy(): Attachment { + const copy = new PathAttachment(this.name); + this.copyTo(copy); + copy.lengths = new Array(this.lengths.length); + Utils.arrayCopy(this.lengths, 0, copy.lengths, 0, this.lengths.length); + copy.closed = closed; + copy.constantSpeed = this.constantSpeed; + copy.color.setFromColor(this.color); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/PointAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/PointAttachment.ts new file mode 100644 index 0000000000..74de51a2ef --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/PointAttachment.ts @@ -0,0 +1,46 @@ +import { VertexAttachment, Attachment } from "./Attachment"; +import { Color, MathUtils, Vector2 } from "../Utils"; +import { Bone } from "../Bone"; + +/** An attachment which is a single point and a rotation. This can be used to spawn projectiles, particles, etc. A bone can be + * used in similar ways, but a PointAttachment is slightly less expensive to compute and can be hidden, shown, and placed in a + * skin. + * + * See [Point Attachments](http://esotericsoftware.com/spine-point-attachments) in the Spine User Guide. */ +export class PointAttachment extends VertexAttachment { + x: number; + y: number; + rotation: number; + + /** The color of the point attachment as it was in Spine. Available only when nonessential data was exported. Point attachments + * are not usually rendered at runtime. */ + color = new Color(0.38, 0.94, 0, 1); + + constructor(name: string) { + super(name); + this.name = name; + } + + computeWorldPosition(bone: Bone, point: Vector2) { + point.x = this.x * bone.a + this.y * bone.b + bone.worldX; + point.y = this.x * bone.c + this.y * bone.d + bone.worldY; + return point; + } + + computeWorldRotation(bone: Bone) { + const cos = MathUtils.cosDeg(this.rotation), + sin = MathUtils.sinDeg(this.rotation); + const x = cos * bone.a + sin * bone.b; + const y = cos * bone.c + sin * bone.d; + return Math.atan2(y, x) * MathUtils.radDeg; + } + + copy(): Attachment { + const copy = new PointAttachment(this.name); + copy.x = this.x; + copy.y = this.y; + copy.rotation = this.rotation; + copy.color.setFromColor(this.color); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/attachments/RegionAttachment.ts b/packages/spine-core-3.8/src/spine-core/attachments/RegionAttachment.ts new file mode 100644 index 0000000000..1730e3880a --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/attachments/RegionAttachment.ts @@ -0,0 +1,211 @@ +import { Attachment } from "./Attachment"; +import { Color, Utils, ArrayLike } from "../Utils"; +import { TextureRegion } from "../Texture"; +import { Bone } from "../Bone"; + +/** An attachment that displays a textured quadrilateral. + * + * See [Region attachments](http://esotericsoftware.com/spine-regions) in the Spine User Guide. */ +export class RegionAttachment extends Attachment { + static OX1 = 0; + static OY1 = 1; + static OX2 = 2; + static OY2 = 3; + static OX3 = 4; + static OY3 = 5; + static OX4 = 6; + static OY4 = 7; + + static X1 = 0; + static Y1 = 1; + static C1R = 2; + static C1G = 3; + static C1B = 4; + static C1A = 5; + static U1 = 6; + static V1 = 7; + + static X2 = 8; + static Y2 = 9; + static C2R = 10; + static C2G = 11; + static C2B = 12; + static C2A = 13; + static U2 = 14; + static V2 = 15; + + static X3 = 16; + static Y3 = 17; + static C3R = 18; + static C3G = 19; + static C3B = 20; + static C3A = 21; + static U3 = 22; + static V3 = 23; + + static X4 = 24; + static Y4 = 25; + static C4R = 26; + static C4G = 27; + static C4B = 28; + static C4A = 29; + static U4 = 30; + static V4 = 31; + + /** The local x translation. */ + x = 0; + + /** The local y translation. */ + y = 0; + + /** The local scaleX. */ + scaleX = 1; + + /** The local scaleY. */ + scaleY = 1; + + /** The local rotation. */ + rotation = 0; + + /** The width of the region attachment in Spine. */ + width = 0; + + /** The height of the region attachment in Spine. */ + height = 0; + + /** The color to tint the region attachment. */ + color = new Color(1, 1, 1, 1); + + /** The name of the texture region for this attachment. */ + path: string; + + rendererObject: any; + region: TextureRegion; + + /** For each of the 4 vertices, a pair of x,y values that is the local position of the vertex. + * + * See {@link #updateOffset()}. */ + offset = Utils.newFloatArray(8); + + uvs = Utils.newFloatArray(8); + + tempColor = new Color(1, 1, 1, 1); + + constructor(name: string) { + super(name); + } + + /** Calculates the {@link #offset} using the region settings. Must be called after changing region settings. */ + updateOffset(): void { + const regionScaleX = (this.width / this.region.originalWidth) * this.scaleX; + const regionScaleY = (this.height / this.region.originalHeight) * this.scaleY; + const localX = (-this.width / 2) * this.scaleX + this.region.offsetX * regionScaleX; + const localY = (-this.height / 2) * this.scaleY + this.region.offsetY * regionScaleY; + const localX2 = localX + this.region.width * regionScaleX; + const localY2 = localY + this.region.height * regionScaleY; + const radians = (this.rotation * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const localXCos = localX * cos + this.x; + const localXSin = localX * sin; + const localYCos = localY * cos + this.y; + const localYSin = localY * sin; + const localX2Cos = localX2 * cos + this.x; + const localX2Sin = localX2 * sin; + const localY2Cos = localY2 * cos + this.y; + const localY2Sin = localY2 * sin; + const offset = this.offset; + offset[RegionAttachment.OX1] = localXCos - localYSin; + offset[RegionAttachment.OY1] = localYCos + localXSin; + offset[RegionAttachment.OX2] = localXCos - localY2Sin; + offset[RegionAttachment.OY2] = localY2Cos + localXSin; + offset[RegionAttachment.OX3] = localX2Cos - localY2Sin; + offset[RegionAttachment.OY3] = localY2Cos + localX2Sin; + offset[RegionAttachment.OX4] = localX2Cos - localYSin; + offset[RegionAttachment.OY4] = localYCos + localX2Sin; + } + + setRegion(region: TextureRegion): void { + this.region = region; + const uvs = this.uvs; + if (region.rotate) { + uvs[2] = region.u; + uvs[3] = region.v2; + uvs[4] = region.u; + uvs[5] = region.v; + uvs[6] = region.u2; + uvs[7] = region.v; + uvs[0] = region.u2; + uvs[1] = region.v2; + } else { + uvs[0] = region.u; + uvs[1] = region.v2; + uvs[2] = region.u; + uvs[3] = region.v; + uvs[4] = region.u2; + uvs[5] = region.v; + uvs[6] = region.u2; + uvs[7] = region.v2; + } + } + + /** Transforms the attachment's four vertices to world coordinates. + * + * See [World transforms](http://esotericsoftware.com/spine-runtime-skeletons#World-transforms) in the Spine + * Runtimes Guide. + * @param worldVertices The output world vertices. Must have a length >= `offset` + 8. + * @param offset The `worldVertices` index to begin writing values. + * @param stride The number of `worldVertices` entries between the value pairs written. */ + computeWorldVertices(bone: Bone, worldVertices: ArrayLike, offset: number, stride: number) { + const vertexOffset = this.offset; + const x = bone.worldX, + y = bone.worldY; + const a = bone.a, + b = bone.b, + c = bone.c, + d = bone.d; + let offsetX = 0, + offsetY = 0; + + offsetX = vertexOffset[RegionAttachment.OX1]; + offsetY = vertexOffset[RegionAttachment.OY1]; + worldVertices[offset] = offsetX * a + offsetY * b + x; // br + worldVertices[offset + 1] = offsetX * c + offsetY * d + y; + offset += stride; + + offsetX = vertexOffset[RegionAttachment.OX2]; + offsetY = vertexOffset[RegionAttachment.OY2]; + worldVertices[offset] = offsetX * a + offsetY * b + x; // bl + worldVertices[offset + 1] = offsetX * c + offsetY * d + y; + offset += stride; + + offsetX = vertexOffset[RegionAttachment.OX3]; + offsetY = vertexOffset[RegionAttachment.OY3]; + worldVertices[offset] = offsetX * a + offsetY * b + x; // ul + worldVertices[offset + 1] = offsetX * c + offsetY * d + y; + offset += stride; + + offsetX = vertexOffset[RegionAttachment.OX4]; + offsetY = vertexOffset[RegionAttachment.OY4]; + worldVertices[offset] = offsetX * a + offsetY * b + x; // ur + worldVertices[offset + 1] = offsetX * c + offsetY * d + y; + } + + copy(): Attachment { + const copy = new RegionAttachment(this.name); + copy.region = this.region; + copy.rendererObject = this.rendererObject; + copy.path = this.path; + copy.x = this.x; + copy.y = this.y; + copy.scaleX = this.scaleX; + copy.scaleY = this.scaleY; + copy.rotation = this.rotation; + copy.width = this.width; + copy.height = this.height; + Utils.arrayCopy(this.uvs, 0, copy.uvs, 0, 8); + Utils.arrayCopy(this.offset, 0, copy.offset, 0, 8); + copy.color.setFromColor(this.color); + return copy; + } +} diff --git a/packages/spine-core-3.8/src/spine-core/index.ts b/packages/spine-core-3.8/src/spine-core/index.ts new file mode 100644 index 0000000000..9dfa8aee05 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/index.ts @@ -0,0 +1,50 @@ +// Barrel for the vendored spine 3.8 runtime. There is no `@esotericsoftware/spine-core` npm +// release for the 3.8 line (npm starts at 4.0.1), so this directory vendors the runtime source +// instead of depending on a package; this file is the re-export surface the 4.2 package gets for +// free from `export * from "@esotericsoftware/spine-core"`. +import "./polyfills"; + +export * from "./Animation"; +export * from "./AnimationState"; +export * from "./AnimationStateData"; +export * from "./AtlasAttachmentLoader"; +export * from "./BlendMode"; +export * from "./Bone"; +export * from "./BoneData"; +export * from "./ConstraintData"; +export * from "./Event"; +export * from "./EventData"; +export * from "./IkConstraint"; +export * from "./IkConstraintData"; +export * from "./PathConstraint"; +export * from "./PathConstraintData"; +export * from "./Skeleton"; +export * from "./SkeletonBinary"; +export * from "./SkeletonBounds"; +export * from "./SkeletonClipping"; +export * from "./SkeletonData"; +export * from "./SkeletonJson"; +export * from "./Skin"; +export * from "./Slot"; +export * from "./SlotData"; +export * from "./Texture"; +export * from "./TextureAtlas"; +export * from "./TransformConstraint"; +export * from "./TransformConstraintData"; +export * from "./Triangulator"; +export * from "./Updatable"; +export * from "./Utils"; +export * from "./VertexEffect"; + +export * from "./attachments/Attachment"; +export * from "./attachments/AttachmentLoader"; +export * from "./attachments/AttachmentType"; +export * from "./attachments/BoundingBoxAttachment"; +export * from "./attachments/ClippingAttachment"; +export * from "./attachments/MeshAttachment"; +export * from "./attachments/PathAttachment"; +export * from "./attachments/PointAttachment"; +export * from "./attachments/RegionAttachment"; + +export * from "./vertexeffects/JitterEffect"; +export * from "./vertexeffects/SwirlEffect"; diff --git a/packages/spine-core-3.8/src/spine-core/polyfills.ts b/packages/spine-core-3.8/src/spine-core/polyfills.ts new file mode 100644 index 0000000000..0531725d1c --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/polyfills.ts @@ -0,0 +1,13 @@ +interface Math { + fround(n: number): number; +} + +(() => { + if (!Math.fround) { + Math.fround = (function (array) { + return function (x: number) { + return (array[0] = x), array[0]; + }; + })(new Float32Array(1)); + } +})(); diff --git a/packages/spine-core-3.8/src/spine-core/vertexeffects/JitterEffect.ts b/packages/spine-core-3.8/src/spine-core/vertexeffects/JitterEffect.ts new file mode 100644 index 0000000000..f7060b06ba --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/vertexeffects/JitterEffect.ts @@ -0,0 +1,22 @@ +import { VertexEffect } from "../VertexEffect"; +import { Skeleton } from "../Skeleton"; +import { Color, MathUtils, Vector2 } from "../Utils"; + +export class JitterEffect implements VertexEffect { + jitterX = 0; + jitterY = 0; + + constructor(jitterX: number, jitterY: number) { + this.jitterX = jitterX; + this.jitterY = jitterY; + } + + begin(skeleton: Skeleton): void {} + + transform(position: Vector2, uv: Vector2, light: Color, dark: Color): void { + position.x += MathUtils.randomTriangular(-this.jitterX, this.jitterY); + position.y += MathUtils.randomTriangular(-this.jitterX, this.jitterY); + } + + end(): void {} +} diff --git a/packages/spine-core-3.8/src/spine-core/vertexeffects/SwirlEffect.ts b/packages/spine-core-3.8/src/spine-core/vertexeffects/SwirlEffect.ts new file mode 100644 index 0000000000..52f0169be5 --- /dev/null +++ b/packages/spine-core-3.8/src/spine-core/vertexeffects/SwirlEffect.ts @@ -0,0 +1,38 @@ +import { VertexEffect } from "../VertexEffect"; +import { PowOut, Color, MathUtils, Vector2 } from "../Utils"; +import { Skeleton } from "../Skeleton"; + +export class SwirlEffect implements VertexEffect { + static interpolation = new PowOut(2); + centerX = 0; + centerY = 0; + radius = 0; + angle = 0; + private worldX = 0; + private worldY = 0; + + constructor(radius: number) { + this.radius = radius; + } + + begin(skeleton: Skeleton): void { + this.worldX = skeleton.x + this.centerX; + this.worldY = skeleton.y + this.centerY; + } + + transform(position: Vector2, uv: Vector2, light: Color, dark: Color): void { + const radAngle = this.angle * MathUtils.degreesToRadians; + const x = position.x - this.worldX; + const y = position.y - this.worldY; + const dist = Math.sqrt(x * x + y * y); + if (dist < this.radius) { + const theta = SwirlEffect.interpolation.apply(0, radAngle, (this.radius - dist) / this.radius); + const cos = Math.cos(theta); + const sin = Math.sin(theta); + position.x = cos * x - sin * y + this.worldX; + position.y = sin * x + cos * y + this.worldY; + } + } + + end(): void {} +} diff --git a/packages/spine-core-3.8/src/util/ClearablePool.ts b/packages/spine-core-3.8/src/util/ClearablePool.ts new file mode 100644 index 0000000000..ff8d6ccc6a --- /dev/null +++ b/packages/spine-core-3.8/src/util/ClearablePool.ts @@ -0,0 +1,26 @@ +export class ClearablePool { + private _type: new () => T; + private _elements: T[]; + private _usedElementCount: number = 0; + + constructor(type: new () => T) { + this._type = type; + this._elements = []; + } + + get(): T { + const { _usedElementCount: usedElementCount, _elements: elements } = this; + this._usedElementCount++; + if (elements.length === usedElementCount) { + const element = new this._type(); + elements.push(element); + return element; + } else { + return elements[usedElementCount]; + } + } + + clear(): void { + this._usedElementCount = 0; + } +} diff --git a/packages/spine-core-3.8/src/util/ReturnablePool.ts b/packages/spine-core-3.8/src/util/ReturnablePool.ts new file mode 100644 index 0000000000..77c1bcaa47 --- /dev/null +++ b/packages/spine-core-3.8/src/util/ReturnablePool.ts @@ -0,0 +1,25 @@ +export class ReturnablePool { + private _type: new () => T; + private _elements: T[]; + private _lastElementIndex: number; + + constructor(type: new () => T, initializeCount: number = 1) { + this._type = type; + this._lastElementIndex = initializeCount - 1; + const elements = (this._elements = new Array(initializeCount)); + for (let i = 0; i < initializeCount; ++i) { + elements[i] = new type(); + } + } + + get(): T { + if (this._lastElementIndex < 0) { + return new this._type(); + } + return this._elements[this._lastElementIndex--]; + } + + return(element: T): void { + this._elements[++this._lastElementIndex] = element; + } +} diff --git a/packages/spine-core-3.8/tsconfig.json b/packages/spine-core-3.8/tsconfig.json new file mode 100644 index 0000000000..588b0055c3 --- /dev/null +++ b/packages/spine-core-3.8/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declarationDir": "types", + "emitDeclarationOnly": true, + "noImplicitOverride": true, + "sourceMap": true, + "incremental": false, + "skipLibCheck": true, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/packages/spine-core-4.2/package.json b/packages/spine-core-4.2/package.json new file mode 100644 index 0000000000..3716a274f0 --- /dev/null +++ b/packages/spine-core-4.2/package.json @@ -0,0 +1,41 @@ +{ + "name": "@galacean/engine-spine-core-4.2", + "version": "0.0.0-experimental-2.0-migrate.3", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/galacean/engine.git" + }, + "license": "MIT", + "main": "dist/main.js", + "module": "dist/module.js", + "debug": "src/index.ts", + "browser": "dist/browser.js", + "types": "types/index.d.ts", + "scripts": { + "b:types": "tsc" + }, + "umd": { + "name": "Galacean.SpineCore42", + "globals": { + "@galacean/engine": "Galacean", + "@galacean/engine-spine": "Galacean.Spine" + } + }, + "files": [ + "dist/**/*", + "types/**/*" + ], + "dependencies": { + "@galacean/engine-spine": "workspace:*" + }, + "devDependencies": { + "@esotericsoftware/spine-core": "~4.2.66", + "@galacean/engine": "workspace:*" + }, + "peerDependencies": { + "@galacean/engine": "workspace:*" + } +} diff --git a/packages/spine-core-4.2/src/Spine42Runtime.ts b/packages/spine-core-4.2/src/Spine42Runtime.ts new file mode 100644 index 0000000000..03082b3589 --- /dev/null +++ b/packages/spine-core-4.2/src/Spine42Runtime.ts @@ -0,0 +1,81 @@ +import { + AnimationState, + AnimationStateData, + AtlasAttachmentLoader, + Physics, + Skeleton, + SkeletonBinary, + SkeletonData, + SkeletonJson, + TextureAtlas +} from "@esotericsoftware/spine-core"; +import type { Texture2D } from "@galacean/engine"; +import type { ISpineRenderTarget, ISpineRuntime } from "@galacean/engine-spine"; +import { SpineGenerator } from "./SpineGenerator"; +import { SpineTexture } from "./SpineTexture"; + +/** + * Spine 4.2 runtime backend. + * + * @remarks + * Owns everything specific to the spine 4.2 runtime line: parsing (`SkeletonJson`/`SkeletonBinary`/ + * `TextureAtlas`), object-model construction, world-transform stepping (4.2's `Physics.update`), and + * the attachment-reading mesh generator. Registered automatically when this package is imported. + */ +export class Spine42Runtime implements ISpineRuntime { + private _generator = new SpineGenerator(); + + parseAtlasPageNames(atlasText: string): string[] { + return new TextureAtlas(atlasText).pages.map((page) => page.name); + } + + createTextureAtlas(atlasText: string, textures: Texture2D[]): TextureAtlas { + const textureAtlas = new TextureAtlas(atlasText); + textureAtlas.pages.forEach((page, index) => { + const engineTexture = textures.find((item) => item.name === page.name) || textures[index]; + const texture = new SpineTexture(engineTexture); + page.setTexture(texture); + }); + return textureAtlas; + } + + createSkeletonData( + skeletonRawData: string | ArrayBuffer, + textureAtlas: TextureAtlas, + skeletonDataScale: number + ): SkeletonData { + const atlasLoader = new AtlasAttachmentLoader(textureAtlas); + if (typeof skeletonRawData === "string") { + const skeletonJson = new SkeletonJson(atlasLoader); + skeletonJson.scale = skeletonDataScale; + return skeletonJson.readSkeletonData(skeletonRawData); + } else { + const skeletonBinary = new SkeletonBinary(atlasLoader); + skeletonBinary.scale = skeletonDataScale; + return skeletonBinary.readSkeletonData(new Uint8Array(skeletonRawData as ArrayBuffer)); + } + } + + createAnimationStateData(skeletonData: SkeletonData): AnimationStateData { + return new AnimationStateData(skeletonData); + } + + createSkeleton(skeletonData: SkeletonData): Skeleton { + return new Skeleton(skeletonData); + } + + createAnimationState(stateData: AnimationStateData): AnimationState { + return new AnimationState(stateData); + } + + updateState(skeleton: Skeleton, state: AnimationState, delta: number): void { + state.update(delta); + state.apply(skeleton); + skeleton.update(delta); + skeleton.updateWorldTransform(Physics.update); + } + + buildPrimitive(skeleton: Skeleton, target: ISpineRenderTarget): void { + this._generator.buildPrimitive(skeleton, target); + } +} diff --git a/packages/spine-core-4.2/src/SpineGenerator.ts b/packages/spine-core-4.2/src/SpineGenerator.ts new file mode 100644 index 0000000000..7e45d6f98e --- /dev/null +++ b/packages/spine-core-4.2/src/SpineGenerator.ts @@ -0,0 +1,342 @@ +import { + ArrayLike, + BlendMode, + ClippingAttachment, + Color, + MeshAttachment, + NumberArrayLike, + RegionAttachment, + Skeleton, + SkeletonClipping +} from "@esotericsoftware/spine-core"; +import { BoundingBox, SubPrimitive } from "@galacean/engine"; +import { SpineTexture } from "./SpineTexture"; +import { ClearablePool } from "./util/ClearablePool"; +import { ReturnablePool } from "./util/ReturnablePool"; +import type { ISpineRenderTarget } from "@galacean/engine-spine"; +import { SpineBlendMode, SpineVertexStride } from "@galacean/engine-spine"; + +class SubRenderItem { + subPrimitive: SubPrimitive; + blendMode: BlendMode; + texture: any; +} + +/** + * @internal + */ +export class SpineGenerator { + static tempDark = new Color(); + static tempColor = new Color(); + static tempVerts = new Array(8); + static QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; + static subPrimitivePool = new ReturnablePool(SubPrimitive); + static subRenderItemPool = new ClearablePool(SubRenderItem); + + private _subRenderItems: SubRenderItem[] = []; + private _clipper: SkeletonClipping = new SkeletonClipping(); + + buildPrimitive(skeleton: Skeleton, renderer: ISpineRenderTarget) { + const { _indices, _vertices, _localBounds, _vertexCount, _subPrimitives, zSpacing, premultipliedAlpha, tintBlack } = + renderer; + + _localBounds.min.set(Infinity, Infinity, Infinity); + _localBounds.max.set(-Infinity, -Infinity, -Infinity); + + const { _clipper, _subRenderItems } = this; + + const { tempVerts, subRenderItemPool, subPrimitivePool } = SpineGenerator; + const { withTint: vertexStrideWithTint, withoutTint: vertexStrideWithoutTint } = SpineVertexStride; + + _subRenderItems.length = 0; + subRenderItemPool.clear(); + + let triangles: Array; + let uvs: NumberArrayLike; + + let verticesLength = 0; + let indicesLength = 0; + let start = 0; + let count = 0; + + let blend = BlendMode.Normal; + let texture: SpineTexture = null; + let tempBlendMode: BlendMode | null = null; + let tempTexture: SpineTexture | null = null; + + let primitiveIndex = 0; + + const drawOrder = skeleton.drawOrder; + for (let slotIndex = 0, n = drawOrder.length; slotIndex < n; ++slotIndex) { + const slot = drawOrder[slotIndex]; + if (!slot.bone.active) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const attachment = slot.getAttachment(); + if (!attachment) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const z = zSpacing * slotIndex; + const isClipping = _clipper.isClipping(); + let numFloats = 0; + let attachmentColor: Color = null; + + // This vertexSize will be passed to spine-core's computeWorldVertices function. + // + // Expected format by computeWorldVertices: + // - Without tintBlack: [x, y, u, v, r, g, b, a] = 8 components + // - With tintBlack: [x, y, u, v, r, g, b, a, dr, dg, db, da] = 12 components + // + // Our actual vertex buffer format: + // - vertexStrideWithoutTint: [x, y, z, u, v, r, g, b, a] = 9 components + // - vertexStrideWithTint: [x, y, z, u, v, r, g, b, a, dr, dg, db] = 12 components + // (Note: we optimized 'da' as uniform instead of buffer attribute) + // + // Calculation: + // - Without tintBlack: 9 - 1 (remove z) = 8 ✓ + // - With tintBlack: 12 - 1 (remove z) + 1 (add back da) = 12 ✓ + const vertexSize = tintBlack ? vertexStrideWithTint : vertexStrideWithoutTint - 1; + const clippedVertexSize = isClipping ? 2 : vertexSize; + + switch (attachment.constructor) { + case RegionAttachment: + const regionAttachment = attachment; + attachmentColor = regionAttachment.color; + numFloats = clippedVertexSize << 2; + regionAttachment.computeWorldVertices(slot, tempVerts, 0, clippedVertexSize); + triangles = SpineGenerator.QUAD_TRIANGLES; + uvs = regionAttachment.uvs; + texture = regionAttachment.region.texture; + break; + case MeshAttachment: + const meshAttachment = attachment; + attachmentColor = meshAttachment.color; + numFloats = (meshAttachment.worldVerticesLength >> 1) * clippedVertexSize; + if (numFloats > _vertices.length) { + SpineGenerator.tempVerts = new Array(numFloats); + } + meshAttachment.computeWorldVertices( + slot, + 0, + meshAttachment.worldVerticesLength, + tempVerts, + 0, + clippedVertexSize + ); + triangles = meshAttachment.triangles; + uvs = meshAttachment.uvs; + texture = meshAttachment.region.texture; + break; + case ClippingAttachment: + const clip = attachment; + _clipper.clipStart(slot, clip); + continue; + default: + _clipper.clipEndWithSlot(slot); + continue; + } + + if (texture != null) { + let finalVertices: ArrayLike; + let finalVerticesLength: number; + let finalIndices: ArrayLike; + let finalIndicesLength: number; + + const skeleton = slot.bone.skeleton; + const skeletonColor = skeleton.color; + const slotColor = slot.color; + const finalColor = SpineGenerator.tempColor; + const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a; + + finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r; + finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g; + finalColor.b = skeletonColor.b * slotColor.b * attachmentColor.b; + finalColor.a = finalAlpha; + + if (premultipliedAlpha) { + finalColor.r *= finalAlpha; + finalColor.g *= finalAlpha; + finalColor.b *= finalAlpha; + } + + const darkColor = SpineGenerator.tempDark; + const slotDarkColor = slot.darkColor; + if (!slotDarkColor) { + darkColor.set(0, 0, 0, 1); + } else { + if (premultipliedAlpha) { + darkColor.r = slotDarkColor.r * finalAlpha; + darkColor.g = slotDarkColor.g * finalAlpha; + darkColor.b = slotDarkColor.b * finalAlpha; + } else { + darkColor.setFromColor(slotDarkColor); + } + } + + if (isClipping) { + _clipper.clipTriangles(tempVerts, triangles, triangles.length, uvs, finalColor, darkColor, tintBlack); + finalVertices = _clipper.clippedVertices; + finalVerticesLength = finalVertices.length; + finalIndices = _clipper.clippedTriangles; + finalIndicesLength = finalIndices.length; + } else { + const { r, g, b, a } = finalColor; + for (let v = 2, u = 0, n = numFloats; v < n; v += vertexSize, u += 2) { + tempVerts[v] = r; + tempVerts[v + 1] = g; + tempVerts[v + 2] = b; + tempVerts[v + 3] = a; + tempVerts[v + 4] = uvs[u]; + tempVerts[v + 5] = uvs[u + 1]; + if (tintBlack) { + tempVerts[v + 6] = darkColor.r; + tempVerts[v + 7] = darkColor.g; + tempVerts[v + 8] = darkColor.b; + tempVerts[v + 9] = darkColor.a; + } + } + finalVertices = tempVerts; + finalVerticesLength = numFloats; + finalIndices = triangles; + finalIndicesLength = triangles.length; + } + + if (finalVerticesLength == 0 || finalIndicesLength == 0) { + _clipper.clipEndWithSlot(slot); + continue; + } + + const stride = tintBlack ? vertexStrideWithTint : vertexStrideWithoutTint; + const indexStart = verticesLength / stride; + let i = verticesLength; + let j = 0; + for (; j < finalVerticesLength; ) { + const x = finalVertices[j++]; + const y = finalVertices[j++]; + _vertices[i++] = x; + _vertices[i++] = y; + _vertices[i++] = z; + _vertices[i++] = finalVertices[j++]; // u + _vertices[i++] = finalVertices[j++]; // v + _vertices[i++] = finalVertices[j++]; // r + _vertices[i++] = finalVertices[j++]; // g + _vertices[i++] = finalVertices[j++]; // b + _vertices[i++] = finalVertices[j++]; // a + if (tintBlack) { + _vertices[i++] = finalVertices[j++]; // darkR + _vertices[i++] = finalVertices[j++]; // darkG + _vertices[i++] = finalVertices[j++]; // darkB + j++; // darkA + } + this._expandBounds(x, y, z, _localBounds); + } + verticesLength = i; + + for (i = indicesLength, j = 0; j < finalIndicesLength; i++, j++) { + _indices[i] = finalIndices[j] + indexStart; + } + indicesLength += finalIndicesLength; + + const textureChanged = tempTexture !== null && tempTexture !== texture; + blend = slot.data.blendMode; + const blendModeChanged = tempBlendMode !== null && tempBlendMode !== blend; + + if (blendModeChanged || textureChanged) { + // Finish accumulated count first + if (count > 0) { + primitiveIndex = this._createRenderItem( + _subPrimitives, + primitiveIndex, + start, + count, + tempTexture, + tempBlendMode + ); + start += count; + count = 0; + } + } + count += finalIndicesLength; + tempTexture = texture; + tempBlendMode = blend; + } + + _clipper.clipEndWithSlot(slot); + } // slot traverse end + + // add reset sub primitive + if (count > 0) { + primitiveIndex = this._createRenderItem(_subPrimitives, primitiveIndex, start, count, texture, blend); + count = 0; + } + + _clipper.clipEnd(); + + const lastLen = _subPrimitives.length; + const curLen = _subRenderItems.length; + for (let i = curLen; i < lastLen; i++) { + const item = _subPrimitives[i]; + subPrimitivePool.return(item); + } + + renderer._clearSubPrimitives(); + for (let i = 0, l = curLen; i < l; ++i) { + const item = _subRenderItems[i]; + const { blendMode, texture } = item; + renderer._addSubPrimitive(item.subPrimitive); + const material = renderer._getMaterial(texture.getImage(), blendMode as unknown as SpineBlendMode); + renderer.setMaterial(i, material); + } + + if (indicesLength > _vertexCount || renderer._needResizeBuffer) { + renderer._createAndBindBuffer(indicesLength); + renderer._needResizeBuffer = false; + this.buildPrimitive(skeleton, renderer); + return; + } + + if (renderer._vertexBuffer) { + renderer._vertexBuffer.setData(_vertices); + renderer._indexBuffer.setData(_indices); + } + } + + private _createRenderItem( + subPrimitives: SubPrimitive[], + primitiveIndex: number, + start: number, + count: number, + texture: SpineTexture, + blend: BlendMode + ): number { + const { subPrimitivePool, subRenderItemPool } = SpineGenerator; + const origin = subPrimitives[primitiveIndex]; + + if (origin) { + primitiveIndex++; + } + + const subPrimitive = origin || subPrimitivePool.get(); + subPrimitive.start = start; + subPrimitive.count = count; + + const renderItem = subRenderItemPool.get(); + renderItem.blendMode = blend; + renderItem.subPrimitive = subPrimitive; + renderItem.texture = texture; + + this._subRenderItems.push(renderItem); + + return primitiveIndex; + } + + private _expandBounds(x: number, y: number, z: number, localBounds: BoundingBox) { + const { min, max } = localBounds; + min.set(Math.min(min.x, x), Math.min(min.y, y), Math.min(min.z, z)); + max.set(Math.max(max.x, x), Math.max(max.y, y), Math.max(max.z, z)); + } +} diff --git a/packages/spine-core-4.2/src/SpineTexture.ts b/packages/spine-core-4.2/src/SpineTexture.ts new file mode 100644 index 0000000000..5297919e1b --- /dev/null +++ b/packages/spine-core-4.2/src/SpineTexture.ts @@ -0,0 +1,49 @@ +import { Texture, TextureFilter, TextureWrap } from "@esotericsoftware/spine-core"; +import { Texture2D, TextureFilterMode, TextureWrapMode } from "@galacean/engine"; + +/** + * @internal + */ +export class SpineTexture extends Texture { + constructor(image: Texture2D) { + super(image); + image.generateMipmaps(); + } + + // rewrite getImage function, return galacean texture2D, then attachment can get size of texture + override getImage(): Texture2D { + return this._image; + } + + setFilters(minFilter: TextureFilter, magFilter: TextureFilter) { + if (minFilter === TextureFilter.Nearest) { + this._image.filterMode = TextureFilterMode.Point; + } else if (minFilter === TextureFilter.Linear) { + this._image.filterMode = TextureFilterMode.Bilinear; + } else { + // The remaining min filters are the MipMap* family (mag filters are never MipMap*); + // mipmaps are generated in the constructor. + this._image.filterMode = TextureFilterMode.Trilinear; + } + } + + setWraps(uWrap: TextureWrap, vWrap: TextureWrap) { + this._image.wrapModeU = this._convertWrapMode(uWrap); + this._image.wrapModeV = this._convertWrapMode(vWrap); + } + + dispose() {} + + private _convertWrapMode(wrap: TextureWrap): TextureWrapMode { + switch (wrap) { + case TextureWrap.ClampToEdge: + return TextureWrapMode.Clamp; + case TextureWrap.Repeat: + return TextureWrapMode.Repeat; + case TextureWrap.MirroredRepeat: + return TextureWrapMode.Mirror; + default: + throw new Error("Unsupported texture wrap mode."); + } + } +} diff --git a/packages/spine-core-4.2/src/index.ts b/packages/spine-core-4.2/src/index.ts new file mode 100644 index 0000000000..8822f0a362 --- /dev/null +++ b/packages/spine-core-4.2/src/index.ts @@ -0,0 +1,15 @@ +import { registerSpineRuntime } from "@galacean/engine-spine"; +import { Spine42Runtime } from "./Spine42Runtime"; + +// Self-register on import: `import "@galacean/engine-spine-core-4.2"` wires the 4.2 backend +// into the shared `@galacean/engine-spine` package. +registerSpineRuntime(new Spine42Runtime()); + +export { Spine42Runtime }; + +// Re-export the spine 4.2 runtime API. Power users that construct spine-core objects directly +// (custom attachments, manual skeleton parsing) import them from this core package. +export * from "@esotericsoftware/spine-core"; + +export const version = `__buildVersion`; +console.log(`Galacean spine-core-4.2 version: ${version}`); diff --git a/packages/spine-core-4.2/src/util/ClearablePool.ts b/packages/spine-core-4.2/src/util/ClearablePool.ts new file mode 100644 index 0000000000..ff8d6ccc6a --- /dev/null +++ b/packages/spine-core-4.2/src/util/ClearablePool.ts @@ -0,0 +1,26 @@ +export class ClearablePool { + private _type: new () => T; + private _elements: T[]; + private _usedElementCount: number = 0; + + constructor(type: new () => T) { + this._type = type; + this._elements = []; + } + + get(): T { + const { _usedElementCount: usedElementCount, _elements: elements } = this; + this._usedElementCount++; + if (elements.length === usedElementCount) { + const element = new this._type(); + elements.push(element); + return element; + } else { + return elements[usedElementCount]; + } + } + + clear(): void { + this._usedElementCount = 0; + } +} diff --git a/packages/spine-core-4.2/src/util/ReturnablePool.ts b/packages/spine-core-4.2/src/util/ReturnablePool.ts new file mode 100644 index 0000000000..77c1bcaa47 --- /dev/null +++ b/packages/spine-core-4.2/src/util/ReturnablePool.ts @@ -0,0 +1,25 @@ +export class ReturnablePool { + private _type: new () => T; + private _elements: T[]; + private _lastElementIndex: number; + + constructor(type: new () => T, initializeCount: number = 1) { + this._type = type; + this._lastElementIndex = initializeCount - 1; + const elements = (this._elements = new Array(initializeCount)); + for (let i = 0; i < initializeCount; ++i) { + elements[i] = new type(); + } + } + + get(): T { + if (this._lastElementIndex < 0) { + return new this._type(); + } + return this._elements[this._lastElementIndex--]; + } + + return(element: T): void { + this._elements[++this._lastElementIndex] = element; + } +} diff --git a/packages/spine-core-4.2/tsconfig.json b/packages/spine-core-4.2/tsconfig.json new file mode 100644 index 0000000000..588b0055c3 --- /dev/null +++ b/packages/spine-core-4.2/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declarationDir": "types", + "emitDeclarationOnly": true, + "noImplicitOverride": true, + "sourceMap": true, + "incremental": false, + "skipLibCheck": true, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/packages/spine/package.json b/packages/spine/package.json new file mode 100644 index 0000000000..8eaa828db8 --- /dev/null +++ b/packages/spine/package.json @@ -0,0 +1,37 @@ +{ + "name": "@galacean/engine-spine", + "version": "0.0.0-experimental-2.0-migrate.3", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/galacean/engine.git" + }, + "license": "MIT", + "main": "dist/main.js", + "module": "dist/module.js", + "debug": "src/index.ts", + "browser": "dist/browser.js", + "types": "types/index.d.ts", + "scripts": { + "b:types": "tsc" + }, + "umd": { + "name": "Galacean.Spine", + "globals": { + "@galacean/engine": "Galacean" + } + }, + "files": [ + "dist/**/*", + "types/**/*" + ], + "devDependencies": { + "@esotericsoftware/spine-core": "~4.2.66", + "@galacean/engine": "workspace:*" + }, + "peerDependencies": { + "@galacean/engine": "workspace:*" + } +} diff --git a/packages/spine/src/SpineConstant.ts b/packages/spine/src/SpineConstant.ts new file mode 100644 index 0000000000..679d01d2e9 --- /dev/null +++ b/packages/spine/src/SpineConstant.ts @@ -0,0 +1,13 @@ +/** + * Vertex stride (float count per vertex) of the spine vertex layout owned by `SpineAnimationRenderer`. + * + * - `withoutTint`: [x, y, z, u, v, r, g, b, a] = 9 floats + * - `withTint`: [x, y, z, u, v, r, g, b, a, dr, dg, db] = 12 floats + * + * Shared between the renderer (which declares the vertex elements and allocates buffers) and the + * version-specific generator in a spine core package (which fills them), so the two never drift. + */ +export const SpineVertexStride = { + withoutTint: 9, + withTint: 12 +} as const; diff --git a/packages/spine/src/enums/SpineBlendMode.ts b/packages/spine/src/enums/SpineBlendMode.ts new file mode 100644 index 0000000000..509a2b60af --- /dev/null +++ b/packages/spine/src/enums/SpineBlendMode.ts @@ -0,0 +1,14 @@ +/** + * Blend mode of a spine slot. + * + * @remarks + * Version-neutral mirror of spine-core's `BlendMode` enum. The values match spine-core across all + * supported runtime versions (3.8 / 4.2), so a core package can pass its `BlendMode` straight through. + * Owning it here keeps the shared package free of any runtime spine-core dependency. + */ +export enum SpineBlendMode { + Normal = 0, + Additive = 1, + Multiply = 2, + Screen = 3 +} diff --git a/packages/spine/src/index.ts b/packages/spine/src/index.ts new file mode 100644 index 0000000000..6cdc5bf196 --- /dev/null +++ b/packages/spine/src/index.ts @@ -0,0 +1,21 @@ +import * as LoaderObject from "./loader"; +import * as RendererObject from "./renderer"; +import { Loader } from "@galacean/engine"; + +for (const key in RendererObject) { + Loader.registerClass(key, RendererObject[key]); +} +for (const key in LoaderObject) { + Loader.registerClass(key, LoaderObject[key]); +} + +export * from "./loader/index"; +export * from "./renderer/index"; +export { SpineBlendMode } from "./enums/SpineBlendMode"; +export { SpineVertexStride } from "./SpineConstant"; +export { registerSpineRuntime, getSpineRuntime } from "./runtime/SpineRuntimeRegistry"; +export type { ISpineRuntime } from "./runtime/ISpineRuntime"; +export type { ISpineRenderTarget } from "./runtime/ISpineRenderTarget"; + +export const version = `__buildVersion`; +console.log(`Galacean spine version: ${version}`); diff --git a/packages/spine/src/loader/LoaderUtils.ts b/packages/spine/src/loader/LoaderUtils.ts new file mode 100644 index 0000000000..967aafa5a0 --- /dev/null +++ b/packages/spine/src/loader/LoaderUtils.ts @@ -0,0 +1,96 @@ +import type { SkeletonData, TextureAtlas } from "@esotericsoftware/spine-core"; +import { AssetPromise, AssetType, Engine, Texture2D } from "@galacean/engine"; +import { getSpineRuntime } from "../runtime/SpineRuntimeRegistry"; + +/** + * @internal + */ +export class LoaderUtils { + static createSkeletonData( + skeletonRawData: string | ArrayBuffer, + textureAtlas: TextureAtlas, + skeletonDataScale: number + ): SkeletonData { + return getSpineRuntime().createSkeletonData(skeletonRawData, textureAtlas, skeletonDataScale); + } + + static createTextureAtlas(atlasText: string, textures: Texture2D[]): TextureAtlas { + return getSpineRuntime().createTextureAtlas(atlasText, textures); + } + + static loadTexturesByPaths( + imagePaths: string[], + imageExtensions: string[], + engine: Engine, + reject: (reason?: any) => void + ): Promise { + const resourceManager = engine.resourceManager; + // @ts-ignore + const virtualPathResourceMap = resourceManager._virtualPathResourceMap; + const texturePromises: AssetPromise[] = imagePaths.map((imagePath, index) => { + const ext = imageExtensions[index]; + let imageLoaderType = AssetType.Texture; + const virtualElement = virtualPathResourceMap[imagePath]; + if (virtualElement) { + imageLoaderType = virtualElement.type; + } else if (ext === "ktx") { + imageLoaderType = AssetType.KTX; + } else if (ext === "ktx2") { + imageLoaderType = AssetType.KTX2; + } + return resourceManager.load({ + url: imagePath, + type: imageLoaderType + }); + }); + return Promise.all(texturePromises).catch((error) => { + reject(error); + return []; + }); + } + + static loadTextureAtlas(atlasPath: string, engine: Engine, reject: (reason?: any) => void): Promise { + const baseUrl = LoaderUtils.getBaseUrl(atlasPath); + const resourceManager = engine.resourceManager; + let atlasText: string; + return ( + resourceManager + // @ts-ignore + ._request(atlasPath, { type: "text" }) + .then((text: string) => { + atlasText = text; + const runtime = getSpineRuntime(); + const pageNames = runtime.parseAtlasPageNames(atlasText); + const loadTexturePromises = pageNames.map((name) => { + const textureUrl = baseUrl + name; + return resourceManager.load({ + url: textureUrl, + type: AssetType.Texture + }) as unknown as Promise; + }); + return Promise.all(loadTexturePromises).then((textures) => { + return runtime.createTextureAtlas(atlasText, textures); + }); + }) + .catch((err) => { + reject(new Error(`Spine Atlas: ${atlasPath} load error: ${err}`)); + return null; + }) + ); + } + + static getBaseUrl(url: string): string { + const isLocalPath = !/^(http|https|ftp):\/\/.*/i.test(url); + if (isLocalPath) { + const lastSlashIndex = url.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return ""; + } + return url.substring(0, lastSlashIndex + 1); + } else { + const parsedUrl = new URL(url); + const basePath = parsedUrl.origin + parsedUrl.pathname; + return basePath.endsWith("/") ? basePath : basePath.substring(0, basePath.lastIndexOf("/") + 1); + } + } +} diff --git a/packages/spine/src/loader/SpineAtlasLoader.ts b/packages/spine/src/loader/SpineAtlasLoader.ts new file mode 100644 index 0000000000..a33f4d6298 --- /dev/null +++ b/packages/spine/src/loader/SpineAtlasLoader.ts @@ -0,0 +1,107 @@ +import type { TextureAtlas } from "@esotericsoftware/spine-core"; +import { AssetPromise, Loader, LoadItem, resourceLoader, ResourceManager } from "@galacean/engine"; +import { LoaderUtils } from "./LoaderUtils"; +import { SpineLoader } from "./SpineLoader"; + +interface SpineAtlasAsset { + atlasPath: string; + imagePaths?: string[]; + imageExtensions?: string[]; +} + +@resourceLoader("SpineAtlas", ["atlas"], false) +export class SpineAtlasLoader extends Loader { + private static _groupAssetsByExtension(url: string, assetPath: SpineAtlasAsset, resourceManager: ResourceManager) { + const ext = SpineLoader._getUrlExtension(url); + if (!ext) return; + + if (ext === "atlas") { + assetPath.atlasPath = url; + } + if (["png", "jpg", "webp", "jpeg", "ktx", "ktx2"].includes(ext)) { + assetPath.imagePaths.push(url); + } + } + + private static _assignAssetPathsFromUrl(url: string, assetPath: SpineAtlasAsset, resourceManager: ResourceManager) { + const ext = SpineLoader._getUrlExtension(url); + if (ext === "atlas") { + assetPath.atlasPath = url; + // @ts-ignore + const atlasDependency = resourceManager?._virtualPathResourceMap?.[url]?.dependentAssetMap; + if (atlasDependency) { + for (const key in atlasDependency) { + const imageVirtualPath = atlasDependency[key]; + assetPath.imagePaths.push(imageVirtualPath); + } + } + } + } + + load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { + return new AssetPromise((resolve, reject) => { + const engine = resourceManager.engine; + const spineAtlasAsset = { + atlasPath: "", + imagePaths: [], + imageExtensions: [] + }; + + if (!item.urls) { + SpineAtlasLoader._assignAssetPathsFromUrl(item.url, spineAtlasAsset, resourceManager); + } else { + const urls = item.urls; + for (let i = 0, len = urls.length; i < len; i += 1) { + const url = urls[i]; + SpineAtlasLoader._groupAssetsByExtension(url, spineAtlasAsset, resourceManager); + } + } + + const { atlasPath } = spineAtlasAsset; + if (!atlasPath) { + reject( + new Error("Failed to load spine atlas. Please check the file path and ensure the file extension is included.") + ); + return; + } + + const imagePaths = spineAtlasAsset.imagePaths; + if (imagePaths.length === 0) { + // Use the parsed atlas path: `item.url` is undefined when the asset came in via `item.urls`. + LoaderUtils.loadTextureAtlas(atlasPath, engine, reject) + .then((textureAtlas) => { + resolve(textureAtlas); + }) + .catch((err) => { + reject(err); + }); + } else { + const { atlasPath, imagePaths, imageExtensions } = spineAtlasAsset; + if (imagePaths.length > 0) { + Promise.all([ + // @ts-ignore + resourceManager._request(atlasPath, { + type: "text" + }) as Promise, + LoaderUtils.loadTexturesByPaths(imagePaths, imageExtensions, engine, reject) + ]) + .then(([atlasText, textures]) => { + const textureAtlas = LoaderUtils.createTextureAtlas(atlasText, textures); + resolve(textureAtlas); + }) + .catch((err) => { + reject(err); + }); + } else { + LoaderUtils.loadTextureAtlas(atlasPath, engine, reject) + .then((textureAtlas) => { + resolve(textureAtlas); + }) + .catch((err) => { + reject(err); + }); + } + } + }); + } +} diff --git a/packages/spine/src/loader/SpineLoader.ts b/packages/spine/src/loader/SpineLoader.ts new file mode 100644 index 0000000000..41d613e822 --- /dev/null +++ b/packages/spine/src/loader/SpineLoader.ts @@ -0,0 +1,144 @@ +import type { TextureAtlas } from "@esotericsoftware/spine-core"; +import { AssetPromise, Loader, LoadItem, resourceLoader, ResourceManager } from "@galacean/engine"; +import { LoaderUtils } from "./LoaderUtils"; +import { SpineResource } from "./SpineResource"; + +type SpineAssetPath = { + atlasPath: string; + skeletonPath: string; + extraPaths: string[]; + fileExtensions?: string | string[]; +}; + +type SpineLoadContext = { + fileName: string; + spineAssetPath: SpineAssetPath; + skeletonRawData: string | ArrayBuffer; +}; + +export type SpineLoaderParams = { + fileExtensions?: string | string[]; +}; + +// Registering "json" makes this loader the default for bare `.json` urls (extension mappings are +// last-registration-wins, overriding the core JSONLoader) — matches the historical engine-spine +// behavior. Pass an explicit `type` (e.g. AssetType.JSON) to load plain JSON once this package +// is imported. +@resourceLoader("Spine", ["json", "bin", "skel"]) +export class SpineLoader extends Loader { + private static _decoder = new TextDecoder("utf-8"); + + private static _groupAssetsByExtension(url: string, assetPath: SpineAssetPath) { + const ext = SpineLoader._getUrlExtension(url); + if (!ext) return; + + if (["skel", "json", "bin"].includes(ext)) { + assetPath.skeletonPath = url; + } else if (ext === "atlas") { + assetPath.atlasPath = url; + } else { + assetPath.extraPaths.push(url); + } + } + + private static _assignAssetPathsFromUrl(url: string, assetPath: SpineAssetPath, resourceManager: ResourceManager) { + const ext = SpineLoader._getUrlExtension(url); + if (!ext) return; + assetPath.skeletonPath = url; + + // @ts-ignore + const skeletonDependency = resourceManager?._virtualPathResourceMap?.[url]?.dependentAssetMap; + if (skeletonDependency) { + assetPath.atlasPath = skeletonDependency.atlas; + } else { + const extensionPattern: RegExp = /\.(json|bin|skel)$/; + let baseUrl: string; + if (extensionPattern.test(url)) { + baseUrl = url.replace(extensionPattern, ""); + } + if (baseUrl) { + const atlasUrl = baseUrl + ".atlas"; + assetPath.atlasPath = atlasUrl; + } + } + } + + static _getUrlExtension(url: string): string | null { + const regex = /\.(\w+)(\?|$)/; + const match = url.match(regex); + return match ? match[1] : null; + } + + load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { + return new AssetPromise((resolve, reject) => { + const spineLoadContext: SpineLoadContext = { + fileName: "", + skeletonRawData: "", + spineAssetPath: { + atlasPath: null, + skeletonPath: null, + extraPaths: [] + } + }; + const { spineAssetPath } = spineLoadContext; + if (!item.urls) { + SpineLoader._assignAssetPathsFromUrl(item.url, spineAssetPath, resourceManager); + } else { + const urls = item.urls; + for (let i = 0, len = urls.length; i < len; i += 1) { + const url = urls[i]; + SpineLoader._groupAssetsByExtension(url, spineAssetPath); + } + } + + const { skeletonPath, atlasPath } = spineAssetPath; + if (!skeletonPath || !atlasPath) { + reject( + new Error( + "Failed to load spine assets. Please check the file path and ensure the file extension is included." + ) + ); + return; + } + + resourceManager + // @ts-ignore + ._request(skeletonPath, { type: "arraybuffer" }) + .then((skeletonRawData: ArrayBuffer) => { + spineLoadContext.skeletonRawData = skeletonRawData; + const skeletonString = SpineLoader._decoder.decode(skeletonRawData); + if (skeletonString.startsWith("{")) { + spineLoadContext.skeletonRawData = skeletonString; + } + return this._loadAndCreateSpineResource(spineLoadContext, resourceManager); + }) + .then(resolve) + .catch(reject); + }); + } + + private _loadAndCreateSpineResource( + spineLoadContext: SpineLoadContext, + resourceManager: ResourceManager + ): Promise { + const { engine } = resourceManager; + const { skeletonRawData, spineAssetPath } = spineLoadContext; + const { skeletonPath, atlasPath, extraPaths } = spineAssetPath; + + const atlasLoadPromise = + extraPaths.length === 0 + ? (resourceManager.load({ + url: atlasPath, + type: "SpineAtlas" + }) as unknown as Promise) + : (resourceManager.load({ + urls: [atlasPath].concat(extraPaths), + type: "SpineAtlas" + }) as unknown as Promise); + + return atlasLoadPromise.then((textureAtlas) => { + const skeletonData = LoaderUtils.createSkeletonData(skeletonRawData, textureAtlas, 0.01); + return new SpineResource(engine, skeletonData, skeletonPath); + }); + } +} diff --git a/packages/spine/src/loader/SpineResource.ts b/packages/spine/src/loader/SpineResource.ts new file mode 100644 index 0000000000..a1532ad606 --- /dev/null +++ b/packages/spine/src/loader/SpineResource.ts @@ -0,0 +1,112 @@ +import type { + AnimationState, + AnimationStateData, + MeshAttachment, + RegionAttachment, + Skeleton, + SkeletonData +} from "@esotericsoftware/spine-core"; +import { Engine, Entity, ReferResource, Texture2D } from "@galacean/engine"; +import { SpineAnimationRenderer } from "../renderer/SpineAnimationRenderer"; +import { getSpineRuntime } from "../runtime/SpineRuntimeRegistry"; + +/** + * Represents a resource that manages Spine animation data, textures, and entity templates for the Galacean engine. + * + */ +export class SpineResource extends ReferResource { + /** The url of spine resource. */ + readonly url: string; + + private _texturesInSpineAtlas: Texture2D[] = []; + private _skeletonData: SkeletonData; + private _stateData: AnimationStateData; + + private _template: Entity; + + constructor(engine: Engine, skeletonData: SkeletonData, url?: string) { + super(engine); + this.url = url; + this._skeletonData = skeletonData; + this._stateData = getSpineRuntime().createAnimationStateData(skeletonData); + this._associationTextureInSkeletonData(skeletonData); + this._createTemplate(); + } + + /** + * The skeleton data associated with this Spine resource. + */ + get skeletonData(): SkeletonData { + return this._skeletonData; + } + + /** + * The animation state data associated with this Spine resource. + */ + get stateData(): AnimationStateData { + return this._stateData; + } + + /** + * Creates and returns a new instance of the spine entity template. + * @returns A instance of the spine entity template + */ + instantiate(): Entity { + return this._template.clone(); + } + + protected override _onDestroy(): void { + super._onDestroy(); + this._disassociationSuperResource(); + this._skeletonData = null; + this._stateData = null; + } + + private _createTemplate(): void { + const name = this._extractFileName(this.url); + const spineEntity = new Entity(this.engine, name); + const spineAnimationRenderer = spineEntity.addComponent(SpineAnimationRenderer); + const runtime = getSpineRuntime(); + const skeleton = runtime.createSkeleton(this._skeletonData); + const state = runtime.createAnimationState(this._stateData); + spineAnimationRenderer._setSkeleton(skeleton); + spineAnimationRenderer._setState(state); + // @ts-ignore + spineEntity._markAsTemplate(this); + this._template = spineEntity; + } + + private _associationTextureInSkeletonData(skeletonData: SkeletonData): void { + const { skins, slots } = skeletonData; + const textures = this._texturesInSpineAtlas; + + for (let i = 0, n = skins.length; i < n; i++) { + for (let j = 0, m = slots.length; j < m; j++) { + const slot = slots[j]; + const attachment = skins[i].getAttachment(slot.index, slot.name); + const texture = (attachment)?.region?.texture.getImage(); + if (texture) { + if (!textures.includes(texture)) { + textures.push(texture); + // @ts-ignore + texture._associationSuperResource(this); + } + } + } + } + } + + private _disassociationSuperResource(): void { + const textures = this._texturesInSpineAtlas; + for (let i = 0, n = textures.length; i < n; i++) { + // @ts-ignore + textures[i]._disassociationSuperResource(this); + } + } + + private _extractFileName(url: string): string { + if (!url) return "Spine Entity"; + const match = url.match(/\/([^\/]+?)(\.[^\/]*)?$/); + return match ? match[1] : "Spine Entity"; + } +} diff --git a/packages/spine/src/loader/index.ts b/packages/spine/src/loader/index.ts new file mode 100644 index 0000000000..b7367ba692 --- /dev/null +++ b/packages/spine/src/loader/index.ts @@ -0,0 +1,5 @@ +import "./SpineLoader"; +import "./SpineAtlasLoader"; + +export { SpineResource } from "./SpineResource"; +export { LoaderUtils } from "./LoaderUtils"; diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts new file mode 100644 index 0000000000..f0005ab510 --- /dev/null +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -0,0 +1,419 @@ +import type { AnimationState, Skeleton } from "@esotericsoftware/spine-core"; +import { + BoundingBox, + Buffer, + BufferBindFlag, + BufferUsage, + DataObject, + Entity, + ignoreClone, + IndexBufferBinding, + IndexFormat, + Material, + Primitive, + Renderer, + SubPrimitive, + Texture2D, + Vector3, + VertexBufferBinding, + VertexElement, + VertexElementFormat +} from "@galacean/engine"; +import { SpineResource } from "../loader/SpineResource"; +import { SpineMaterial } from "./SpineMaterial"; +import { SpineBlendMode } from "../enums/SpineBlendMode"; +import { SpineVertexStride } from "../SpineConstant"; +import { getSpineRuntime } from "../runtime/SpineRuntimeRegistry"; +import type { ISpineRenderTarget } from "../runtime/ISpineRenderTarget"; + +/** + * Spine animation renderer, capable of rendering spine animations and providing functions for animation and skeleton manipulation. + */ +export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarget { + private static _positionVertexElement = new VertexElement("POSITION", 0, VertexElementFormat.Vector3, 0); + private static _lightColorVertexElement = new VertexElement("LIGHT_COLOR", 12, VertexElementFormat.Vector4, 0); + private static _uvVertexElement = new VertexElement("TEXCOORD_0", 28, VertexElementFormat.Vector2, 0); + private static _darkColorVertexElement = new VertexElement("DARK_COLOR", 36, VertexElementFormat.Vector3, 0); + + /** @internal */ + static _materialCacheMap = new Map(); + + /** + * The spacing between z layers in world units. + */ + zSpacing = 0.001; + + /** + * Whether to use premultiplied alpha mode for rendering. + * When enabled, vertex color values are multiplied by the alpha channel. + * @remarks + If this option is enabled, the Spine editor must export textures with "Premultiply Alpha" checked. + */ + premultipliedAlpha = false; + + private _tintBlack = false; + + /** + * Whether to enable tint black feature for dark color tinting. + * + * @remarks Should be enabled when using "Tint Black" feature in Spine editor. + */ + get tintBlack(): boolean { + return this._tintBlack; + } + + set tintBlack(value: boolean) { + if (this._tintBlack !== value) { + this._tintBlack = value; + this._needResizeBuffer = true; + } + } + + /** + * Default state for spine animation. + * Contains the default animation name to be played, whether this animation should loop, the default skin name. + */ + readonly defaultConfig: SpineAnimationDefaultConfig = new SpineAnimationDefaultConfig(); + + /** @internal */ + @ignoreClone + _primitive: Primitive; + /** @internal */ + @ignoreClone + _subPrimitives: SubPrimitive[] = []; + /** @internal */ + @ignoreClone + _indexBuffer: Buffer; + /** @internal */ + @ignoreClone + _vertexBuffer: Buffer; + /** @internal */ + @ignoreClone + _vertices = new Float32Array(); + /** @internal */ + @ignoreClone + _indices = new Uint16Array(); + /** @internal */ + @ignoreClone + _needResizeBuffer = false; + /** @internal */ + @ignoreClone + _vertexCount = 0; + /** @internal */ + @ignoreClone + _resource: SpineResource; + /** @internal */ + @ignoreClone + _localBounds = new BoundingBox( + new Vector3(Infinity, Infinity, Infinity), + new Vector3(-Infinity, -Infinity, -Infinity) + ); + + @ignoreClone + private _skeleton: Skeleton; + @ignoreClone + private _state: AnimationState; + + /** + * The Spine.AnimationState object of this SpineAnimationRenderer. + * Manage, blend, and transition between multiple simultaneous animations effectively. + */ + get state(): AnimationState { + return this._state; + } + + /** + * The Spine.Skeleton object of this SpineAnimationRenderer. + * Manipulate bone positions, rotations, scaling + * and change spine attachment to customize character appearances dynamically during runtime. + */ + get skeleton(): Skeleton { + return this._skeleton; + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + const primitive = new Primitive(this.engine); + this._primitive = primitive; + this._primitive._addReferCount(1); + primitive.addVertexElement(SpineAnimationRenderer._positionVertexElement); + primitive.addVertexElement(SpineAnimationRenderer._lightColorVertexElement); + primitive.addVertexElement(SpineAnimationRenderer._uvVertexElement); + primitive.addVertexElement(SpineAnimationRenderer._darkColorVertexElement); + } + + /** + * @internal + */ + // @ts-ignore + override _onEnable(): void { + this._applyDefaultConfig(); + } + + /** + * @internal + */ + override update(delta: number): void { + const { _state: state, _skeleton: skeleton } = this; + if (!state || !skeleton) return; + const runtime = getSpineRuntime(); + runtime.updateState(skeleton, state, delta); + runtime.buildPrimitive(skeleton, this); + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + + /** + * @internal + */ + // @ts-ignore + override _render(context: any): void { + const { _primitive, _subPrimitives, _materials: materials } = this; + if (!_subPrimitives) return; + // Engine 2.0 render-element model: one RenderElement per sub-primitive (no sub-element pool). + const engine = this.engine as any; + const priority = this.priority; + const distanceForSort = (this as any)._distanceForSort; + const renderElementPool = engine._renderElementPool; + const renderPipeline = context.camera._renderPipeline; + for (let i = 0, n = _subPrimitives.length; i < n; i++) { + let material = materials[i]; + if (!material) { + continue; + } + if (material.destroyed || material.shader.destroyed) { + material = engine._basicResources.meshMagentaMaterial; + } + const renderElement = renderElementPool.get(); + renderElement.set(this, material, _primitive, _subPrimitives[i]); + renderElement.priority = priority; + renderElement.distanceForSort = distanceForSort; + renderPipeline.pushRenderElement(context, renderElement); + } + } + + /** + * @internal + */ + // @ts-ignore + override _updateBounds(worldBounds: BoundingBox): void { + BoundingBox.transform(this._localBounds, this.entity.transform.worldMatrix, worldBounds); + } + + /** + * @internal + */ + _setSkeleton(skeleton: Skeleton) { + this._skeleton = skeleton; + } + + /** + * @internal + */ + _setState(state: AnimationState) { + this._state = state; + } + + /** + * @internal + */ + // @ts-ignore + override _cloneTo(target: SpineAnimationRenderer): void { + // A renderer added manually and never bound to a resource has no skeleton/state to clone. + if (!this._skeleton || !this._state) return; + const runtime = getSpineRuntime(); + // Clones share the source's immutable SkeletonData and AnimationStateData (mix config), + // so mix times configured on the resource propagate to every instance; only the + // per-instance Skeleton / AnimationState are fresh. + const skeleton = runtime.createSkeleton(this._skeleton.data); + const state = runtime.createAnimationState(this._state.data); + target._setSkeleton(skeleton); + target._setState(state); + } + + /** + * @internal + */ + override _onDestroy(): void { + this._clearMaterialCache(); + this._subPrimitives.length = 0; + const primitive = this._primitive; + if (primitive) { + primitive._addReferCount(-1); + primitive.destroy(); + this._primitive = null; + } + // destroy() is refCount-guarded; the primitive released its buffer references above. + this._vertexBuffer?.destroy(); + this._indexBuffer?.destroy(); + this._vertexBuffer = null; + this._indexBuffer = null; + this._resource = null; + this._skeleton = null; + this._state = null; + super._onDestroy(); + } + + /** + * @internal + */ + _createAndBindBuffer(vertexCount: number): void { + const { engine: _engine, _primitive } = this; + const oldVertexBuffer = this._vertexBuffer; + const oldIndexBuffer = this._indexBuffer; + this._vertexCount = vertexCount; + const stride = this.tintBlack ? SpineVertexStride.withTint : SpineVertexStride.withoutTint; + this._vertices = new Float32Array(vertexCount * stride); + this._indices = new Uint16Array(vertexCount); + const vertexStride = stride << 2; + const vertexBuffer = new Buffer(_engine, BufferBindFlag.VertexBuffer, this._vertices, BufferUsage.Dynamic); + const indexBuffer = new Buffer(_engine, BufferBindFlag.IndexBuffer, this._indices, BufferUsage.Dynamic); + this._indexBuffer = indexBuffer; + this._vertexBuffer = vertexBuffer; + const vertexBufferBinding = new VertexBufferBinding(vertexBuffer, vertexStride); + this._primitive.setVertexBufferBinding(0, vertexBufferBinding); + const indexBufferBinding = new IndexBufferBinding(indexBuffer, IndexFormat.UInt16); + _primitive.setIndexBufferBinding(indexBufferBinding); + // Rebinding released the primitive's references to the old buffers; destroy() is + // refCount-guarded, so this frees their GPU resources without waiting for gc(). + oldVertexBuffer?.destroy(); + oldIndexBuffer?.destroy(); + } + + /** + * @internal + */ + _addSubPrimitive(subPrimitive: SubPrimitive): void { + this._subPrimitives.push(subPrimitive); + } + + /** + * @internal + */ + _clearSubPrimitives(): void { + this._subPrimitives.length = 0; + } + + /** + * @internal + */ + _getMaterial(texture: Texture2D, blendMode: SpineBlendMode): Material { + const engine = this.engine; + const premultipliedAlpha = this.premultipliedAlpha; + const tintBlack = this.tintBlack; + + // tintBlack must be part of the key: it toggles a per-material macro, so renderers that + // differ only in tintBlack cannot share a material. + const key = `${texture.instanceId}_${blendMode}_${premultipliedAlpha ? 1 : 0}_${tintBlack ? 1 : 0}`; + let cached = SpineAnimationRenderer._materialCacheMap.get(key); + if (!cached) { + cached = new SpineMaterial(engine); + cached.isGCIgnored = true; + cached._cacheKey = key; + SpineAnimationRenderer._materialCacheMap.set(key, cached); + } + cached._setBlendMode(blendMode, premultipliedAlpha); + cached._setTexture(texture); + cached._setTintBlack(tintBlack); + cached._setPremultipliedAlpha(premultipliedAlpha); + return cached; + } + + private _clearMaterialCache(): void { + const materialCache = SpineAnimationRenderer._materialCacheMap; + const materials = this._materials; + for (let i = 0, len = materials.length; i < len; i += 1) { + const material = materials[i]; + // `setMaterial` is public API, so entries may be user materials or null holes; a cached + // SpineMaterial is removed by the exact key it was registered under (recomputing the key + // from the renderer's current state would miss materials cached under older settings). + if (material instanceof SpineMaterial) { + materialCache.delete(material._cacheKey); + } + } + } + + private _applyDefaultConfig(): void { + const { skeleton, state } = this; + if (skeleton && state) { + const { animationName, skinName, loop } = this.defaultConfig; + if (skinName !== "default") { + skeleton.setSkinByName(skinName); + skeleton.setToSetupPose(); + } + if (animationName) { + state.setAnimation(0, animationName, loop); + } + } + } + + /** + * * @deprecated This property is deprecated and will be removed in future releases. + * Spine resource of current spine animation. + */ + get resource(): SpineResource { + return this._resource; + } + + /** + * * @deprecated This property is deprecated and will be removed in future releases. + * Sets the Spine resource for the current animation. This property allows switching to a different `SpineResource`. + * + * @param value - The new `SpineResource` to be used for the current animation + */ + set resource(value: SpineResource) { + if (!value) { + this._state = null; + this._skeleton = null; + this._resource = null; + return; + } + this._resource = value; + const { skeletonData, stateData } = value; + const runtime = getSpineRuntime(); + const skeleton = runtime.createSkeleton(skeletonData); + const state = runtime.createAnimationState(stateData); + this._setSkeleton(skeleton); + this._setState(state); + this._applyDefaultConfig(); + } +} + +/** + * @internal + */ +export enum RendererUpdateFlags { + /** Include world position and world bounds. */ + WorldVolume = 0x1 +} + +/** + * Default state for spine animation. + * Contains the default animation name to be played, whether this animation should loop, + * the default skin name, and the default scale of the skeleton. + */ +export class SpineAnimationDefaultConfig extends DataObject { + /** + * Creates an instance of default config + */ + constructor( + /** + * Whether the default animation should loop @defaultValue `true. The default animation should loop` + */ + public loop: boolean = true, + + /** + * The name of the default animation @defaultValue `null. Do not play any animation by default` + */ + public animationName: string | null = null, + + /** + * The name of the default skin @defaultValue `default` + */ + public skinName: string = "default" + ) { + super(); + } +} diff --git a/packages/spine/src/renderer/SpineMaterial.ts b/packages/spine/src/renderer/SpineMaterial.ts new file mode 100644 index 0000000000..bf4bec3b97 --- /dev/null +++ b/packages/spine/src/renderer/SpineMaterial.ts @@ -0,0 +1,106 @@ +import { BlendFactor, Engine, Material, Shader, ShaderProperty, Texture2D } from "@galacean/engine"; +import { SpineBlendMode } from "../enums/SpineBlendMode"; + +const { SourceAlpha, One, DestinationColor, OneMinusSourceColor, OneMinusSourceAlpha } = BlendFactor; + +/** + * Material for spine rendering. + * + * @remarks + * The shader lives as a built-in engine shader (`2D/Spine`, registered by the engine's + * `ShaderPool`). Per-material blend factors are driven through shader data properties + * (`sourceColorBlendFactor` etc.), which the `2D/Spine` shader binds into its `BlendState`. + * The constant render state (transparent queue, depth-write off, cull off) is declared in + * the shader, so this material only varies the blend factors per blend mode. + */ +export class SpineMaterial extends Material { + private static _sourceColorBlendFactorProp = ShaderProperty.getByName("sourceColorBlendFactor"); + private static _destinationColorBlendFactorProp = ShaderProperty.getByName("destinationColorBlendFactor"); + private static _sourceAlphaBlendFactorProp = ShaderProperty.getByName("sourceAlphaBlendFactor"); + private static _destinationAlphaBlendFactorProp = ShaderProperty.getByName("destinationAlphaBlendFactor"); + + /** + * @internal + * The key this material is registered under in `SpineAnimationRenderer._materialCacheMap`. + */ + _cacheKey: string; + + private _blendMode: SpineBlendMode = SpineBlendMode.Normal; + + /** + * @internal + */ + _setTintBlack(enabled: boolean) { + if (enabled) { + this.shaderData.enableMacro("RENDERER_TINT_BLACK"); + } else { + this.shaderData.disableMacro("RENDERER_TINT_BLACK"); + } + } + + /** + * @internal + */ + _setPremultipliedAlpha(enabled: boolean) { + this.shaderData.setFloat("renderer_PremultipliedAlpha", enabled ? 1 : 0); + } + + /** + * @internal + */ + _setTexture(value: Texture2D) { + this.shaderData.setTexture("material_SpineTexture", value); + } + + constructor(engine: Engine) { + super(engine, Shader.find("2D/Spine")); + this._setBlendMode(SpineBlendMode.Normal, false); + } + + /** + * @internal + */ + _setBlendMode(blendMode: SpineBlendMode, premultipliedAlpha: boolean) { + const { shaderData } = this; + const { + _sourceColorBlendFactorProp: srcColor, + _destinationColorBlendFactorProp: dstColor, + _sourceAlphaBlendFactorProp: srcAlpha, + _destinationAlphaBlendFactorProp: dstAlpha + } = SpineMaterial; + this._blendMode = blendMode; + switch (blendMode) { + case SpineBlendMode.Additive: + shaderData.setInt(srcColor, premultipliedAlpha ? One : SourceAlpha); + shaderData.setInt(dstColor, One); + shaderData.setInt(srcAlpha, One); + shaderData.setInt(dstAlpha, One); + break; + case SpineBlendMode.Multiply: + shaderData.setInt(srcColor, DestinationColor); + shaderData.setInt(dstColor, OneMinusSourceAlpha); + shaderData.setInt(srcAlpha, One); + shaderData.setInt(dstAlpha, OneMinusSourceAlpha); + break; + case SpineBlendMode.Screen: + shaderData.setInt(srcColor, One); + shaderData.setInt(dstColor, OneMinusSourceColor); + shaderData.setInt(srcAlpha, One); + shaderData.setInt(dstAlpha, OneMinusSourceColor); + break; + default: // Normal + shaderData.setInt(srcColor, premultipliedAlpha ? One : SourceAlpha); + shaderData.setInt(dstColor, OneMinusSourceAlpha); + shaderData.setInt(srcAlpha, One); + shaderData.setInt(dstAlpha, OneMinusSourceAlpha); + break; + } + } + + /** + * @internal + */ + _getBlendMode(): SpineBlendMode { + return this._blendMode; + } +} diff --git a/packages/spine/src/renderer/index.ts b/packages/spine/src/renderer/index.ts new file mode 100644 index 0000000000..aca750bda7 --- /dev/null +++ b/packages/spine/src/renderer/index.ts @@ -0,0 +1,2 @@ +export { SpineAnimationRenderer } from "./SpineAnimationRenderer"; +export { SpineMaterial } from "./SpineMaterial"; diff --git a/packages/spine/src/runtime/ISpineRenderTarget.ts b/packages/spine/src/runtime/ISpineRenderTarget.ts new file mode 100644 index 0000000000..50133ae9f8 --- /dev/null +++ b/packages/spine/src/runtime/ISpineRenderTarget.ts @@ -0,0 +1,30 @@ +import type { BoundingBox, Buffer, Material, SubPrimitive, Texture2D } from "@galacean/engine"; +import type { SpineBlendMode } from "../enums/SpineBlendMode"; + +/** + * The write target a spine core package's generator fills while building the renderable primitive. + * + * @remarks + * Implemented by `SpineAnimationRenderer`. This is the narrow, spine-core-free contract that lets the + * version-specific generator live in a core package while the buffer/material/bounds bookkeeping stays + * in the shared renderer — the analogue of how engine physics colliders expose a native handle. + */ +export interface ISpineRenderTarget { + readonly _vertices: Float32Array; + readonly _indices: Uint16Array; + readonly _vertexCount: number; + readonly _localBounds: BoundingBox; + readonly _subPrimitives: SubPrimitive[]; + readonly _vertexBuffer: Buffer; + readonly _indexBuffer: Buffer; + readonly zSpacing: number; + readonly premultipliedAlpha: boolean; + readonly tintBlack: boolean; + _needResizeBuffer: boolean; + + _createAndBindBuffer(vertexCount: number): void; + _addSubPrimitive(subPrimitive: SubPrimitive): void; + _clearSubPrimitives(): void; + _getMaterial(texture: Texture2D, blendMode: SpineBlendMode): Material; + setMaterial(index: number, material: Material): void; +} diff --git a/packages/spine/src/runtime/ISpineRuntime.ts b/packages/spine/src/runtime/ISpineRuntime.ts new file mode 100644 index 0000000000..8f3d4202ea --- /dev/null +++ b/packages/spine/src/runtime/ISpineRuntime.ts @@ -0,0 +1,52 @@ +import type { + AnimationState, + AnimationStateData, + Skeleton, + SkeletonData, + TextureAtlas +} from "@esotericsoftware/spine-core"; +import type { Texture2D } from "@galacean/engine"; +import type { ISpineRenderTarget } from "./ISpineRenderTarget"; + +/** + * The version-specific spine runtime backend. + * + * @remarks + * The engine-spine analogue of `IPhysics`: a narrow factory + stepping interface that a spine core + * package (e.g. `@galacean/engine-spine-core-4.2`) implements and registers via {@link registerSpineRuntime}. + * It owns everything that differs between spine runtime versions — parsing, object-model construction, + * world-transform stepping (e.g. spine 4.2's `Physics` argument), and the attachment-reading mesh + * generator — so the shared renderer/loader never reference a concrete spine-core version at runtime. + * + * The spine-core types in these signatures are the 4.2 line, used as the canonical type contract; a + * 3.8 backend returns structurally-3.8 objects typed against the same contract. + */ +export interface ISpineRuntime { + /** Parse the page names declared in an atlas text, used to resolve which page textures to load. */ + parseAtlasPageNames(atlasText: string): string[]; + + /** Build a `TextureAtlas`, binding the given engine textures to its pages (matched by page name). */ + createTextureAtlas(atlasText: string, textures: Texture2D[]): TextureAtlas; + + /** Parse skeleton data (json or binary) against an atlas. */ + createSkeletonData( + skeletonRawData: string | ArrayBuffer, + textureAtlas: TextureAtlas, + skeletonDataScale: number + ): SkeletonData; + + /** Create the shared animation state data for a skeleton data. */ + createAnimationStateData(skeletonData: SkeletonData): AnimationStateData; + + /** Create a skeleton instance. */ + createSkeleton(skeletonData: SkeletonData): Skeleton; + + /** Create an animation state instance. */ + createAnimationState(stateData: AnimationStateData): AnimationState; + + /** Advance the animation state and apply it to the skeleton, then update world transforms. */ + updateState(skeleton: Skeleton, state: AnimationState, delta: number): void; + + /** Build the renderable primitive for the current skeleton pose into the given target. */ + buildPrimitive(skeleton: Skeleton, target: ISpineRenderTarget): void; +} diff --git a/packages/spine/src/runtime/SpineRuntimeRegistry.ts b/packages/spine/src/runtime/SpineRuntimeRegistry.ts new file mode 100644 index 0000000000..df865bc687 --- /dev/null +++ b/packages/spine/src/runtime/SpineRuntimeRegistry.ts @@ -0,0 +1,25 @@ +import type { ISpineRuntime } from "./ISpineRuntime"; + +let _runtime: ISpineRuntime | null = null; + +/** + * Register the spine runtime backend. Called for its side effect when a spine core package + * (e.g. `@galacean/engine-spine-core-4.2`) is imported. The last registration wins. + */ +export function registerSpineRuntime(runtime: ISpineRuntime): void { + _runtime = runtime; +} + +/** + * Get the registered spine runtime backend. + * @throws If no core package has been imported. + */ +export function getSpineRuntime(): ISpineRuntime { + if (!_runtime) { + throw new Error( + "@galacean/engine-spine: no spine runtime registered. Import a spine core package, " + + 'e.g. `import "@galacean/engine-spine-core-4.2"`.' + ); + } + return _runtime; +} diff --git a/packages/spine/tsconfig.json b/packages/spine/tsconfig.json new file mode 100644 index 0000000000..588b0055c3 --- /dev/null +++ b/packages/spine/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declarationDir": "types", + "emitDeclarationOnly": true, + "noImplicitOverride": true, + "sourceMap": true, + "incremental": false, + "skipLibCheck": true, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 27aa07b7a3..f7ef671633 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-ui", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index f47e0a8053..90d0bda7a7 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,10 +1,67 @@ -import { Entity } from "@galacean/engine"; +import { Entity, Matrix, Plane, Ray, Vector2, Vector3 } from "@galacean/engine"; +import { UITransform } from "./component"; import { RootCanvasModifyFlags, UICanvas } from "./component/UICanvas"; import { GroupModifyFlags, UIGroup } from "./component/UIGroup"; +import { CanvasRenderMode } from "./enums/CanvasRenderMode"; import { IElement } from "./interface/IElement"; import { IGroupAble } from "./interface/IGroupAble"; export class Utils { + static _tempRay: Ray = new Ray(); + static _tempPlane: Plane = new Plane(); + static _tempVec3: Vector3 = new Vector3(); + static _tempMat: Matrix = new Matrix(); + + /** + * Local position of a screen point in the component + */ + static screenToLocalPoint(position: Vector2, transform: UITransform, out: Vector3): boolean { + const engine = transform.engine; + // Get root canvas + let entity = transform.entity; + let rootCanvas: UICanvas; + while (entity) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && component instanceof UICanvas && component._isRootCanvas) { + rootCanvas = component; + } + } + entity = entity.parent; + } + if (!rootCanvas) return false; + // Calculate ray + const ray = this._tempRay; + switch (rootCanvas._realRenderMode) { + case CanvasRenderMode.ScreenSpaceOverlay: + // Screen to world ( Assume that world units have a one-to-one relationship with pixel units ) + ray.origin.set(position.x, engine.canvas.height - position.y, 1); + ray.direction.set(0, 0, -1); + break; + case CanvasRenderMode.ScreenSpaceCamera: + rootCanvas.renderCamera.screenPointToRay(position, ray); + break; + default: + // World space not yet supported, see issue #2793 + return false; + } + // Intersect ray with UI plane to get local coordinates + const plane = this._tempPlane; + const normal = plane.normal.copyFrom(transform.worldForward); + plane.distance = -Vector3.dot(normal, transform.worldPosition); + const curDistance = ray.intersectPlane(plane); + if (curDistance >= 0 && curDistance < Number.MAX_SAFE_INTEGER) { + const hitPointWorld = ray.getPoint(curDistance, this._tempVec3); + const worldMatrixInv = this._tempMat; + Matrix.invert(transform.worldMatrix, worldMatrixInv); + Vector3.transformCoordinate(hitPointWorld, worldMatrixInv, out); + return true; + } + return false; + } + static setRootCanvasDirty(element: IElement): void { if (element._isRootCanvasDirty) return; element._isRootCanvasDirty = true; diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index a69cd846bf..b83e698608 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -14,8 +14,6 @@ import { RenderElement, Vector2, Vector3, - assignmentClone, - deepClone, dependentComponents, ignoreClone } from "@galacean/engine"; @@ -26,6 +24,7 @@ import { ResolutionAdaptationMode } from "../enums/ResolutionAdaptationMode"; import { UIHitResult } from "../input/UIHitResult"; import { IElement } from "../interface/IElement"; import { IGroupAble } from "../interface/IGroupAble"; +import { RectMask2D } from "./advanced/RectMask2D"; import { UIGroup } from "./UIGroup"; import { UIRenderer } from "./UIRenderer"; import { UITransform } from "./UITransform"; @@ -40,6 +39,7 @@ export class UICanvas extends Component implements IElement { /** @internal */ static _hierarchyCounter: number = 1; private static _tempGroupAbleList: IGroupAble[] = []; + private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); private static _tempMat: Matrix = new Matrix(); @@ -77,28 +77,21 @@ export class UICanvas extends Component implements IElement { @ignoreClone _realRenderMode: number = CanvasRealRenderMode.None; /** @internal */ - @ignoreClone _disorderedElements: DisorderedArray = new DisorderedArray(); @ignoreClone private _renderMode = CanvasRenderMode.WorldSpace; private _camera: Camera; private _cameraObserver: Camera; - @assignmentClone private _resolutionAdaptationMode = ResolutionAdaptationMode.HeightAdaptation; - @assignmentClone private _sortOrder: number = 0; - @assignmentClone private _distance: number = 10; - @deepClone private _referenceResolution: Vector2 = new Vector2(800, 600); - @assignmentClone private _referenceResolutionPerUnit: number = 100; @ignoreClone private _hierarchyVersion: number = -1; @ignoreClone private _center: Vector3 = new Vector3(); - @ignoreClone private _centerDirtyFlag: BoolUpdateFlag; /** @@ -435,7 +428,8 @@ export class UICanvas extends Component implements IElement { const { _orderedRenderers: renderers, entity } = this; const uiHierarchyVersion = entity._uiHierarchyVersion; if (this._hierarchyVersion !== uiHierarchyVersion) { - renderers.length = this._walk(this.entity, renderers); + UICanvas._tempRectMaskList.length = 0; + renderers.length = this._walk(this.entity, renderers, 0, null, 0); UICanvas._tempGroupAbleList.length = 0; this._hierarchyVersion = uiHierarchyVersion; ++UICanvas._hierarchyCounter; @@ -517,10 +511,18 @@ export class UICanvas extends Component implements IElement { transform.size.set(curWidth / expectX, curHeight / expectY); } - private _walk(entity: Entity, renderers: UIRenderer[], depth = 0, group: UIGroup = null): number { + private _walk( + entity: Entity, + renderers: UIRenderer[], + depth = 0, + group: UIGroup = null, + rectMaskCount: number = 0 + ): number { // @ts-ignore const components: Component[] = entity._components; const tempGroupAbleList = UICanvas._tempGroupAbleList; + const tempRectMaskList = UICanvas._tempRectMaskList; + let rectMask: RectMask2D = null; let groupAbleCount = 0; for (let i = 0, n = components.length; i < n; i++) { const component = components[i]; @@ -532,11 +534,14 @@ export class UICanvas extends Component implements IElement { if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + component._setRectMasks(tempRectMaskList, rectMaskCount); } else if (component instanceof UIInteractive) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + } else if (component instanceof RectMask2D) { + rectMask = component; } else if (component instanceof UIGroup) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); component._isGroupDirty && Utils.setGroup(component, group); @@ -546,10 +551,13 @@ export class UICanvas extends Component implements IElement { for (let i = 0; i < groupAbleCount; i++) { Utils.setGroup(tempGroupAbleList[i], group); } + if (rectMask) { + tempRectMaskList[rectMaskCount++] = rectMask; + } const children = entity.children; for (let i = 0, n = children.length; i < n; i++) { const child = children[i]; - child.isActive && (depth = this._walk(child, renderers, depth, group)); + child.isActive && (depth = this._walk(child, renderers, depth, group, rectMaskCount)); } return depth; } diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts index b876e05ec4..99b59f3d12 100644 --- a/packages/ui/src/component/UIGroup.ts +++ b/packages/ui/src/component/UIGroup.ts @@ -1,4 +1,4 @@ -import { Component, DisorderedArray, Entity, EntityModifyFlags, assignmentClone, ignoreClone } from "@galacean/engine"; +import { Component, DisorderedArray, Entity, EntityModifyFlags, ignoreClone } from "@galacean/engine"; import { Utils } from "../Utils"; import { IGroupAble } from "../interface/IGroupAble"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; @@ -15,7 +15,6 @@ export class UIGroup extends Component implements IGroupAble { /** @internal */ _rootCanvas: UICanvas; /** @internal */ - @ignoreClone _disorderedElements: DisorderedArray = new DisorderedArray(); /** @internal */ @@ -25,11 +24,8 @@ export class UIGroup extends Component implements IGroupAble { @ignoreClone _globalInteractive = true; - @assignmentClone private _alpha = 1; - @assignmentClone private _interactive = true; - @assignmentClone private _ignoreParentGroup = false; /** @internal */ diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 82af6480bd..e090b503aa 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -11,22 +11,26 @@ import { RendererUpdateFlags, ShaderMacroCollection, ShaderProperty, + SpriteMaskInteraction, + SpriteMaskLayer, Vector3, Vector4, - assignmentClone, - deepClone, dependentComponents, - ignoreClone + ignoreClone, + Vector2 } from "@galacean/engine"; import { Utils } from "../Utils"; import { UIHitResult } from "../input/UIHitResult"; import { IGraphics } from "../interface/IGraphics"; +import { RectMask2D } from "./advanced/RectMask2D"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; import { GroupModifyFlags, UIGroup } from "./UIGroup"; import { UITransform } from "./UITransform"; @dependentComponents(UITransform, DependentMode.AutoAdd) export class UIRenderer extends Renderer implements IGraphics { + /** @internal */ + static _tempVec20: Vector2 = new Vector2(); /** @internal */ static _tempVec30: Vector3 = new Vector3(); /** @internal */ @@ -37,12 +41,21 @@ export class UIRenderer extends Renderer implements IGraphics { static _tempPlane: Plane = new Plane(); /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_UITexture"); + /** @internal */ + static _rectClipRectProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipRect"); + /** @internal */ + static _rectClipEnabledProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipEnabled"); + /** @internal */ + static _rectClipSoftnessProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipSoftness"); + /** @internal */ + static _rectClipHardClipProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipHardClip"); + /** @internal */ + static _tempRect: Vector4 = new Vector4(); /** * Custom boundary for raycast detection. * @remarks this is based on `this.entity.transform`. */ - @deepClone raycastPadding: Vector4 = new Vector4(0, 0, 0, 0); /** @internal */ _rootCanvas: UICanvas; @@ -69,10 +82,23 @@ export class UIRenderer extends Renderer implements IGraphics { /** @internal */ @ignoreClone _subChunk; + /** @internal */ + @ignoreClone + _rectMasks: RectMask2D[] = []; + /** @internal */ + @ignoreClone + _rectMaskRect: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskEnabled: boolean = false; + /** @internal */ + @ignoreClone + _rectMaskSoftness: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskHardClip: boolean = false; - @assignmentClone - private _raycastEnabled: boolean = true; - @deepClone + private _raycastEnabled: boolean = false; protected _color: Color = new Color(1, 1, 1, 1); /** @@ -88,6 +114,30 @@ export class UIRenderer extends Renderer implements IGraphics { } } + /** + * The mask layer the ui renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + /** * Whether this renderer be picked up by raycast. */ @@ -110,6 +160,9 @@ export class UIRenderer extends Renderer implements IGraphics { this._color._onValueChanged = this._onColorChanged; this._groupListener = this._groupListener.bind(this); this._rootCanvasListener = this._rootCanvasListener.bind(this); + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, this._rectMaskSoftness); + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); } // @ts-ignore @@ -135,6 +188,7 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } + this._updateRectMaskClipState(); this._render(context); // union camera global macro and renderer macro. @@ -237,6 +291,17 @@ export class UIRenderer extends Renderer implements IGraphics { return this.engine._batcherManager.primitiveChunkManagerUI; } + /** + * @internal + */ + _setRectMasks(rectMasks: RectMask2D[], count: number): void { + const targetMasks = this._rectMasks; + targetMasks.length = count; + for (let i = 0; i < count; i++) { + targetMasks[i] = rectMasks[i]; + } + } + /** * @internal */ @@ -252,7 +317,11 @@ export class UIRenderer extends Renderer implements IGraphics { Matrix.invert(transform.worldMatrix, worldMatrixInv); const localPosition = UIRenderer._tempVec31; Vector3.transformCoordinate(hitPointWorld, worldMatrixInv, localPosition); - if (this._hitTest(localPosition)) { + if ( + this._hitTest(localPosition) && + this._isRaycastVisibleByRectMask(hitPointWorld) && + this._isRaycastVisibleByMask(hitPointWorld) + ) { out.component = this; out.distance = curDistance; out.entity = this.entity; @@ -278,6 +347,143 @@ export class UIRenderer extends Renderer implements IGraphics { ); } + private _isRaycastVisibleByMask(hitPointWorld: Vector3): boolean { + const maskInteraction = this._maskInteraction; + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + // @ts-ignore + return this.scene._maskManager.isVisibleByMask(maskInteraction, this._maskLayer, hitPointWorld); + } + + private _isRaycastVisibleByRectMask(hitPointWorld: Vector3): boolean { + const rectMasks = this._rectMasks; + for (let i = 0, n = rectMasks.length; i < n; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + if (!rectMask._containsWorldPoint(hitPointWorld)) { + return false; + } + } + return true; + } + + private _updateRectMaskClipState(): void { + const rectMasks = this._rectMasks; + const count = rectMasks.length; + if (count <= 0) { + this._resetRectMaskClipState(); + return; + } + + let minX = Number.NEGATIVE_INFINITY; + let minY = Number.NEGATIVE_INFINITY; + let maxX = Number.POSITIVE_INFINITY; + let maxY = Number.POSITIVE_INFINITY; + let clipSoftnessLeft = 0; + let clipSoftnessBottom = 0; + let clipSoftnessRight = 0; + let clipSoftnessTop = 0; + let clipHardClip = false; + let hasActiveMask = false; + const tempRect = UIRenderer._tempRect; + for (let i = 0; i < count; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + hasActiveMask = true; + const softness = rectMask.softness; + if (!clipHardClip && rectMask.alphaClip) { + clipHardClip = true; + } + if (!rectMask._getWorldRect(tempRect)) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + break; + } + if (tempRect.x > minX) { + minX = tempRect.x; + clipSoftnessLeft = softness.x; + } + if (tempRect.y > minY) { + minY = tempRect.y; + clipSoftnessBottom = softness.y; + } + if (tempRect.z < maxX) { + maxX = tempRect.z; + clipSoftnessRight = softness.x; + } + if (tempRect.w < maxY) { + maxY = tempRect.w; + clipSoftnessTop = softness.y; + } + } + + if (!hasActiveMask) { + this._resetRectMaskClipState(); + return; + } + + if (minX >= maxX || minY >= maxY) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + clipSoftnessLeft = 0; + clipSoftnessBottom = 0; + clipSoftnessRight = 0; + clipSoftnessTop = 0; + } + + const rectMaskRect = this._rectMaskRect; + if (rectMaskRect.x !== minX || rectMaskRect.y !== minY || rectMaskRect.z !== maxX || rectMaskRect.w !== maxY) { + rectMaskRect.set(minX, minY, maxX, maxY); + this.shaderData.setVector4(UIRenderer._rectClipRectProperty, rectMaskRect); + } + + const rectMaskSoftness = this._rectMaskSoftness; + if ( + rectMaskSoftness.x !== clipSoftnessLeft || + rectMaskSoftness.y !== clipSoftnessBottom || + rectMaskSoftness.z !== clipSoftnessRight || + rectMaskSoftness.w !== clipSoftnessTop + ) { + rectMaskSoftness.set(clipSoftnessLeft, clipSoftnessBottom, clipSoftnessRight, clipSoftnessTop); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + + if (this._rectMaskHardClip !== clipHardClip) { + this._rectMaskHardClip = clipHardClip; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, clipHardClip ? 1 : 0); + } + + if (!this._rectMaskEnabled) { + this._rectMaskEnabled = true; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 1); + } + } + + private _resetRectMaskClipState(): void { + if (this._rectMaskEnabled) { + this._rectMaskEnabled = false; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + } + const rectMaskSoftness = this._rectMaskSoftness; + if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + rectMaskSoftness.set(0, 0, 0, 0); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + if (this._rectMaskHardClip) { + this._rectMaskHardClip = false; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); + } + } + protected override _onDestroy(): void { if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); @@ -287,6 +493,8 @@ export class UIRenderer extends Renderer implements IGraphics { //@ts-ignore this._color._onValueChanged = null; this._color = null; + this._rectMasks = null; + this._rectMaskSoftness = null; } } diff --git a/packages/ui/src/component/UITransform.ts b/packages/ui/src/component/UITransform.ts index 9144373472..366ef3aaa2 100644 --- a/packages/ui/src/component/UITransform.ts +++ b/packages/ui/src/component/UITransform.ts @@ -1,13 +1,4 @@ -import { - Entity, - MathUtil, - Rect, - Transform, - TransformModifyFlags, - Vector2, - deepClone, - ignoreClone -} from "@galacean/engine"; +import { Entity, MathUtil, Rect, Transform, TransformModifyFlags, Vector2, ignoreClone } from "@galacean/engine"; import { HorizontalAlignmentMode } from "../enums/HorizontalAlignmentMode"; import { VerticalAlignmentMode } from "../enums/VerticalAlignmentMode"; @@ -19,7 +10,6 @@ export class UITransform extends Transform { private _size = new Vector2(100, 100); @ignoreClone private _pivot = new Vector2(0.5, 0.5); - @deepClone private _rect = new Rect(-50, -50, 100, 100); private _alignLeft = 0; diff --git a/packages/ui/src/component/advanced/Button.ts b/packages/ui/src/component/advanced/Button.ts index 73bf817562..c407c7a9cf 100644 --- a/packages/ui/src/component/advanced/Button.ts +++ b/packages/ui/src/component/advanced/Button.ts @@ -1,9 +1,8 @@ -import { deepClone, PointerEventData, Signal } from "@galacean/engine"; +import { PointerEventData, Signal } from "@galacean/engine"; import { UIInteractive } from "../interactive/UIInteractive"; export class Button extends UIInteractive { /** Signal emitted when the button is clicked. */ - @deepClone readonly onClick = new Signal<[PointerEventData]>(); override onPointerClick(event: PointerEventData): void { diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index b364a2e303..a58bc81916 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -1,6 +1,7 @@ import { BoundingBox, Entity, + FilledSpriteAssembler, ISpriteAssembler, ISpriteRenderer, MathUtil, @@ -9,10 +10,11 @@ import { SlicedSpriteAssembler, Sprite, SpriteDrawMode, + SpriteFilledMode, + SpriteFilledOrigin, SpriteModifyFlags, SpriteTileMode, TiledSpriteAssembler, - assignmentClone, ignoreClone } from "@galacean/engine"; import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; @@ -30,10 +32,12 @@ export class Image extends UIRenderer implements ISpriteRenderer { private _drawMode: SpriteDrawMode; @ignoreClone private _assembler: ISpriteAssembler; - @assignmentClone private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; - @assignmentClone private _tiledAdaptiveThreshold: number = 0.5; + private _filledMode: SpriteFilledMode = SpriteFilledMode.Radial360; + private _filledAmount: number = 1; + private _filledOrigin: SpriteFilledOrigin = SpriteFilledOrigin.Bottom; + private _filledClockWise: boolean = true; /** * The draw mode of the image. @@ -55,6 +59,9 @@ export class Image extends UIRenderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._assembler = TiledSpriteAssembler; break; + case SpriteDrawMode.Filled: + this._assembler = FilledSpriteAssembler; + break; default: break; } @@ -96,6 +103,73 @@ export class Image extends UIRenderer implements ISpriteRenderer { } } + /** + * The fill amount of the image, range from 0 to 1. (Only works in filled mode.) + */ + get filledAmount(): number { + return this._filledAmount; + } + + set filledAmount(value: number) { + value = MathUtil.clamp(value, 0, 1); + if (this._filledAmount !== value) { + this._filledAmount = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill mode of the image. (Only works in filled mode.) + */ + get filledMode(): SpriteFilledMode { + return this._filledMode; + } + + set filledMode(value: SpriteFilledMode) { + if (this._filledMode !== value) { + this._filledMode = value; + this._filledOrigin = Image._correctOrigin(value, this._filledOrigin); + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * The fill origin of the image. (Only works in filled mode.) + */ + get filledOrigin(): SpriteFilledOrigin { + return this._filledOrigin; + } + + set filledOrigin(value: SpriteFilledOrigin) { + value = Image._correctOrigin(this._filledMode, value); + if (this._filledOrigin !== value) { + this._filledOrigin = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; + } + } + } + + /** + * Whether the fill is clockwise. (Only works in filled radial mode.) + */ + get filledClockWise(): boolean { + return this._filledClockWise; + } + + set filledClockWise(value: boolean) { + if (this._filledClockWise !== value) { + this._filledClockWise = value; + if (this._drawMode === SpriteDrawMode.Filled) { + this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; + } + } + } + /** * The Sprite to render. */ @@ -247,7 +321,10 @@ export class Image extends UIRenderer implements ISpriteRenderer { @ignoreClone protected override _onTransformChanged(type: number): void { - if (type & UITransformModifyFlags.Size && this._drawMode === SpriteDrawMode.Tiled) { + if ( + type & UITransformModifyFlags.Size && + (this._drawMode === SpriteDrawMode.Tiled || this._drawMode === SpriteDrawMode.Filled) + ) { this._dirtyUpdateFlag |= ImageUpdateFlags.All; } this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; @@ -278,6 +355,9 @@ export class Image extends UIRenderer implements ISpriteRenderer { case SpriteDrawMode.Tiled: this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; break; + case SpriteDrawMode.Filled: + this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; + break; default: break; } @@ -299,13 +379,41 @@ export class Image extends UIRenderer implements ISpriteRenderer { this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; break; case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= ImageUpdateFlags.UV; + this._dirtyUpdateFlag |= + this._drawMode === SpriteDrawMode.Filled ? ImageUpdateFlags.WorldVolumeAndUV : ImageUpdateFlags.UV; break; case SpriteModifyFlags.destroy: this.sprite = null; break; } } + + private static _correctOrigin(mode: SpriteFilledMode, origin: SpriteFilledOrigin): SpriteFilledOrigin { + switch (mode) { + case SpriteFilledMode.Horizontal: + return origin === SpriteFilledOrigin.Left || origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Left; + case SpriteFilledMode.Vertical: + return origin === SpriteFilledOrigin.Top || origin === SpriteFilledOrigin.Bottom + ? origin + : SpriteFilledOrigin.Bottom; + case SpriteFilledMode.Radial90: + return origin === SpriteFilledOrigin.TopLeft || + origin === SpriteFilledOrigin.TopRight || + origin === SpriteFilledOrigin.BottomLeft || + origin === SpriteFilledOrigin.BottomRight + ? origin + : SpriteFilledOrigin.BottomLeft; + default: + return origin === SpriteFilledOrigin.Top || + origin === SpriteFilledOrigin.Bottom || + origin === SpriteFilledOrigin.Left || + origin === SpriteFilledOrigin.Right + ? origin + : SpriteFilledOrigin.Bottom; + } + } } /** diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts new file mode 100644 index 0000000000..cff2fd85ab --- /dev/null +++ b/packages/ui/src/component/advanced/Mask.ts @@ -0,0 +1,80 @@ +import { BoundingBox, Entity, MaskRenderable, Vector2 } from "@galacean/engine"; +import type { IMaskRenderable } from "@galacean/engine"; +import { UIRenderer } from "../UIRenderer"; +import { UITransform } from "../UITransform"; + +/** + * UI component that uses a sprite to mask child UI renderers via stencil. + */ +export class Mask extends MaskRenderable(UIRenderer) { + /** + * @internal + */ + override _getChunkManager() { + // @ts-ignore + return this.engine._batcherManager.primitiveChunkManagerMask; + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + this._initMask(); + this.raycastEnabled = false; + } + + /** + * @internal + */ + // @ts-ignore + _cloneTo(target: Mask): void { + // @ts-ignore + super._cloneTo(target); + this._cloneMaskData(target); + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const rootCanvas = this._getRootCanvas(); + if (this.sprite && rootCanvas) { + this._updateMaskBounds(worldBounds); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + /** + * @inheritdoc + */ + protected override _render(context): void { + this._renderMask(0); + } + + /** + * @inheritdoc + */ + protected override _onDestroy(): void { + this._destroyMaskResources(); + + super._onDestroy(); + + if (this._subChunk) { + this._getChunkManager().freeSubChunk(this._subChunk); + this._subChunk = null; + } + } + + override _getSpriteWidth(): number { + return (this._transformEntity.transform).size.x; + } + + override _getSpriteHeight(): number { + return (this._transformEntity.transform).size.y; + } + + override _getSpritePivot(): Vector2 { + return (this._transformEntity.transform).pivot; + } +} diff --git a/packages/ui/src/component/advanced/RectMask2D.ts b/packages/ui/src/component/advanced/RectMask2D.ts new file mode 100644 index 0000000000..44e591873e --- /dev/null +++ b/packages/ui/src/component/advanced/RectMask2D.ts @@ -0,0 +1,145 @@ +import { Component, DependentMode, Entity, Vector2, Vector3, Vector4, dependentComponents } from "@galacean/engine"; +import { UICanvas } from "../UICanvas"; +import { UITransform } from "../UITransform"; + +/** + * UI component that clips descendant graphics by an axis-aligned rectangle. + */ +@dependentComponents(UITransform, DependentMode.AutoAdd) +export class RectMask2D extends Component { + private static _tempRect: Vector4 = new Vector4(); + private static _tempCorner0: Vector3 = new Vector3(); + private static _tempCorner1: Vector3 = new Vector3(); + private static _tempCorner2: Vector3 = new Vector3(); + private static _tempCorner3: Vector3 = new Vector3(); + + private _softness: Vector2 = new Vector2(0, 0); + private _alphaClip: boolean = false; + + /** + * Soft clipping width on X/Y axis in world space. + */ + get softness(): Vector2 { + return this._softness; + } + + set softness(value: Vector2) { + const softness = this._softness; + if (softness === value) { + return; + } + if (softness.x !== value.x || softness.y !== value.y) { + softness.copyFrom(value); + this._clampSoftness(); + } + } + + /** + * Whether to enable hard clip (discard) when outside the rect. + */ + get alphaClip(): boolean { + return this._alphaClip; + } + + set alphaClip(value: boolean) { + this._alphaClip = value; + } + + /** + * @internal + */ + _getWorldRect(out: Vector4): boolean { + const transform = this.entity.transform; + const { x: width, y: height } = transform.size; + if (!width || !height) { + return false; + } + + const { x: pivotX, y: pivotY } = transform.pivot; + const left = -width * pivotX; + const right = width * (1 - pivotX); + const bottom = -height * pivotY; + const top = height * (1 - pivotY); + + const worldMatrix = transform.worldMatrix; + const corner0 = RectMask2D._tempCorner0; + const corner1 = RectMask2D._tempCorner1; + const corner2 = RectMask2D._tempCorner2; + const corner3 = RectMask2D._tempCorner3; + Vector3.transformCoordinate(corner0.set(left, bottom, 0), worldMatrix, corner0); + Vector3.transformCoordinate(corner1.set(left, top, 0), worldMatrix, corner1); + Vector3.transformCoordinate(corner2.set(right, bottom, 0), worldMatrix, corner2); + Vector3.transformCoordinate(corner3.set(right, top, 0), worldMatrix, corner3); + + const minX = Math.min(corner0.x, corner1.x, corner2.x, corner3.x); + const minY = Math.min(corner0.y, corner1.y, corner2.y, corner3.y); + const maxX = Math.max(corner0.x, corner1.x, corner2.x, corner3.x); + const maxY = Math.max(corner0.y, corner1.y, corner2.y, corner3.y); + out.set(minX, minY, maxX, maxY); + return true; + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + const worldRect = RectMask2D._tempRect; + if (!this._getWorldRect(worldRect)) { + return false; + } + const { x, y } = worldPoint; + return x >= worldRect.x && x <= worldRect.z && y >= worldRect.y && y <= worldRect.w; + } + + constructor(entity: Entity) { + super(entity); + this._onSoftnessChanged = this._onSoftnessChanged.bind(this); + // @ts-ignore + this._softness._onValueChanged = this._onSoftnessChanged; + } + + // @ts-ignore + override _onEnableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _onDisableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _cloneTo(target: RectMask2D): void { + // RectMask2D extends Component directly; Component.prototype 上没有 _cloneTo, + // 不能 super._cloneTo(target) — 会拿到 undefined 报 "Cannot read properties of undefined (reading 'call')"。 + // (Image/Mask 走 Renderer 链路所以能 super;RectMask2D 不在 Renderer 链路里。) + const targetSoftness = target._softness; + // @ts-ignore + targetSoftness._onValueChanged = null; + targetSoftness.copyFrom(this._softness); + target._clampSoftness(); + // @ts-ignore + targetSoftness._onValueChanged = target._onSoftnessChanged; + } + + protected override _onDestroy(): void { + // @ts-ignore + this._softness._onValueChanged = null; + this._softness = null; + super._onDestroy(); + } + + private _onSoftnessChanged(): void { + this._clampSoftness(); + } + + private _clampSoftness(): void { + const softness = this._softness; + if (softness.x < 0) { + softness.x = 0; + } + if (softness.y < 0) { + softness.y = 0; + } + } +} diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index b534253a9a..d2ae7bec78 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -1,6 +1,7 @@ import { BoundingBox, CharRenderInfo, + Color, Engine, Entity, Font, @@ -17,10 +18,9 @@ import { TextVerticalAlignment, Texture2D, Vector3, - assignmentClone, + VertexMergeBatcher, ignoreClone } from "@galacean/engine"; -import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; import { UIRenderer, UIRendererUpdateFlags } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; @@ -30,6 +30,9 @@ import { UITransform, UITransformModifyFlags } from "../UITransform"; */ export class Text extends UIRenderer implements ITextRenderer { private static _textTextureProperty = ShaderProperty.getByName("renderElement_TextTexture"); + private static _textTextureSizeProperty = ShaderProperty.getByName("renderElement_TextTextureSize"); + private static _outlineColorProperty = ShaderProperty.getByName("renderer_OutlineColor"); + private static _outlineWidthProperty = ShaderProperty.getByName("renderer_OutlineWidth"); private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; private static _charRenderInfos: CharRenderInfo[] = []; @@ -37,28 +40,20 @@ export class Text extends UIRenderer implements ITextRenderer { private _textChunks = Array(); @ignoreClone private _subFont: SubFont = null; - @assignmentClone private _text: string = ""; @ignoreClone private _localBounds: BoundingBox = new BoundingBox(); - @assignmentClone private _font: Font = null; - @assignmentClone private _fontSize: number = 24; - @assignmentClone private _fontStyle: FontStyle = FontStyle.None; - @assignmentClone private _lineSpacing: number = 0; - @assignmentClone private _characterSpacing: number = 0; - @assignmentClone private _horizontalAlignment: TextHorizontalAlignment = TextHorizontalAlignment.Center; - @assignmentClone private _verticalAlignment: TextVerticalAlignment = TextVerticalAlignment.Center; - @assignmentClone private _enableWrapping: boolean = false; - @assignmentClone private _overflowMode: OverflowMode = OverflowMode.Overflow; + private _outlineColor: Color = new Color(0, 0, 0, 1); + private _outlineWidth: number = 0; /** * Rendering string for the Text. @@ -205,14 +200,32 @@ export class Text extends UIRenderer implements ITextRenderer { } /** - * The mask layer the sprite renderer belongs to. + * The outline width in pixels. 0 means outline is disabled. Clamped to [0, 8]. */ - get maskLayer(): number { - return this._maskLayer; + get outlineWidth(): number { + return this._outlineWidth; } - set maskLayer(value: number) { - this._maskLayer = value; + set outlineWidth(value: number) { + value = Math.max(0, Math.min(value, 3)); + if (this._outlineWidth !== value) { + this._outlineWidth = value; + this.shaderData.setFloat(Text._outlineWidthProperty, value); + this._setDirtyFlagTrue(DirtyFlag.Position); + } + } + + /** + * The outline color. Only effective when outlineWidth > 0. + */ + get outlineColor(): Color { + return this._outlineColor; + } + + set outlineColor(value: Color) { + if (this._outlineColor !== value) { + this._outlineColor.copyFrom(value); + } } /** @@ -222,8 +235,14 @@ export class Text extends UIRenderer implements ITextRenderer { if (this._isTextNoVisible()) { if (this._isContainDirtyFlag(RendererUpdateFlags.WorldVolume)) { const localBounds = this._localBounds; - localBounds.min.set(0, 0, 0); - localBounds.max.set(0, 0, 0); + if (this._getRootCanvas()) { + localBounds.min.set(0, 0, 0); + localBounds.max.set(0, 0, 0); + } else { + const { size, pivot } = this.entity.transform; + localBounds.min.set(-size.x * pivot.x, -size.y * pivot.y, 0); + localBounds.max.set(size.x * (1 - pivot.x), size.y * (1 - pivot.y), 0); + } this._updateBounds(this._bounds); this._setDirtyFlagFalse(RendererUpdateFlags.WorldVolume); } @@ -246,6 +265,11 @@ export class Text extends UIRenderer implements ITextRenderer { this.raycastEnabled = false; // @ts-ignore this.setMaterial(engine._basicResources.textDefaultMaterial); + const shaderData = this.shaderData; + shaderData.setFloat(Text._outlineWidthProperty, this._outlineWidth); + shaderData.setColor(Text._outlineColorProperty, this._outlineColor); + // @ts-ignore + this._outlineColor._onValueChanged = this._onOutlineColorChanged.bind(this); } /** @@ -265,14 +289,6 @@ export class Text extends UIRenderer implements ITextRenderer { this._subFont && (this._subFont = null); } - // @ts-ignore - override _cloneTo(target: Text): void { - // @ts-ignore - super._cloneTo(target); - target.font = this._font; - target._subFont = this._subFont; - } - /** * @internal */ @@ -313,13 +329,15 @@ export class Text extends UIRenderer implements ITextRenderer { } } + /** + * @internal + */ + override _canBatch(preElement, curElement): boolean { + return VertexMergeBatcher.canBatchText(preElement, curElement); + } + protected override _updateBounds(worldBounds: BoundingBox): void { - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - const { x: pivotX, y: pivotY } = transform.pivot; - worldBounds.min.set(-width * pivotX, -height * pivotY, 0); - worldBounds.max.set(width * (1 - pivotX), height * (1 - pivotY), 0); - BoundingBox.transform(worldBounds, this._transformEntity.transform.worldMatrix, worldBounds); + BoundingBox.transform(this._localBounds, this._transformEntity.transform.worldMatrix, worldBounds); } protected override _render(context): void { @@ -356,6 +374,7 @@ export class Text extends UIRenderer implements ITextRenderer { const distanceForSort = canvas._sortDistance; const textChunks = this._textChunks; const subShader = material.shader.subShaders[0]; + const textTextureSize = Text._tempVec20; for (let i = 0, n = textChunks.length; i < n; ++i) { const { subChunk, texture } = textChunks[i]; const renderElement = textRenderElementPool.get(); @@ -363,6 +382,10 @@ export class Text extends UIRenderer implements ITextRenderer { // @ts-ignore renderElement.shaderData ||= new ShaderData(ShaderDataGroup.RenderElement); renderElement.shaderData.setTexture(Text._textTextureProperty, texture); + renderElement.shaderData.setVector2( + Text._textTextureSizeProperty, + textTextureSize.set(texture.width, texture.height) + ); renderElement.subShader = subShader; renderElement.priority = priority; renderElement.distanceForSort = distanceForSort; @@ -377,6 +400,17 @@ export class Text extends UIRenderer implements ITextRenderer { this._subFont.nativeFontString = TextUtils.getNativeFontString(font.name, this.fontSize, this.fontStyle); } + /** + * Switch the sub font to a specific font size, used by the SHRINK overflow measurement. + */ + private _applyFontSizeForShrink(fontSize: number): void { + const font = this._font; + // @ts-ignore + const subFont = font._getSubFont(fontSize, this._fontStyle); + subFont.nativeFontString = TextUtils.getNativeFontString(font.name, fontSize, this._fontStyle); + this._subFont = subFont; + } + private _updatePosition(): void { const e = this._transformEntity.transform.worldMatrix.elements; @@ -447,27 +481,45 @@ export class Text extends UIRenderer implements ITextRenderer { const pixelsPerResolution = Engine._pixelsPerUnit / this._getRootCanvas().referenceResolutionPerUnit; const { min, max } = this._localBounds; const charRenderInfos = Text._charRenderInfos; - const charFont = this._getSubFont(); const { size, pivot } = this._transformEntity.transform; let rendererWidth = size.x; let rendererHeight = size.y; const offsetWidth = rendererWidth * (0.5 - pivot.x); const offsetHeight = rendererHeight * (0.5 - pivot.y); - const characterSpacing = this._characterSpacing * this._fontSize; - const textMetrics = this.enableWrapping - ? TextUtils.measureTextWithWrap( - this, - rendererWidth * pixelsPerResolution, - rendererHeight * pixelsPerResolution, - this._lineSpacing * this._fontSize, - characterSpacing - ) - : TextUtils.measureTextWithoutWrap( - this, - rendererHeight * pixelsPerResolution, - this._lineSpacing * this._fontSize, - characterSpacing - ); + let fontSize = this._fontSize; + let textMetrics: ReturnType; + if (this._overflowMode === OverflowMode.Shrink) { + const result = TextUtils.measureTextWithShrink( + this, + rendererWidth * pixelsPerResolution, + rendererHeight * pixelsPerResolution, + this._fontSize, + this._lineSpacing, + this._characterSpacing, + this.enableWrapping, + (sizeValue) => this._applyFontSizeForShrink(sizeValue) + ); + fontSize = result.fontSize; + textMetrics = result.metrics; + } else { + const characterSpacing = this._characterSpacing * fontSize; + textMetrics = this.enableWrapping + ? TextUtils.measureTextWithWrap( + this, + rendererWidth * pixelsPerResolution, + rendererHeight * pixelsPerResolution, + this._lineSpacing * fontSize, + characterSpacing + ) + : TextUtils.measureTextWithoutWrap( + this, + rendererHeight * pixelsPerResolution, + this._lineSpacing * fontSize, + characterSpacing + ); + } + const charFont = this._getSubFont(); + const characterSpacing = this._characterSpacing * fontSize; const { height, lines, lineWidths, lineHeight, lineMaxSizes } = textMetrics; // @ts-ignore const charRenderInfoPool = this.engine._charRenderInfoPool; @@ -490,7 +542,10 @@ export class Text extends UIRenderer implements ITextRenderer { startY = rendererHeight * 0.5 - halfLineHeight + topDiff; break; case TextVerticalAlignment.Center: - startY = height * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; + // Center the text block (lineHeight * lineCount) within the renderer, independent of + // `height` — which equals the renderer height for Truncate/Shrink and would otherwise + // push the text upward by (rendererHeight - blockHeight) / 2 when the box is taller. + startY = lineHeight * linesLen * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; break; case TextVerticalAlignment.Bottom: startY = height - rendererHeight * 0.5 - halfLineHeight - bottomDiff; @@ -532,10 +587,11 @@ export class Text extends UIRenderer implements ITextRenderer { charRenderInfo.texture = charFont._getTextureByIndex(charInfo.index); charRenderInfo.uvs = charInfo.uvs; const { w, ascent, descent } = charInfo; - const left = (startX + offsetWidth) * pixelsPerUnitReciprocal; - const right = (startX + w + offsetWidth) * pixelsPerUnitReciprocal; - const top = (startY + ascent + offsetHeight) * pixelsPerUnitReciprocal; - const bottom = (startY - descent + offsetHeight) * pixelsPerUnitReciprocal; + const ow = this._outlineWidth * pixelsPerUnitReciprocal; + const left = (startX + offsetWidth) * pixelsPerUnitReciprocal - ow; + const right = (startX + w + offsetWidth) * pixelsPerUnitReciprocal + ow; + const top = (startY + ascent + offsetHeight) * pixelsPerUnitReciprocal + ow; + const bottom = (startY - descent + offsetHeight) * pixelsPerUnitReciprocal - ow; localPositions.set(left, top, right, bottom); i === firstLine && (maxY = Math.max(maxY, top)); minY = Math.min(minY, bottom); @@ -618,7 +674,7 @@ export class Text extends UIRenderer implements ITextRenderer { this._text === "" || this._fontSize === 0 || (this.enableWrapping && size.x <= 0) || - (this.overflowMode === OverflowMode.Truncate && size.y <= 0) || + ((this.overflowMode === OverflowMode.Truncate || this.overflowMode === OverflowMode.Shrink) && size.y <= 0) || !this._getRootCanvas() ); } @@ -632,6 +688,10 @@ export class Text extends UIRenderer implements ITextRenderer { const vertices = subChunk.chunk.vertices; const indices = (subChunk.indices = []); const charRenderInfos = textChunk.charRenderInfos; + const ow = this._outlineWidth; + const texture = textChunk.texture; + const owU = ow > 0 ? ow / texture.width : 0; + const owV = ow > 0 ? ow / texture.height : 0; for (let i = 0, ii = 0, io = 0, vo = subChunk.vertexArea.start + 3; i < count; ++i, io += 4) { const charRenderInfo = charRenderInfos[i]; charRenderInfo.indexInChunk = i; @@ -641,10 +701,13 @@ export class Text extends UIRenderer implements ITextRenderer { indices[ii++] = tempIndices[j] + io; } - // Set uv and color for vertices + // Set uv and color for vertices, expand uv outward by outline width for (let j = 0; j < 4; ++j, vo += 9) { const uv = charRenderInfo.uvs[j]; - uv.copyToArray(vertices, vo); + const su = j === 1 || j === 2 ? 1 : -1; + const sv = j >= 2 ? 1 : -1; + vertices[vo] = uv.x + owU * su; + vertices[vo + 1] = uv.y + owV * sv; vertices[vo + 2] = r; vertices[vo + 3] = g; vertices[vo + 4] = b; @@ -673,6 +736,11 @@ export class Text extends UIRenderer implements ITextRenderer { } textChunks.length = 0; } + + @ignoreClone + private _onOutlineColorChanged(): void { + this.shaderData.setColor(Text._outlineColorProperty, this._outlineColor); + } } class TextChunk { diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 1f89431265..8329d1f39a 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -1,11 +1,13 @@ -export { UICanvas } from "./UICanvas"; -export { UIGroup } from "./UIGroup"; -export { UIRenderer } from "./UIRenderer"; -export { UITransform } from "./UITransform"; export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; +export { Mask } from "./advanced/Mask"; +export { RectMask2D } from "./advanced/RectMask2D"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; export { ScaleTransition } from "./interactive/transition/ScaleTransition"; export { SpriteTransition } from "./interactive/transition/SpriteTransition"; export { Transition } from "./interactive/transition/Transition"; +export { UICanvas } from "./UICanvas"; +export { UIGroup } from "./UIGroup"; +export { UIRenderer } from "./UIRenderer"; +export { UITransform } from "./UITransform"; diff --git a/packages/ui/src/component/interactive/UIInteractive.ts b/packages/ui/src/component/interactive/UIInteractive.ts index 6811819585..43a7a36e47 100644 --- a/packages/ui/src/component/interactive/UIInteractive.ts +++ b/packages/ui/src/component/interactive/UIInteractive.ts @@ -1,12 +1,4 @@ -import { - CloneUtils, - Entity, - EntityModifyFlags, - Script, - assignmentClone, - deepClone, - ignoreClone -} from "@galacean/engine"; +import { Entity, EntityModifyFlags, Script, ignoreClone } from "@galacean/engine"; import { UIGroup } from "../.."; import { Utils } from "../../Utils"; import { IGroupAble } from "../../interface/IGroupAble"; @@ -48,9 +40,7 @@ export class UIInteractive extends Script implements IGroupAble { @ignoreClone _globalInteractiveDirty: boolean = false; - @deepClone protected _transitions: Transition[] = []; - @assignmentClone protected _interactive: boolean = true; @ignoreClone protected _state: InteractiveState = InteractiveState.Normal; diff --git a/packages/ui/src/component/interactive/transition/SpriteTransition.ts b/packages/ui/src/component/interactive/transition/SpriteTransition.ts index 4a20c1bc13..f90441236d 100644 --- a/packages/ui/src/component/interactive/transition/SpriteTransition.ts +++ b/packages/ui/src/component/interactive/transition/SpriteTransition.ts @@ -6,35 +6,6 @@ import { Transition } from "./Transition"; * Sprite transition. */ export class SpriteTransition extends Transition { - /** - * @internal - */ - override destroy(): void { - super.destroy(); - if (this._normal) { - // @ts-ignore - this._normal._addReferCount(-1); - this._normal = null; - } - if (this._hover) { - // @ts-ignore - this._hover._addReferCount(-1); - this._hover = null; - } - if (this._pressed) { - // @ts-ignore - this._pressed._addReferCount(-1); - this._pressed = null; - } - if (this._disabled) { - // @ts-ignore - this._disabled._addReferCount(-1); - this._disabled = null; - } - this._initialValue = this._currentValue = this._finalValue = null; - this._target = null; - } - protected _getTargetValueCopy(): Sprite { return this._target?.sprite; } diff --git a/packages/ui/src/component/interactive/transition/Transition.ts b/packages/ui/src/component/interactive/transition/Transition.ts index 5dd461e615..1b76932098 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -1,4 +1,4 @@ -import { Color, ReferResource, Sprite } from "@galacean/engine"; +import { Color, DataObject, ReferResource, Sprite, ignoreClone } from "@galacean/engine"; import { UIRenderer } from "../../UIRenderer"; import { InteractiveState, UIInteractive } from "../UIInteractive"; @@ -8,7 +8,7 @@ import { InteractiveState, UIInteractive } from "../UIInteractive"; export abstract class Transition< T extends TransitionValueType = TransitionValueType, K extends UIRenderer = UIRenderer -> { +> extends DataObject { /** @internal */ _interactive: UIInteractive; @@ -18,10 +18,15 @@ export abstract class Transition< protected _hover: T; protected _disabled: T; protected _duration: number = 0; + @ignoreClone protected _countDown: number = 0; + @ignoreClone protected _initialValue: T; + @ignoreClone protected _finalValue: T; + @ignoreClone protected _currentValue: T; + @ignoreClone protected _finalState: InteractiveState = InteractiveState.Normal; /** @@ -119,9 +124,19 @@ export abstract class Transition< destroy(): void { this._interactive?.removeTransition(this); + this._addStateValuesReferCount(-1); + this._normal = this._pressed = this._hover = this._disabled = null; + this._initialValue = this._currentValue = this._finalValue = null; this._target = null; } + /** + * @internal + */ + _cloneTo(target: Transition): void { + target._addStateValuesReferCount(1); + } + /** * @internal */ @@ -170,6 +185,18 @@ export abstract class Transition< this._target?.enabled && this._applyValue(this._currentValue); } + private _addStateValuesReferCount(count: number): void { + const { _normal, _pressed, _hover, _disabled } = this; + // @ts-ignore + _normal instanceof ReferResource && _normal._addReferCount(count); + // @ts-ignore + _pressed instanceof ReferResource && _pressed._addReferCount(count); + // @ts-ignore + _hover instanceof ReferResource && _hover._addReferCount(count); + // @ts-ignore + _disabled instanceof ReferResource && _disabled._addReferCount(count); + } + private _getValueByState(state: InteractiveState): T { switch (state) { case InteractiveState.Normal: diff --git a/packages/ui/src/input/UIPointerEventEmitter.ts b/packages/ui/src/input/UIPointerEventEmitter.ts index 83bf8f1484..5ea25e23c3 100644 --- a/packages/ui/src/input/UIPointerEventEmitter.ts +++ b/packages/ui/src/input/UIPointerEventEmitter.ts @@ -91,11 +91,11 @@ export class UIPointerEventEmitter extends PointerEventEmitter { } } if (camera.clearFlags & CameraClearFlags.Color) { - this._updateRaycast(null); + this._updateRaycast(null, pointer); return; } } - this._updateRaycast(null); + this._updateRaycast(null, pointer); } } @@ -128,10 +128,7 @@ export class UIPointerEventEmitter extends PointerEventEmitter { if (pressedPath.length > 0) { const common = UIPointerEventEmitter._tempArray0; if (this._findCommonInPath(enteredPath, pressedPath, common)) { - const eventData = this._createEventData(pointer); - for (let i = 0, n = common.length; i < n; i++) { - this._fireClick(common[i], eventData); - } + this._bubble(common, pointer, this._fireClick); common.length = 0; } } @@ -170,18 +167,17 @@ export class UIPointerEventEmitter extends PointerEventEmitter { this._enteredPath.length = this._pressedPath.length = this._draggedPath.length = 0; } - private _updateRaycast(element: UIRenderer, pointer: Pointer = null): void { + private _updateRaycast(element: UIRenderer | null, pointer: Pointer): void { const enteredPath = this._enteredPath; const curPath = this._composedPath(element, UIPointerEventEmitter._path); const add = UIPointerEventEmitter._tempArray0; const del = UIPointerEventEmitter._tempArray1; if (this._findDiffInPath(enteredPath, curPath, add, del)) { - const eventData = this._createEventData(pointer); for (let i = 0, n = add.length; i < n; i++) { - this._fireEnter(add[i], eventData); + this._fireEnter(add[i], this._createEventData(pointer, add[i])); } for (let i = 0, n = del.length; i < n; i++) { - this._fireExit(del[i], eventData); + this._fireExit(del[i], this._createEventData(pointer, del[i])); } const length = (enteredPath.length = curPath.length); @@ -193,7 +189,7 @@ export class UIPointerEventEmitter extends PointerEventEmitter { curPath.length = 0; } - private _composedPath(element: UIRenderer, path: Entity[]): Entity[] { + private _composedPath(element: UIRenderer | null, path: Entity[]): Entity[] { if (!element) { path.length = 0; return path; @@ -256,8 +252,9 @@ export class UIPointerEventEmitter extends PointerEventEmitter { private _bubble(path: Entity[], pointer: Pointer, fireEvent: FireEvent): void { const length = path.length; if (length <= 0) return; - const eventData = this._createEventData(pointer); + const eventData = this._createEventData(pointer, path[0]); for (let i = 0; i < length; i++) { + eventData.currentTarget = path[i]; fireEvent(path[i], eventData); } } diff --git a/packages/xr-webxr/package.json b/packages/xr-webxr/package.json index 71f021fda4..0f4af9d9f9 100644 --- a/packages/xr-webxr/package.json +++ b/packages/xr-webxr/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-xr-webxr", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/xr/package.json b/packages/xr/package.json index be05c40951..6cafa4bbbf 100644 --- a/packages/xr/package.json +++ b/packages/xr/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-xr", - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c82cc287c..778352b1c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,12 @@ importers: '@galacean/engine-shader-compiler': specifier: workspace:* version: link:../packages/shader-compiler + '@galacean/engine-spine': + specifier: workspace:* + version: link:../packages/spine + '@galacean/engine-spine-core-4.2': + specifier: workspace:* + version: link:../packages/spine-core-4.2 '@galacean/engine-ui': specifier: workspace:* version: link:../packages/ui @@ -179,6 +185,15 @@ importers: '@galacean/engine-shader-compiler': specifier: workspace:* version: link:../packages/shader-compiler + '@galacean/engine-spine': + specifier: workspace:* + version: link:../packages/spine + '@galacean/engine-spine-core-3.8': + specifier: workspace:* + version: link:../packages/spine-core-3.8 + '@galacean/engine-spine-core-4.2': + specifier: workspace:* + version: link:../packages/spine-core-4.2 '@galacean/engine-toolkit': specifier: latest version: 1.6.0(@galacean/engine-ui@packages+ui)(@galacean/engine@packages+galacean) @@ -288,6 +303,38 @@ importers: specifier: workspace:* version: link:../design + packages/spine: + devDependencies: + '@esotericsoftware/spine-core': + specifier: ~4.2.66 + version: 4.2.119 + '@galacean/engine': + specifier: workspace:* + version: link:../galacean + + packages/spine-core-3.8: + dependencies: + '@galacean/engine-spine': + specifier: workspace:* + version: link:../spine + devDependencies: + '@galacean/engine': + specifier: workspace:* + version: link:../galacean + + packages/spine-core-4.2: + dependencies: + '@galacean/engine-spine': + specifier: workspace:* + version: link:../spine + devDependencies: + '@esotericsoftware/spine-core': + specifier: ~4.2.66 + version: 4.2.119 + '@galacean/engine': + specifier: workspace:* + version: link:../galacean + packages/ui: devDependencies: '@galacean/engine': @@ -345,10 +392,19 @@ importers: '@galacean/engine-shader-compiler': specifier: workspace:* version: link:../packages/shader-compiler + '@galacean/engine-spine': + specifier: workspace:* + version: link:../packages/spine + '@galacean/engine-spine-core-4.2': + specifier: workspace:* + version: link:../packages/spine-core-4.2 '@galacean/engine-ui': specifier: workspace:* version: link:../packages/ui devDependencies: + '@esotericsoftware/spine-core': + specifier: ~4.2.66 + version: 4.2.119 '@vitest/browser': specifier: 3.2.6 version: 3.2.6(msw@2.6.5(@types/node@18.19.64)(typescript@5.6.3))(playwright@1.55.0)(vite@5.4.11(@types/node@18.19.64)(sass@1.81.0)(terser@5.44.1))(vitest@3.2.6) @@ -833,6 +889,9 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@esotericsoftware/spine-core@4.2.119': + resolution: {integrity: sha512-lvaRECaQDScO758ZSAR1Fj+GWkBKVZxPdgT/gCiKqdkrjZCDu2UzgbZtdPhxnLPcKG/zsaGJkfbN4OS0wHsxZQ==} + '@fastify/deepmerge@1.3.0': resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} @@ -4436,6 +4495,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@esotericsoftware/spine-core@4.2.119': {} + '@fastify/deepmerge@1.3.0': {} '@galacean/engine-toolkit-auxiliary-lines@1.6.0(@galacean/engine@packages+galacean)': diff --git a/rollup.config.js b/rollup.config.js index 1831d6fb4e..04e9b4f06f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -44,7 +44,10 @@ const commonPlugins = [ swc( defineRollupSwcOption({ include: /\.[mc]?[jt]sx?$/, - exclude: /node_modules/, + // Transpile bundled @esotericsoftware/spine-core to es5 too, so spine core packages' + // es5 subclasses (e.g. SpineTexture extends Texture) can extend it without the + // "Class constructor cannot be invoked without 'new'" es5-extends-es6 error. + exclude: /node_modules\/(?!\.pnpm\/@esotericsoftware\+|@esotericsoftware\/)/, jsc: { loose: true, externalHelpers: true, diff --git a/tests/package.json b/tests/package.json index 3740665d09..123079cf1a 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,7 +1,7 @@ { "name": "@galacean/engine-tests", "private": true, - "version": "2.0.0-alpha.38", + "version": "0.0.0-experimental-2.0-migrate.3", "license": "MIT", "main": "dist/main.js", "module": "dist/module.js", @@ -24,9 +24,12 @@ "@galacean/engine-shader-compiler": "workspace:*", "@galacean/engine-physics-physx": "workspace:*", "@galacean/engine-ui": "workspace:*", - "@galacean/engine-shader": "workspace:*" + "@galacean/engine-shader": "workspace:*", + "@galacean/engine-spine": "workspace:*", + "@galacean/engine-spine-core-4.2": "workspace:*" }, "devDependencies": { + "@esotericsoftware/spine-core": "~4.2.66", "@vitest/browser": "3.2.6" } } diff --git a/tests/src/core/2d/text/TextUtils.test.ts b/tests/src/core/2d/text/TextUtils.test.ts index 54a5a57820..640cb4713c 100644 --- a/tests/src/core/2d/text/TextUtils.test.ts +++ b/tests/src/core/2d/text/TextUtils.test.ts @@ -564,6 +564,90 @@ describe("TextUtils", () => { ); }); + it("measureTextWithShrink", () => { + // @ts-ignore + const { _pixelsPerUnit } = Engine; + const r = textRendererTruncate; + r.overflowMode = OverflowMode.Shrink; + r.enableWrapping = false; + r.lineSpacing = 0; + r.characterSpacing = 0; + r.text = "15"; + // applyFontSize: switch the sub font for each measurement during the shrink search. + const apply = (fs: number) => { + // @ts-ignore + r._applyFontSizeForShrink(fs); + }; + const measure = (w: number, h: number, fontSize: number) => { + r.width = w; + r.height = h; + r.fontSize = fontSize; + r.bounds; + return TextUtils.measureTextWithShrink( + r, + w * _pixelsPerUnit, + h * _pixelsPerUnit, + fontSize, + r.lineSpacing, + r.characterSpacing, + r.enableWrapping, + apply + ); + }; + + // Fits at original size → keep it (never shrinks unnecessarily). + let res = measure(3, 1, 24); + expect(res.fontSize).to.be.equal(24); + expect(res.metrics.width).to.be.at.most(300); + expect(res.metrics.lineHeight * res.metrics.lines.length).to.be.at.most(100); + + // Box too short → shrink the font size until the content height fits. + res = measure(3, 0.3, 80); + expect(res.fontSize).to.be.below(80); + expect(res.metrics.lineHeight * res.metrics.lines.length).to.be.at.most(30); + + // Box too narrow (no wrap) → shrink the font size until the width fits. + res = measure(0.3, 3, 80); + expect(res.fontSize).to.be.below(80); + expect(res.metrics.width).to.be.at.most(30); + + // Only shrinks, never enlarges: even a huge box keeps the original font size. + res = measure(10, 10, 24); + expect(res.fontSize).to.be.equal(24); + + // Wrapping path: a long text that wraps to multiple lines and overflows the height + // must shrink until the real content height (lineHeight * lineCount) fits the box. + r.enableWrapping = true; + r.text = "这是一段会换行的较长文本"; + res = measure(2, 0.5, 60); // 200x50px box + expect(res.fontSize).to.be.below(60); + expect(res.metrics.width).to.be.at.most(200); + expect(res.metrics.lineHeight * res.metrics.lines.length).to.be.at.most(50); + }); + + it("_getCharInfo writes the atlas only when uploadTexture is true (used by SHRINK search)", () => { + const r = wrap2TextRenderer; + // @ts-ignore + const subFont = r._getSubFont(); + const fontString = subFont.nativeFontString; + const char = "Q"; // a glyph not measured by the other cases above + + // Sanity: the glyph is not in the atlas yet. + // @ts-ignore + expect(subFont._getCharInfo(char)).to.be.null; + + // measure-only path: returns valid metrics but must NOT upload to the atlas. + const info = TextUtils._getCharInfo(char, fontString, subFont, false); + expect(info.w).to.be.greaterThan(0); + // @ts-ignore + expect(subFont._getCharInfo(char)).to.be.null; + + // full path: uploads the glyph to the atlas. + TextUtils._getCharInfo(char, fontString, subFont, true); + // @ts-ignore + expect(subFont._getCharInfo(char)).to.not.be.null; + }); + afterAll(() => { engine.destroy(); }); diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts new file mode 100644 index 0000000000..3d3d6a6363 --- /dev/null +++ b/tests/src/core/CloneManager.test.ts @@ -0,0 +1,2243 @@ +import { + BoolUpdateFlag, + Burst, + DataObject, + DisorderedArray, + Entity, + Logger, + MeshRenderer, + ParticleCompositeCurve, + ParticleCompositeGradient, + ParticleRenderer, + Script, + Signal, + Texture2D, + assignmentClone, + deepClone, + ignoreClone +} from "@galacean/engine-core"; +import * as EngineCore from "@galacean/engine-core"; +import * as EngineMath from "@galacean/engine-math"; +import * as EngineUI from "@galacean/engine-ui"; +import { Color, Ray, Vector3 } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine"; +import { describe, expect, it, vi } from "vitest"; + +class TestScript extends Script { + targetEntity: Entity; + targetRenderer: MeshRenderer; + externalEntity: Entity; + externalRenderer: MeshRenderer; + deepChild: Entity; + selfRef: Entity; + speed: number; + name2: string; + flag: boolean; + data: object; +} + +/** Script with multiple entity/component refs pointing to different nodes */ +class MultiRefScript extends Script { + entityA: Entity; + entityB: Entity; + rendererA: MeshRenderer; + rendererB: MeshRenderer; +} + +/** Script where the same entity is referenced by multiple properties */ +class DuplicateRefScript extends Script { + ref1: Entity; + ref2: Entity; +} + +/** Script with null/undefined entity/component refs */ +class NullRefScript extends Script { + nullEntity: Entity = null; + undefinedEntity: Entity; + nullRenderer: MeshRenderer = null; + someNumber: number = 0; +} + +/** Script referencing a sibling entity (not parent/child, but sibling under clone root) */ +class SiblingRefScript extends Script { + sibling: Entity; + siblingRenderer: MeshRenderer; +} + +/** Script with a mix of decorated and undecorated entity refs */ +class DecoratedRefScript extends Script { + // Undecorated — Entity type default (Remap) applies + autoRemapEntity: Entity; + + // @assignmentClone — field decorator wins over the type default: shares the source reference + @assignmentClone + assignedEntity: Entity; + + // @ignoreClone — field decorator wins over the type default: keeps the clone's own value + @ignoreClone + ignoredEntity: Entity; +} + +/** Script with a @deepClone array of entities */ +class ArrayRefScript extends Script { + @deepClone + entities: Entity[] = []; +} + +/** Script with Component self-reference */ +class SelfComponentRefScript extends Script { + selfScript: SelfComponentRefScript; +} + +/** Script referencing another Script on a different entity */ +class CrossScriptRefScript extends Script { + otherScript: TestScript; +} + +/** Script with a nested plain object containing entity refs */ +class NestedObjectScript extends Script { + @deepClone + config: { target: Entity; label: string } = { target: null, label: "" }; +} + +/** Script for testing multiple same-type components on one entity */ +class CounterScript extends Script { + value: number = 0; + partner: CounterScript; + targetEntity: Entity; +} + +/** Script that references a CounterScript */ +class CounterRefScript extends Script { + counter: CounterScript; +} + +/** Handler script used for Signal structured binding tests */ +class ClickHandler extends Script { + callCount = 0; + lastPrefix: string = ""; + + handleClick(): void { + this.callCount++; + } + + handleClickWithPrefix(arg: number, prefix: string): void { + this.callCount++; + this.lastPrefix = prefix; + } +} + +/** Script with a Signal property */ +class SignalScript extends Script { + @deepClone + readonly onFire = new Signal<[number]>(); +} + +/** Script with function-valued fields, standalone and inside containers */ +class HandlerScript extends Script { + onTick: () => void; + handlers: Array<() => void> = []; + handlerSet: Set<() => void> = new Set(); + config: { onDone: (() => void) | null; x: number } = { onDone: null, x: 0 }; +} + +/** Data object counting how often the gate runs its post-clone hook */ +class HookCountPayload extends DataObject { + static runs = 0; + value = 0; + _cloneTo(): void { + HookCountPayload.runs++; + } +} + +/** Script referencing one payload from two slots */ +class SharedPayloadScript extends Script { + slotA: HookCountPayload = null; + slotB: HookCountPayload = null; +} + +/** Script opting a plain runtime container into a deep copy */ +class DeepContainerScript extends Script { + @deepClone + entries: DisorderedArray<{ id: number }> = new DisorderedArray<{ id: number }>(); +} + +/** Script asking for a deep copy the paired flag registry cannot honor */ +class DeepFlagManagerScript extends Script { + @deepClone + flagManager: any = null; +} + +/** Script whose constructor establishes its own bound handler */ +class BoundHandlerScript extends Script { + tickCount = 0; + boundTick = this._tick.bind(this); + + private _tick(): void { + this.tickCount++; + } +} + +/** Base script decorating two Entity fields, one to be overridden by a subclass, one left inherited. */ +class BaseOverrideScript extends Script { + @assignmentClone + reDecorated: Entity; + @ignoreClone + inherited: Entity = null; +} + +/** Subclass re-decorates `reDecorated` (Assignment → Ignore) but never mentions `inherited`. */ +class SubOverrideScript extends BaseOverrideScript { + @ignoreClone + reDecorated: Entity; +} + +/** Script holding binary data views */ +class BinaryScript extends Script { + view: DataView; + bytes: Float32Array; +} + +/** + * Script holding a shared ReferResource, honoring the slot-ownership contract the same way + * engine components do: acquire on assignment, release on destroy. The clone gate adds +1 for + * the cloned backing slot, which the same onDestroy release balances. + */ +class ResourceRefScript extends Script { + private _texture: Texture2D; + + get texture(): Texture2D { + return this._texture; + } + + set texture(value: Texture2D) { + if (this._texture !== value) { + (this._texture as any)?._addReferCount(-1); + (value as any)?._addReferCount(1); + this._texture = value; + } + } + + override onDestroy(): void { + this.texture = null; + } +} + +/** Script whose constructor presets an owned counted resource into a clonable slot. */ +class PresetTextureScript extends Script { + static created: Texture2D[] = []; + + texture: Texture2D; + + constructor(entity: Entity) { + super(entity); + const texture = new Texture2D(entity.engine, 1, 1); + (texture as any)._addReferCount(1); + PresetTextureScript.created.push(texture); + this.texture = texture; + } +} + +/** Script with two fields aliasing one typed array (identity must survive the clone). */ +class AliasedBinaryScript extends Script { + a: Float32Array; + b: Float32Array; +} + +/** Unregistered user value type without any counting API — must share, never count. */ +class SharedConfig { + value = 1; +} + +/** Script holding a user Assignment-registered object. */ +class SharedConfigScript extends Script { + config: SharedConfig = null; +} + +/** Script whose constructor presets a counted resource WITHOUT acquiring it (contract violation). */ +class UnownedPresetScript extends Script { + static created: Texture2D[] = []; + + tex: Texture2D; + + constructor(entity: Entity) { + super(entity); + const texture = new Texture2D(entity.engine, 1, 1); + UnownedPresetScript.created.push(texture); + this.tex = texture; + } +} + +/** User DataObject whose constructor dereferences a required argument (contract violation). */ +class ParamDeepConfig extends DataObject { + target: string; + + constructor(source: { id: string }) { + super(); + this.target = source.id; + } +} + +/** Script holding plain data whose payload happens to carry a `copyFrom` key. */ +class CopyFromDataScript extends Script { + config: any = null; +} + +/** Script whose binary fields alias class-level shared default tables (preset === source). */ +class SharedDefaultTableScript extends Script { + static DEFAULT_WEIGHTS = new Float32Array([1, 2, 3]); + static DEFAULT_VIEW = new DataView(new ArrayBuffer(4)); + weights: Float32Array = SharedDefaultTableScript.DEFAULT_WEIGHTS; + view: DataView = SharedDefaultTableScript.DEFAULT_VIEW; +} + +/** Script whose Map / Set / Array fields carry constructor-built entries (the clone's preset). */ +class PresetContainerScript extends Script { + map = new Map([["preset", 1]]); + set = new Set(["preset"]); + list: number[] = [1, 2, 3]; +} + +/** Script whose Array / Map / Set fields alias class-level shared defaults (preset === source). */ +class SharedDefaultContainerScript extends Script { + static DEFAULT_LIST = [1, 2, 3]; + static DEFAULT_MAP = new Map([["a", 1]]); + static DEFAULT_SET = new Set(["a"]); + list: number[] = SharedDefaultContainerScript.DEFAULT_LIST; + map: Map = SharedDefaultContainerScript.DEFAULT_MAP; + set: Set = SharedDefaultContainerScript.DEFAULT_SET; +} + +/** Plain class with no deep-clone default (not DataObject / math / container). */ +class PlainConfig { + count = 0; + nested = { x: 1 }; +} + +/** Script sharing one container through an @assignmentClone field */ +class AssignedContainerScript extends Script { + @assignmentClone + shared: number[] = []; +} + +/** Script pointing @deepClone at a function value */ +class DeepFnScript extends Script { + @deepClone + onTick: () => void = () => {}; +} + +/** Script whose @deepClone fields hold members with no deep default of their own */ +class DeepSubtreeScript extends Script { + @deepClone + configs: PlainConfig[] = []; + @deepClone + bag: any = null; + @deepClone + mixed: any[] = null; +} + +/** Script whose ctor-built binary values stay observable through @ignoreClone aliases */ +class BinaryPresetScript extends Script { + weights = new Float32Array(3); + view = new DataView(new ArrayBuffer(4)); + shortWeights = new Float32Array(2); + @ignoreClone + ownWeights = this.weights; + @ignoreClone + ownView = this.view; + @ignoreClone + ownShort = this.shortWeights; +} + +/** Non-component class carrying its own @ignoreClone, to be reached through a field walk. */ +class Bag { + kept = 1; + @ignoreClone + runtime = 1; +} + +/** Script forcing the walk into Bag with @deepClone. */ +class BagHolderScript extends Script { + @deepClone + bag: Bag = null; +} + +/** Script @deepClone-ing a class that has no deep-clone default — must force a deep copy. */ +class ForcedDeepScript extends Script { + @deepClone + config: PlainConfig = null; +} + +/** Two fields on one script pointing at the same default-less instance, one @deepClone'd. */ +class MixedIntentScript extends Script { + @deepClone + deep: PlainConfig = null; + shared: PlainConfig = null; +} + +/** Same pair, declared the other way round — field order must not change either outcome. */ +class MixedIntentReversedScript extends Script { + shared: PlainConfig = null; + @deepClone + deep: PlainConfig = null; +} + +/** Script holding the same class without @deepClone — the default path shares it. */ +class SharedPlainScript extends Script { + config: PlainConfig = null; +} + +/** Script misusing @deepClone on an Entity ref — cloning it must throw. */ +class DeepEntityRefScript extends Script { + @deepClone + target: Entity; +} + +/** Script misusing @deepClone on an engine-bound asset — cloning it must throw. */ +class DeepAssetRefScript extends Script { + @deepClone + texture: Texture2D; +} + +/** Script with an @assignmentClone function field preset by the constructor */ +class AssignedHandlerScript extends Script { + @assignmentClone + handler: () => void = this._noop.bind(this); + + private _noop(): void {} +} + +/** Script with an @ignoreClone function field preset by the constructor */ +class IgnoredHandlerScript extends Script { + @ignoreClone + handler: () => void = this._noop.bind(this); + + private _noop(): void {} +} + +describe("Clone remap", async () => { + const engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const scene = engine.sceneManager.activeScene; + engine.run(); + + describe("Basic Entity/Component remap", () => { + it("script undecorated Entity ref should remap to cloned entity", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(TestScript); + script.targetEntity = child; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + const clonedChild = clonedParent.children[0]; + + expect(clonedScript.targetEntity).not.eq(child); + expect(clonedScript.targetEntity).eq(clonedChild); + expect(clonedScript.targetEntity.name).eq("child"); + + rootEntity.destroy(); + }); + + it("script undecorated Component ref should remap to cloned component", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const meshRenderer = child.addComponent(MeshRenderer); + const script = parent.addComponent(TestScript); + script.targetRenderer = meshRenderer; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + const clonedChild = clonedParent.children[0]; + const clonedMeshRenderer = clonedChild.getComponent(MeshRenderer); + + expect(clonedScript.targetRenderer).not.eq(meshRenderer); + expect(clonedScript.targetRenderer).eq(clonedMeshRenderer); + + rootEntity.destroy(); + }); + + it("script ref to entity outside hierarchy should keep original", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const script = parent.addComponent(TestScript); + script.externalEntity = external; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + + expect(clonedScript.externalEntity).eq(external); + + rootEntity.destroy(); + }); + + it("script ref to component outside hierarchy should keep original", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const externalMR = external.addComponent(MeshRenderer); + const script = parent.addComponent(TestScript); + script.externalRenderer = externalMR; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + + expect(clonedScript.externalRenderer).eq(externalMR); + + rootEntity.destroy(); + }); + + it("deep hierarchy entity ref should remap correctly", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const grandchild = child.createChild("grandchild"); + const script = parent.addComponent(TestScript); + script.deepChild = grandchild; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + const clonedGrandchild = clonedParent.children[0].children[0]; + + expect(clonedScript.deepChild).not.eq(grandchild); + expect(clonedScript.deepChild).eq(clonedGrandchild); + expect(clonedScript.deepChild.name).eq("grandchild"); + + rootEntity.destroy(); + }); + + it("script ref to self entity (clone root) should remap", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(TestScript); + script.selfRef = parent; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + + expect(clonedScript.selfRef).not.eq(parent); + expect(clonedScript.selfRef).eq(clonedParent); + + rootEntity.destroy(); + }); + + it("primitive props copied by value; plain object deep cloned", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(TestScript); + const obj = { x: 1 }; + script.speed = 42; + script.name2 = "test"; + script.flag = true; + script.data = obj; + + const clonedParent = parent.clone(); + const clonedScript = clonedParent.getComponent(TestScript); + + expect(clonedScript.speed).eq(42); + expect(clonedScript.name2).eq("test"); + expect(clonedScript.flag).eq(true); + // plain object is deep cloned into an independent copy, not shared + expect(clonedScript.data).not.eq(obj); + expect(clonedScript.data.x).eq(1); + + rootEntity.destroy(); + }); + }); + + describe("Multiple and duplicate refs", () => { + it("a shared object clones once, hook included", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SharedPayloadScript); + const shared = new HookCountPayload(); + shared.value = 42; + script.slotA = shared; + script.slotB = shared; + + HookCountPayload.runs = 0; + const cloned = parent.clone(); + const cs = cloned.getComponent(SharedPayloadScript); + + expect(cs.slotA).not.eq(shared); + expect(cs.slotA).eq(cs.slotB); + expect(cs.slotA.value).eq(42); + // A second slot resolving to the same clone must not re-run the post-clone hook: hooks + // acquire references and register listeners, so a replay silently doubles both. + expect(HookCountPayload.runs).eq(1); + + rootEntity.destroy(); + }); + + it("multiple entity/component refs on same script all remap independently", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const childA = parent.createChild("childA"); + const childB = parent.createChild("childB"); + const mrA = childA.addComponent(MeshRenderer); + const mrB = childB.addComponent(MeshRenderer); + const script = parent.addComponent(MultiRefScript); + script.entityA = childA; + script.entityB = childB; + script.rendererA = mrA; + script.rendererB = mrB; + + const cloned = parent.clone(); + const cs = cloned.getComponent(MultiRefScript); + + expect(cs.entityA).not.eq(childA); + expect(cs.entityB).not.eq(childB); + expect(cs.entityA.name).eq("childA"); + expect(cs.entityB.name).eq("childB"); + expect(cs.entityA).eq(cloned.children[0]); + expect(cs.entityB).eq(cloned.children[1]); + expect(cs.rendererA).eq(cloned.children[0].getComponent(MeshRenderer)); + expect(cs.rendererB).eq(cloned.children[1].getComponent(MeshRenderer)); + + rootEntity.destroy(); + }); + + it("two properties referencing the same entity both remap to the same cloned entity", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DuplicateRefScript); + script.ref1 = child; + script.ref2 = child; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DuplicateRefScript); + + expect(cs.ref1).not.eq(child); + expect(cs.ref1).eq(cs.ref2); + expect(cs.ref1).eq(cloned.children[0]); + + rootEntity.destroy(); + }); + }); + + describe("Null and undefined refs", () => { + it("null entity/component refs should not crash and remain null", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(NullRefScript); + + const cloned = parent.clone(); + const cs = cloned.getComponent(NullRefScript); + + expect(cs.nullEntity).eq(null); + expect(cs.nullRenderer).eq(null); + expect(cs.someNumber).eq(0); + + rootEntity.destroy(); + }); + }); + + describe("Sibling entity refs", () => { + it("ref to sibling entity under clone root should remap", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const childA = parent.createChild("childA"); + const childB = parent.createChild("childB"); + const mrB = childB.addComponent(MeshRenderer); + const script = childA.addComponent(SiblingRefScript); + script.sibling = childB; + script.siblingRenderer = mrB; + + const cloned = parent.clone(); + const clonedChildA = cloned.children[0]; + const clonedChildB = cloned.children[1]; + const cs = clonedChildA.getComponent(SiblingRefScript); + + expect(cs.sibling).not.eq(childB); + expect(cs.sibling).eq(clonedChildB); + expect(cs.siblingRenderer).not.eq(mrB); + expect(cs.siblingRenderer).eq(clonedChildB.getComponent(MeshRenderer)); + + rootEntity.destroy(); + }); + }); + + describe("Field decorators take priority over Entity/Component remap", () => { + it("@assignmentClone entity ref shares the source reference (decorator wins)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DecoratedRefScript); + script.assignedEntity = child; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DecoratedRefScript); + + expect(cs.assignedEntity).eq(child); + + rootEntity.destroy(); + }); + + it("@ignoreClone entity ref keeps the clone's own value (decorator wins)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DecoratedRefScript); + script.ignoredEntity = child; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DecoratedRefScript); + + expect(cs.ignoredEntity).eq(undefined); + + rootEntity.destroy(); + }); + + it("undecorated entity ref remaps correctly", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DecoratedRefScript); + script.autoRemapEntity = child; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DecoratedRefScript); + + expect(cs.autoRemapEntity).not.eq(child); + expect(cs.autoRemapEntity).eq(cloned.children[0]); + + rootEntity.destroy(); + }); + + it("@ignoreClone entity ref outside hierarchy is ignored the same way", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const script = parent.addComponent(DecoratedRefScript); + script.ignoredEntity = external; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DecoratedRefScript); + + expect(cs.ignoredEntity).eq(undefined); + + rootEntity.destroy(); + }); + + it("@deepClone on an Entity ref throws — the explicit intent can't be honored, so it's surfaced", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DeepEntityRefScript); + script.target = child; + + // A decorator is the developer's explicit intent; @deepClone on an engine-bound Entity is a + // mistake — thrown, never silently remapped (an undecorated Entity ref remaps by default). + expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone/); + + rootEntity.destroy(); + }); + + it("@deepClone on an asset ref throws — assets are engine-bound and shared by reference", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepAssetRefScript); + const texture = new Texture2D(engine, 4, 4); + script.texture = texture; + + expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone/); + + rootEntity.destroy(); + texture.destroy(true); + }); + + it("@deepClone forces a deep copy of a class that has no deep-clone default", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ForcedDeepScript); + script.config = new PlainConfig(); + script.config.count = 7; + script.config.nested.x = 42; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ForcedDeepScript); + + // @deepClone is an explicit request: the value is field-walked into an independent copy, + // even though PlainConfig is not DataObject / math / container (the default would share it). + expect(cs.config).not.eq(script.config); + expect(cs.config).instanceOf(PlainConfig); + expect(cs.config.count).eq(7); + expect(cs.config.nested).not.eq(script.config.nested); + expect(cs.config.nested.x).eq(42); + + rootEntity.destroy(); + }); + + it("the same class without @deepClone is shared by the default path", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SharedPlainScript); + script.config = new PlainConfig(); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SharedPlainScript); + + // No deep-clone default and no decorator — the clone shares the source instance. + expect(cs.config).eq(script.config); + + rootEntity.destroy(); + }); + + it("each field keeps its own intent when both point at one instance (@deepClone declared first)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(MixedIntentScript); + const config = new PlainConfig(); + script.deep = config; + script.shared = config; + + const cloned = parent.clone(); + const cs = cloned.getComponent(MixedIntentScript); + + // The two fields ask for opposite things about the same instance, so each is honored on its + // own: the decorated one gets an independent copy, the undecorated one keeps sharing. + expect(cs.deep).not.eq(config); + expect(cs.shared).eq(config); + + rootEntity.destroy(); + }); + + it("each field keeps its own intent when both point at one instance (@deepClone declared last)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(MixedIntentReversedScript); + const config = new PlainConfig(); + script.deep = config; + script.shared = config; + + const cloned = parent.clone(); + const cs = cloned.getComponent(MixedIntentReversedScript); + + // Same expectations as above: declaration order must not change the outcome. + expect(cs.deep).not.eq(config); + expect(cs.shared).eq(config); + + rootEntity.destroy(); + }); + + it("@assignmentClone function field shares the source function (decorator wins over reuse)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(AssignedHandlerScript); + const custom = () => {}; + script.handler = custom; + + const cloned = parent.clone(); + const cs = cloned.getComponent(AssignedHandlerScript); + + expect(cs.handler).eq(custom); + + rootEntity.destroy(); + }); + + it("@ignoreClone function field keeps the clone's own constructor-built value (never the source)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(IgnoredHandlerScript); + const custom = () => {}; + script.handler = custom; + + const cloned = parent.clone(); + const cs = cloned.getComponent(IgnoredHandlerScript); + + // Ignore means the field is untouched by the gate — the clone's constructor already bound + // its own handler to its own `this`, so it must be neither the overridden source value nor + // the source instance's original bound handler. + expect(cs.handler).not.eq(custom); + expect(cs.handler).not.eq(script.handler); + expect(typeof cs.handler).eq("function"); + + rootEntity.destroy(); + }); + }); + + describe("Subclass field re-decoration shadows the base class's", () => { + it("the subclass's own decorator wins on a re-decorated field; an untouched field still inherits the base's", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const sibling = parent.createChild("sibling"); + const script = parent.addComponent(SubOverrideScript); + script.reDecorated = sibling; + script.inherited = sibling; + + const cloned = parent.clone(); + const cs = cloned.getComponent(SubOverrideScript); + + // Base decorates `reDecorated` @assignmentClone (share); the subclass re-decorates it + // @ignoreClone — the subclass's own decoration must win, not the base's. + expect(cs.reDecorated).eq(undefined); + // `inherited` is never touched by the subclass — the base's @ignoreClone must still apply. + expect(cs.inherited).eq(null); + + rootEntity.destroy(); + }); + + it("does not leak the subclass's re-decoration back onto the base class", () => { + // A subclass re-decorating a field must leave the base class untouched. + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const sibling = parent.createChild("sibling"); + const baseScript = parent.addComponent(BaseOverrideScript); + baseScript.reDecorated = sibling; + + const cloned = parent.clone(); + const cs = cloned.getComponent(BaseOverrideScript); + + // The base class's own @assignmentClone on `reDecorated` must still share the source. + expect(cs.reDecorated).eq(sibling); + + rootEntity.destroy(); + }); + }); + + describe("@deepClone array of entities", () => { + it("deep cloned entity array should remap internal refs", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const childA = parent.createChild("childA"); + const childB = parent.createChild("childB"); + const script = parent.addComponent(ArrayRefScript); + script.entities = [childA, childB]; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ArrayRefScript); + + expect(cs.entities).not.eq(script.entities); + expect(cs.entities.length).eq(2); + expect(cs.entities[0]).not.eq(childA); + expect(cs.entities[1]).not.eq(childB); + expect(cs.entities[0]).eq(cloned.children[0]); + expect(cs.entities[1]).eq(cloned.children[1]); + + rootEntity.destroy(); + }); + + it("deep cloned entity array with external ref keeps original", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const external = rootEntity.createChild("external"); + const script = parent.addComponent(ArrayRefScript); + script.entities = [child, external]; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ArrayRefScript); + + expect(cs.entities[0]).eq(cloned.children[0]); + expect(cs.entities[1]).eq(external); + + rootEntity.destroy(); + }); + + it("deep cloned empty entity array stays empty", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ArrayRefScript); + script.entities = []; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ArrayRefScript); + + expect(cs.entities).not.eq(script.entities); + expect(cs.entities.length).eq(0); + + rootEntity.destroy(); + }); + }); + + describe("Component self and cross references", () => { + it("script referencing itself should remap to cloned script", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SelfComponentRefScript); + script.selfScript = script; + + const cloned = parent.clone(); + const cs = cloned.getComponent(SelfComponentRefScript); + + expect(cs.selfScript).not.eq(script); + expect(cs.selfScript).eq(cs); + + rootEntity.destroy(); + }); + + it("script referencing another script on child entity should remap", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const childScript = child.addComponent(TestScript); + const script = parent.addComponent(CrossScriptRefScript); + script.otherScript = childScript; + + const cloned = parent.clone(); + const cs = cloned.getComponent(CrossScriptRefScript); + const clonedChildScript = cloned.children[0].getComponent(TestScript); + + expect(cs.otherScript).not.eq(childScript); + expect(cs.otherScript).eq(clonedChildScript); + + rootEntity.destroy(); + }); + + it("script referencing external script should keep original", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const externalScript = external.addComponent(TestScript); + const script = parent.addComponent(CrossScriptRefScript); + script.otherScript = externalScript; + + const cloned = parent.clone(); + const cs = cloned.getComponent(CrossScriptRefScript); + + expect(cs.otherScript).eq(externalScript); + + rootEntity.destroy(); + }); + }); + + describe("Nested @deepClone object with entity refs", () => { + it("entity ref inside deep cloned plain object should remap", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(NestedObjectScript); + script.config = { target: child, label: "hello" }; + + const cloned = parent.clone(); + const cs = cloned.getComponent(NestedObjectScript); + + expect(cs.config).not.eq(script.config); + expect(cs.config.label).eq("hello"); + expect(cs.config.target).not.eq(child); + expect(cs.config.target).eq(cloned.children[0]); + + rootEntity.destroy(); + }); + + it("entity ref inside deep cloned object pointing outside keeps original", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const script = parent.addComponent(NestedObjectScript); + script.config = { target: external, label: "ext" }; + + const cloned = parent.clone(); + const cs = cloned.getComponent(NestedObjectScript); + + expect(cs.config.target).eq(external); + expect(cs.config.label).eq("ext"); + + rootEntity.destroy(); + }); + }); + + describe("Signal clone with structured bindings", () => { + it("@deepClone Signal should not copy closure listeners", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SignalScript); + let called = false; + script.onFire.on(() => { + called = true; + }); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + + expect(cs.onFire).not.eq(script.onFire); + cs.onFire.invoke(1); + expect(called).eq(false); + + rootEntity.destroy(); + }); + + it("@deepClone Signal should remap structured binding target to cloned hierarchy", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const handlerEntity = parent.createChild("handler"); + const handler = handlerEntity.addComponent(ClickHandler); + const script = parent.addComponent(SignalScript); + script.onFire.on(handler, "handleClick"); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); + + cs.onFire.invoke(1); + expect(clonedHandler.callCount).eq(1); + expect(handler.callCount).eq(0); + + rootEntity.destroy(); + }); + + it("@deepClone Signal should keep external structured binding target", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const external = rootEntity.createChild("external"); + const externalHandler = external.addComponent(ClickHandler); + const script = parent.addComponent(SignalScript); + script.onFire.on(externalHandler, "handleClick"); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + + cs.onFire.invoke(1); + expect(externalHandler.callCount).eq(1); + + rootEntity.destroy(); + }); + + it("@deepClone Signal should remap structured binding with pre-resolved args", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const handlerEntity = parent.createChild("handler"); + const handler = handlerEntity.addComponent(ClickHandler); + const script = parent.addComponent(SignalScript); + script.onFire.on(handler, "handleClickWithPrefix", "myPrefix"); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); + + cs.onFire.invoke(1); + expect(clonedHandler.callCount).eq(1); + expect(clonedHandler.lastPrefix).eq("myPrefix"); + expect(handler.callCount).eq(0); + + rootEntity.destroy(); + }); + + it("@deepClone Signal shares non-entity object args deterministically", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const handlerEntity = parent.createChild("handler"); + const handler = handlerEntity.addComponent(ClickHandler); + const script = parent.addComponent(SignalScript); + const payload = { hp: 5 }; + script.onFire.on(handler, "handleClickWithPrefix", payload); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); + + cs.onFire.invoke(1); + // Non-entity object args are shared with the source, independent of field-walk order. + expect(clonedHandler.lastPrefix).eq(payload); + + rootEntity.destroy(); + }); + + it("@deepClone Signal should preserve once flag on structured binding", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const handlerEntity = parent.createChild("handler"); + const handler = handlerEntity.addComponent(ClickHandler); + const script = parent.addComponent(SignalScript); + script.onFire.once(handler, "handleClick"); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SignalScript); + const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); + + cs.onFire.invoke(1); + expect(clonedHandler.callCount).eq(1); + cs.onFire.invoke(2); + expect(clonedHandler.callCount).eq(1); // once: removed after first call + + rootEntity.destroy(); + }); + }); + + describe("Clone hierarchy integrity", () => { + it("clone preserves children count and names", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + parent.createChild("a"); + parent.createChild("b"); + parent.createChild("c"); + + const cloned = parent.clone(); + expect(cloned.children.length).eq(3); + expect(cloned.children[0].name).eq("a"); + expect(cloned.children[1].name).eq("b"); + expect(cloned.children[2].name).eq("c"); + + rootEntity.destroy(); + }); + + it("clone of deeply nested hierarchy preserves structure", () => { + const rootEntity = scene.createRootEntity("root"); + const a = rootEntity.createChild("a"); + const b = a.createChild("b"); + const c = b.createChild("c"); + const d = c.createChild("d"); + + const cloned = a.clone(); + expect(cloned.children[0].name).eq("b"); + expect(cloned.children[0].children[0].name).eq("c"); + expect(cloned.children[0].children[0].children[0].name).eq("d"); + + rootEntity.destroy(); + }); + + it("script on child entity with ref to parent should remap", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = child.addComponent(TestScript); + script.targetEntity = parent; + + const cloned = parent.clone(); + const clonedChild = cloned.children[0]; + const cs = clonedChild.getComponent(TestScript); + + expect(cs.targetEntity).not.eq(parent); + expect(cs.targetEntity).eq(cloned); + + rootEntity.destroy(); + }); + + it("multiple scripts on different entities with cross-refs all remap correctly", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const childA = parent.createChild("childA"); + const childB = parent.createChild("childB"); + + const scriptA = childA.addComponent(TestScript); + scriptA.targetEntity = childB; + + const scriptB = childB.addComponent(TestScript); + scriptB.targetEntity = childA; + + const cloned = parent.clone(); + const clonedA = cloned.children[0]; + const clonedB = cloned.children[1]; + const csA = clonedA.getComponent(TestScript); + const csB = clonedB.getComponent(TestScript); + + expect(csA.targetEntity).eq(clonedB); + expect(csB.targetEntity).eq(clonedA); + + rootEntity.destroy(); + }); + }); + + describe("Function fields", () => { + it("@deepClone on a function throws", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + parent.addComponent(DeepFnScript); + + expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone a function/); + + rootEntity.destroy(); + }); + + it("a function inside a @deepClone subtree is shared, not thrown on", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepSubtreeScript); + const fn = () => {}; + script.bag = { onDone: fn }; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepSubtreeScript); + + expect(cs.bag.onDone).eq(fn); + + rootEntity.destroy(); + }); + + it("plain function field is shared, not lost", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const fn = () => {}; + script.onTick = fn; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript); + + expect(cs.onTick).eq(fn); + + rootEntity.destroy(); + }); + + it("functions inside arrays / sets / plain objects survive cloning", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const fn = () => {}; + script.handlers = [fn]; + script.handlerSet = new Set([fn]); + script.config = { onDone: fn, x: 1 }; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript); + + expect(cs.handlers).not.eq(script.handlers); + expect(cs.handlers.length).eq(1); + expect(cs.handlers[0]).eq(fn); + expect(cs.handlerSet).not.eq(script.handlerSet); + expect(cs.handlerSet.has(fn)).eq(true); + expect(cs.config).not.eq(script.config); + expect(cs.config.onDone).eq(fn); + expect(cs.config.x).eq(1); + + rootEntity.destroy(); + }); + + it("constructor-bound function field keeps the clone's own binding", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BoundHandlerScript); + + const cloned = parent.clone(); + const cs = cloned.getComponent(BoundHandlerScript); + + expect(cs.boundTick).not.eq(script.boundTick); + cs.boundTick(); + expect(cs.tickCount).eq(1); + expect(script.tickCount).eq(0); + + rootEntity.destroy(); + }); + }); + + describe("Runtime-container type defaults (Ignore)", () => { + it("an undecorated DisorderedArray slot keeps the clone's own instance", async () => { + const { DisorderedArray } = await import("@galacean/engine-core"); + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const runtimeList = new DisorderedArray(); + runtimeList.add(1); + (script as any).runtimeList = runtimeList; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + // Type-level Ignore: the slot is neither shared nor deep-cloned — the clone keeps its + // own (absent) value instead of aliasing the source's runtime container. + expect(cs.runtimeList).not.eq(runtimeList); + expect(cs.runtimeList).eq(undefined); + expect(runtimeList.length).eq(1); + + rootEntity.destroy(); + }); + + it("undecorated SafeLoopArray and UpdateFlag slots keep the clone's own value", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const loopList = (new Signal())._listeners; + loopList.push({ once: false }); + (script as any).loopList = loopList; + (script as any).flag = new BoolUpdateFlag(); + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.loopList).eq(undefined); + expect(cs.flag).eq(undefined); + expect(loopList.length).eq(1); + + rootEntity.destroy(); + }); + + it("@deepClone overrides the default on a plain container", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepContainerScript); + script.entries.add({ id: 1 }); + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepContainerScript); + + expect(cs.entries).instanceOf(DisorderedArray); + expect(cs.entries).not.eq(script.entries); + expect(cs.entries.length).eq(1); + expect(cs.entries._elements[0]).not.eq(script.entries._elements[0]); + expect(cs.entries._elements[0].id).eq(1); + + rootEntity.destroy(); + }); + + it("@deepClone on a paired flag registry throws", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepFlagManagerScript); + script.flagManager = (parent as any)._updateFlagManager; + + expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone "UpdateFlagManager"/); + + rootEntity.destroy(); + }); + }); + + describe("Null-prototype containers", () => { + it("Object.create(null) fields deep-clone as data containers, not shared references", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(HandlerScript); + const bag = Object.create(null); + bag.hp = 5; + bag.target = child; + (script as any).bag = bag; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.bag).not.eq(bag); + expect(Object.getPrototypeOf(cs.bag)).eq(null); + expect(cs.bag.hp).eq(5); + // Entity refs nested in the bag remap like in any other container. + expect(cs.bag.target).eq(cloned.children[0]); + cs.bag.hp = 9; + expect(bag.hp).eq(5); + + rootEntity.destroy(); + }); + }); + + describe("Field-walk decorator awareness", () => { + it("respects @ignoreClone on a walked non-component class", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BagHolderScript); + script.bag = new Bag(); + script.bag.kept = 42; + script.bag.runtime = 42; + + const cloned = parent.clone(); + const cs = cloned.getComponent(BagHolderScript); + + // @deepClone forces the field walk into Bag, which must still honor Bag's own @ignoreClone: + // `kept` is copied, `runtime` keeps the fresh instance's constructor value. + expect(cs.bag).not.eq(script.bag); + expect(cs.bag.kept).eq(42); + expect(cs.bag.runtime).eq(1); + + rootEntity.destroy(); + }); + }); + + describe("Container member semantics", () => { + it("@assignmentClone on a container shares the instance with the source", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(AssignedContainerScript); + script.shared.push(1, 2); + + const cloned = parent.clone(); + const cs = cloned.getComponent(AssignedContainerScript); + + expect(cs.shared).eq(script.shared); + cs.shared.push(3); + expect(script.shared.length).eq(3); + + rootEntity.destroy(); + }); + + it("Map keys are cloned along with values", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const key = { id: 7 }; + (script as any).byObject = new Map([[key, 1]]); + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + // The clone's key is a fresh object: lookups by the source key miss by design. + expect(cs.byObject.size).eq(1); + expect(cs.byObject.has(key)).eq(false); + const clonedKey = [...cs.byObject.keys()][0]; + expect(clonedKey).not.eq(key); + expect(clonedKey.id).eq(7); + expect(cs.byObject.get(clonedKey)).eq(1); + + rootEntity.destroy(); + }); + + it("entity Map keys remap to the cloned subtree", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(HandlerScript); + (script as any).byEntity = new Map([[child, 5]]); + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.byEntity.get(cloned.children[0])).eq(5); + expect(cs.byEntity.has(child)).eq(false); + + rootEntity.destroy(); + }); + + it("a function held as a Map value is shared", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const fn = () => 1; + (script as any).handlerMap = new Map([["k", fn]]); + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.handlerMap.get("k")).eq(fn); + + rootEntity.destroy(); + }); + }); + + describe("@deepClone subtree propagation", () => { + it("plain-class members of a @deepClone container are copied, not shared", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepSubtreeScript); + const config = new PlainConfig(); + config.count = 5; + script.configs.push(config); + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepSubtreeScript); + + expect(cs.configs[0]).not.eq(config); + expect(cs.configs[0].count).eq(5); + expect(cs.configs[0].nested).not.eq(config.nested); + cs.configs[0].count = 1; + expect(config.count).eq(5); + + rootEntity.destroy(); + }); + + it("the force carries through nested plain objects", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepSubtreeScript); + const config = new PlainConfig(); + script.bag = { cfg: config }; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepSubtreeScript); + + expect(cs.bag.cfg).not.eq(config); + expect(cs.bag.cfg.nested).not.eq(config.nested); + + rootEntity.destroy(); + }); + + it("engine-bound members inside a @deepClone subtree keep their defaults", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const script = parent.addComponent(DeepSubtreeScript); + const texture = new Texture2D(engine, 1, 1); + script.mixed = [child, texture]; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepSubtreeScript); + + expect(cs.mixed[0]).eq(cloned.children[0]); + expect(cs.mixed[1]).eq(texture); + + rootEntity.destroy(); + }); + }); + + describe("copyFrom value types via entity.clone", () => { + it("a Ray field deep-clones through the gate", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + (script as any).ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0)); + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.ray).instanceOf(Ray); + expect(cs.ray).not.eq((script as any).ray); + expect(cs.ray.origin.x).eq(1); + cs.ray.origin.x = 9; + expect((script as any).ray.origin.x).eq(1); + + rootEntity.destroy(); + }); + }); + + describe("Aliasing topology", () => { + it("one instance referenced three times clones into one instance referenced three times", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const vec = new Vector3(1, 2, 3); + (script as any).points = [vec, vec, vec]; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + // One NEW instance, shared by all three slots — the reference topology is preserved. + expect(cs.points[0]).not.eq(vec); + expect(cs.points[0]).eq(cs.points[1]); + expect(cs.points[1]).eq(cs.points[2]); + expect(cs.points[0].x).eq(1); + + // Mutating through one slot is visible through the others, matching the source's behavior. + cs.points[0].x = 9; + expect(cs.points[2].x).eq(9); + expect(vec.x).eq(1); + + rootEntity.destroy(); + }); + + it("a self-referencing plain object clones into a self-referencing clone", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(HandlerScript); + const node: any = { value: 1 }; + node.self = node; + const ring: any[] = [node]; + ring.push(ring); + (script as any).node = node; + (script as any).ring = ring; + + const cloned = parent.clone(); + const cs = cloned.getComponent(HandlerScript) as any; + + expect(cs.node).not.eq(node); + expect(cs.node.self).eq(cs.node); + expect(cs.ring).not.eq(ring); + expect(cs.ring[1]).eq(cs.ring); + expect(cs.ring[0]).eq(cs.node); + + rootEntity.destroy(); + }); + }); + + describe("Binary data fields", () => { + it("matching binary presets are written into, not replaced", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BinaryPresetScript); + script.weights = new Float32Array([9, 8, 7]); + const view = new DataView(new ArrayBuffer(4)); + view.setUint8(0, 42); + script.view = view; + + const cloned = parent.clone(); + const cs = cloned.getComponent(BinaryPresetScript); + + // Same length / byteLength: the clone's ctor-built instance is reused as the target. + expect(cs.weights).eq(cs.ownWeights); + expect(Array.from(cs.weights)).deep.eq([9, 8, 7]); + expect(cs.view).eq(cs.ownView); + expect(cs.view.getUint8(0)).eq(42); + + rootEntity.destroy(); + }); + + it("a length-mismatched binary preset is replaced by a fresh copy", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BinaryPresetScript); + script.shortWeights = new Float32Array([1, 2, 3]); + + const cloned = parent.clone(); + const cs = cloned.getComponent(BinaryPresetScript); + + expect(cs.shortWeights).not.eq(cs.ownShort); + expect(Array.from(cs.shortWeights)).deep.eq([1, 2, 3]); + + rootEntity.destroy(); + }); + + it("DataView field clones by bytes without crashing", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BinaryScript); + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat32(0, 3.5); + view.setUint16(4, 42); + script.view = view; + + const cloned = parent.clone(); + const cs = cloned.getComponent(BinaryScript); + + expect(cs.view).not.eq(view); + expect(cs.view.buffer).not.eq(buffer); + expect(cs.view.getFloat32(0)).eq(3.5); + expect(cs.view.getUint16(4)).eq(42); + cs.view.setUint16(4, 7); + expect(view.getUint16(4)).eq(42); + + rootEntity.destroy(); + }); + + it("typed array field clones into an independent copy", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(BinaryScript); + script.bytes = new Float32Array([1, 2, 3]); + + const cloned = parent.clone(); + const cs = cloned.getComponent(BinaryScript); + + expect(cs.bytes).not.eq(script.bytes); + expect(Array.from(cs.bytes)).deep.eq([1, 2, 3]); + cs.bytes[0] = 9; + expect(script.bytes[0]).eq(1); + + rootEntity.destroy(); + }); + + it("aliased typed arrays keep identity through the clone", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(AliasedBinaryScript); + const shared = new Float32Array([1, 2, 3]); + script.a = shared; + script.b = shared; + + const cloned = parent.clone(); + const cs = cloned.getComponent(AliasedBinaryScript); + + expect(cs.a).not.eq(shared); + expect(cs.a).eq(cs.b); + expect(Array.from(cs.a)).deep.eq([1, 2, 3]); + + rootEntity.destroy(); + }); + + it("typed-array preset aliasing the source value still yields a fresh copy", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SharedDefaultTableScript); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SharedDefaultTableScript); + + expect(cs.weights).not.eq(SharedDefaultTableScript.DEFAULT_WEIGHTS); + expect(Array.from(cs.weights)).deep.eq([1, 2, 3]); + cs.weights[0] = 9; + expect(SharedDefaultTableScript.DEFAULT_WEIGHTS[0]).eq(1); + expect(script.weights[0]).eq(1); + expect(cs.view).not.eq(SharedDefaultTableScript.DEFAULT_VIEW); + + rootEntity.destroy(); + }); + + it("array / map / set presets aliasing the source value are never written into", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + parent.addComponent(SharedDefaultContainerScript); + + const cloned = parent.clone(); + const cs = cloned.getComponent(SharedDefaultContainerScript); + + // The clone's preset IS the source value (a class-level shared default), so it can't be + // filled in place — that would write through into the source. + expect(cs.list).not.eq(SharedDefaultContainerScript.DEFAULT_LIST); + expect(cs.map).not.eq(SharedDefaultContainerScript.DEFAULT_MAP); + expect(cs.set).not.eq(SharedDefaultContainerScript.DEFAULT_SET); + expect(cs.list).deep.eq([1, 2, 3]); + expect(SharedDefaultContainerScript.DEFAULT_LIST).deep.eq([1, 2, 3]); + expect(SharedDefaultContainerScript.DEFAULT_MAP.size).eq(1); + expect(SharedDefaultContainerScript.DEFAULT_SET.size).eq(1); + + rootEntity.destroy(); + }); + + it("a reused map / set preset drops its own constructor-built entries", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(PresetContainerScript); + // Source diverges from what the clone's constructor will build. + script.map = new Map([["source", 9]]); + script.set = new Set(["source"]); + script.list = [7, 8, 9]; + + const cloned = parent.clone(); + const cs = cloned.getComponent(PresetContainerScript); + + // The clone's own preset entries ("preset") must not survive next to the source's. + expect([...cs.map.keys()]).deep.eq(["source"]); + expect([...cs.set]).deep.eq(["source"]); + expect(cs.list).deep.eq([7, 8, 9]); + // ...and the source is untouched. + expect([...script.map.keys()]).deep.eq(["source"]); + + rootEntity.destroy(); + }); + }); + + describe("Plain data carrying copyFrom-shaped keys", () => { + it("plain object with a string copyFrom key deep-clones without crashing", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + script.config = { copyFrom: "nodeA", other: 1 }; + + const cloned = parent.clone(); + const cc = cloned.getComponent(CopyFromDataScript).config; + expect(cc).not.eq(script.config); + expect(cc.copyFrom).eq("nodeA"); + expect(cc.other).eq(1); + + rootEntity.destroy(); + }); + + it("plain object with a function copyFrom key shares the function and clones the rest", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + const fn = () => 42; + script.config = { copyFrom: fn, n: 2 }; + + const cloned = parent.clone(); + const cc = cloned.getComponent(CopyFromDataScript).config; + expect(cc).not.eq(script.config); + expect(cc.copyFrom).eq(fn); + expect(cc.n).eq(2); + + rootEntity.destroy(); + }); + + it("null-prototype object with a copyFrom key deep-clones as null-prototype", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + const data = Object.create(null); + data.copyFrom = "x"; + data.v = 2; + script.config = data; + + const cloned = parent.clone(); + const cc = cloned.getComponent(CopyFromDataScript).config; + expect(cc).not.eq(data); + expect(Object.getPrototypeOf(cc)).eq(null); + expect(cc.copyFrom).eq("x"); + expect(cc.v).eq(2); + + rootEntity.destroy(); + }); + }); + + describe("Parameter-constructed Deep values as container elements", () => { + it("clones gradients and curves held in arrays / maps without a reusable preset", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + const gradient = new ParticleCompositeGradient(new Color(1, 0, 0, 1)); + const curve = new ParticleCompositeCurve(0.5); + script.config = { gradients: [gradient], curves: new Map([["a", curve]]) }; + + const cloned = parent.clone(); + const cc = cloned.getComponent(CopyFromDataScript).config; + expect(cc.gradients[0]).not.eq(gradient); + expect(cc.gradients[0].mode).eq(gradient.mode); + expect(cc.gradients[0].constant.r).eq(1); + expect(cc.curves.get("a")).not.eq(curve); + expect(cc.curves.get("a").constant).eq(0.5); + + rootEntity.destroy(); + }); + + it("a Deep type that cannot construct bare fails with the contract named", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + script.config = { items: [new ParamDeepConfig({ id: "a" })] }; + + expect(() => parent.clone()).toThrowError(/bare-construct "ParamDeepConfig"/); + + rootEntity.destroy(); + }); + + it("a host-bound instance in a container remaps when its engine slot clones first", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + // Renderer added first: its generator/module tree enters the identity map before the + // script's fields walk, so the container reference dedups onto the cloned module. + const renderer = parent.addComponent(ParticleRenderer); + const script = parent.addComponent(CopyFromDataScript); + script.config = { modules: [renderer.generator.main] }; + + const cloned = parent.clone(); + const clonedModule = cloned.getComponent(CopyFromDataScript).config.modules[0]; + expect(clonedModule).eq(cloned.getComponent(ParticleRenderer).generator.main); + expect(clonedModule).not.eq(renderer.generator.main); + + rootEntity.destroy(); + }); + + it("a host-bound instance in a container fails with the named error when no host precedes", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + // Script added first: the container walks before any engine slot registers the module, + // so the gate must bare-construct a host-bound structure — rejected with the contract + // named (sharing must be declared via @assignmentClone). + const script = parent.addComponent(CopyFromDataScript); + const renderer = parent.addComponent(ParticleRenderer); + script.config = { modules: [renderer.generator.main] }; + + expect(() => parent.clone()).toThrowError(/bare-construct "MainModule"/); + + rootEntity.destroy(); + }); + + it("every exported Deep-registered type constructs bare (gate contract)", () => { + // The gate creates container elements and preset-less slots with `new Type()` and then + // populates every field, so a Deep-registered type MUST construct without arguments. + // Exemptions are engine-bound structural types the gate only ever clones against a + // same-type constructor preset (`reusable`) — each entry states why it cannot be bare. + const exempt = new Set([ + // Physics shapes construct a PhysicsMaterial and need an initialized physics backend — + // bare-constructible in a real runtime, just not in this physics-less test engine. + "ColliderShape", + "BoxColliderShape", + "SphereColliderShape", + "CapsuleColliderShape", + "PlaneColliderShape", + "MeshColliderShape", + // Host-bound structural types wired to their host at construction — in their engine + // slots the gate clones them against the component's same-type constructor preset; a + // preset-less occurrence (e.g. a user container) fails with the named bare-construction + // error by design (share explicitly via @assignmentClone instead). + "ParticleGenerator", + "MainModule", + "VelocityOverLifetimeModule", + "SizeOverLifetimeModule", + "LimitVelocityOverLifetimeModule", + "NoiseModule" + ]); + const failures: string[] = []; + const packages: [string, Record][] = [ + ["core", EngineCore], + ["math", EngineMath], + ["ui", EngineUI] + ]; + for (const [pkg, ns] of packages) { + for (const [name, exported] of Object.entries(ns)) { + if (typeof exported !== "function" || !exported.prototype) continue; + // math value types dispatch by their callable copyFrom; core/ui Deep types are the + // DataObject family + const isDeep = + pkg === "math" + ? typeof exported.prototype.copyFrom === "function" + : exported.prototype instanceof DataObject; + if (!isDeep) continue; + if (exempt.has(name)) continue; + try { + new exported(); + } catch (e) { + failures.push(`${pkg}/${name}: ${(e as Error).message}`); + } + } + } + expect(failures).deep.eq([]); + }); + + it("orbital velocity fields deep-clone through the type default", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const renderer = parent.addComponent(ParticleRenderer); + const vol = renderer.generator.velocityOverLifetime; + vol.orbitalX = new ParticleCompositeCurve(1, 2); + vol.centerOffset.set(3, 4, 5); + + const cloned = parent.clone(); + const clonedVol = cloned.getComponent(ParticleRenderer).generator.velocityOverLifetime; + expect(clonedVol.orbitalX).not.eq(vol.orbitalX); + expect(clonedVol.orbitalX.constantMin).eq(1); + expect(clonedVol.orbitalX.constantMax).eq(2); + expect(clonedVol.centerOffset).not.eq(vol.centerOffset); + expect(clonedVol.centerOffset.z).eq(5); + + rootEntity.destroy(); + }); + + it("clones the emission bursts array through the engine path", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const renderer = parent.addComponent(ParticleRenderer); + renderer.generator.emission.addBurst(new Burst(0.5, new ParticleCompositeCurve(30))); + + const cloned = parent.clone(); + const clonedBursts = cloned.getComponent(ParticleRenderer).generator.emission.bursts; + expect(clonedBursts.length).eq(1); + expect(clonedBursts[0]).not.eq(renderer.generator.emission.bursts[0]); + expect(clonedBursts[0].time).eq(0.5); + expect(clonedBursts[0].count.constant).eq(30); + + rootEntity.destroy(); + }); + }); + + describe("Script-held ReferResource", () => { + it("cloned slot owns one reference; the script's own contract releases it", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ResourceRefScript); + const texture = new Texture2D(engine, 4, 4); + script.texture = texture; + expect(texture.refCount).eq(1); + + const cloned = parent.clone(); + const cs = cloned.getComponent(ResourceRefScript); + + // Shared by reference; the cloned slot owns one count — same contract as engine components. + expect(cs.texture).eq(texture); + expect(texture.refCount).eq(2); + + // Releasing on destroy is the script class's responsibility (onDestroy → setter -1). + cloned.destroy(); + expect(texture.refCount).eq(1); + + parent.destroy(); + expect(texture.refCount).eq(0); + + rootEntity.destroy(); + texture.destroy(); + }); + + it("a replaced owned preset releases its count even when the source slot is empty", () => { + PresetTextureScript.created.length = 0; + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(PresetTextureScript); + // The source empties the slot, releasing its own preset ownership first. + (script.texture as any)._addReferCount(-1); + script.texture = null; + + const cloned = parent.clone(); + expect(PresetTextureScript.created.length).eq(2); + const [sourcePreset, clonePreset] = PresetTextureScript.created; + + // The clone's constructor preset was displaced by the empty slot — its owned count returns. + expect(cloned.getComponent(PresetTextureScript).texture).eq(null); + expect(clonePreset.refCount).eq(0); + expect(sourcePreset.refCount).eq(0); + + rootEntity.destroy(); + }); + + it("an unowned counted preset triggers the contract diagnostic and still releases", () => { + UnownedPresetScript.created.length = 0; + const errorSpy = vi.spyOn(Logger, "error"); + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(UnownedPresetScript); + script.tex = null; + + const cloned = parent.clone(); + expect(cloned.getComponent(UnownedPresetScript).tex).eq(null); + const diagnostics = errorSpy.mock.calls.filter((c) => String(c[0]).includes("holds no owned reference")); + expect(diagnostics.length).eq(1); + // Pins the current semantics: the unconditional -1 drives the unowned preset negative. + expect(UnownedPresetScript.created[1].refCount).eq(-1); + + errorSpy.mockRestore(); + rootEntity.destroy(); + }); + + it("a replaced owned preset releases its count when displaced by a deep-cloned value", () => { + PresetTextureScript.created.length = 0; + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(PresetTextureScript); + (script.texture as any)._addReferCount(-1); + // The source slot holds a container: the gate deep-clones it, displacing the clone's preset. + (script as any).texture = [1, 2, 3]; + + const cloned = parent.clone(); + const [, clonePreset] = PresetTextureScript.created; + expect(cloned.getComponent(PresetTextureScript).texture as any).deep.eq([1, 2, 3]); + expect(clonePreset.refCount).eq(0); + + rootEntity.destroy(); + }); + + it("a user type registered Assignment without counting API shares safely", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(SharedConfigScript); + script.config = new SharedConfig(); + + const cloned = parent.clone(); + expect(cloned.getComponent(SharedConfigScript).config).eq(script.config); + + rootEntity.destroy(); + }); + }); + + describe("Single entity with multiple same-type components", () => { + it("clone preserves multiple same-type components with correct state", () => { + const rootEntity = scene.createRootEntity("root"); + const entity = rootEntity.createChild("entity"); + const script1 = entity.addComponent(CounterScript); + const script2 = entity.addComponent(CounterScript); + script1.value = 10; + script2.value = 20; + + const cloned = entity.clone(); + const clonedScripts = cloned.getComponents(CounterScript, []); + + expect(clonedScripts.length).eq(2); + expect(clonedScripts[0].value).eq(10); + expect(clonedScripts[1].value).eq(20); + expect(clonedScripts[0]).not.eq(script1); + expect(clonedScripts[1]).not.eq(script2); + + rootEntity.destroy(); + }); + + it("ref to second component of same type should remap correctly", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const counter1 = child.addComponent(CounterScript); + const counter2 = child.addComponent(CounterScript); + counter1.value = 1; + counter2.value = 2; + + const refScript = parent.addComponent(CounterRefScript); + refScript.counter = counter2; + + const cloned = parent.clone(); + const clonedRef = cloned.getComponent(CounterRefScript); + const clonedCounters = cloned.children[0].getComponents(CounterScript, []); + + expect(clonedRef.counter).not.eq(counter2); + expect(clonedRef.counter).eq(clonedCounters[1]); + expect(clonedRef.counter.value).eq(2); + + rootEntity.destroy(); + }); + + it("ref to first component of same type should remap correctly", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const counter1 = child.addComponent(CounterScript); + const counter2 = child.addComponent(CounterScript); + counter1.value = 100; + counter2.value = 200; + + const refScript = parent.addComponent(CounterRefScript); + refScript.counter = counter1; + + const cloned = parent.clone(); + const clonedRef = cloned.getComponent(CounterRefScript); + const clonedCounters = cloned.children[0].getComponents(CounterScript, []); + + expect(clonedRef.counter).not.eq(counter1); + expect(clonedRef.counter).eq(clonedCounters[0]); + expect(clonedRef.counter.value).eq(100); + + rootEntity.destroy(); + }); + + it("cross-references between multiple same-type components on same entity", () => { + const rootEntity = scene.createRootEntity("root"); + const entity = rootEntity.createChild("entity"); + const script1 = entity.addComponent(CounterScript); + const script2 = entity.addComponent(CounterScript); + script1.value = 1; + script2.value = 2; + script1.partner = script2; + script2.partner = script1; + + const cloned = entity.clone(); + const clonedScripts = cloned.getComponents(CounterScript, []); + + expect(clonedScripts[0].partner).eq(clonedScripts[1]); + expect(clonedScripts[1].partner).eq(clonedScripts[0]); + expect(clonedScripts[0].partner).not.eq(script2); + expect(clonedScripts[1].partner).not.eq(script1); + + rootEntity.destroy(); + }); + + it("self-reference among multiple same-type components remaps to correct clone", () => { + const rootEntity = scene.createRootEntity("root"); + const entity = rootEntity.createChild("entity"); + const script1 = entity.addComponent(CounterScript); + const script2 = entity.addComponent(CounterScript); + script1.partner = script1; + script2.partner = script2; + + const cloned = entity.clone(); + const clonedScripts = cloned.getComponents(CounterScript, []); + + expect(clonedScripts[0].partner).eq(clonedScripts[0]); + expect(clonedScripts[1].partner).eq(clonedScripts[1]); + + rootEntity.destroy(); + }); + + it("multiple same-type components with entity refs all remap independently", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const childA = parent.createChild("childA"); + const childB = parent.createChild("childB"); + const script1 = parent.addComponent(CounterScript); + const script2 = parent.addComponent(CounterScript); + script1.targetEntity = childA; + script2.targetEntity = childB; + + const cloned = parent.clone(); + const clonedScripts = cloned.getComponents(CounterScript, []); + + expect(clonedScripts[0].targetEntity).eq(cloned.children[0]); + expect(clonedScripts[1].targetEntity).eq(cloned.children[1]); + expect(clonedScripts[0].targetEntity.name).eq("childA"); + expect(clonedScripts[1].targetEntity.name).eq("childB"); + + rootEntity.destroy(); + }); + + it("@deepClone array referencing specific component among same-type siblings", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const counter1 = child.addComponent(CounterScript); + const counter2 = child.addComponent(CounterScript); + const counter3 = child.addComponent(CounterScript); + counter1.value = 1; + counter2.value = 2; + counter3.value = 3; + + const arrayScript = parent.addComponent(ArrayRefScript); + // Note: ArrayRefScript uses Entity[], but we test component indexing + // via direct component references instead + const refScript1 = parent.addComponent(CounterRefScript); + const refScript2 = parent.addComponent(CounterRefScript); + refScript1.counter = counter1; + refScript2.counter = counter3; + + const cloned = parent.clone(); + const clonedRefs = cloned.getComponents(CounterRefScript, []); + const clonedCounters = cloned.children[0].getComponents(CounterScript, []); + + expect(clonedRefs[0].counter).eq(clonedCounters[0]); + expect(clonedRefs[0].counter.value).eq(1); + expect(clonedRefs[1].counter).eq(clonedCounters[2]); + expect(clonedRefs[1].counter.value).eq(3); + + rootEntity.destroy(); + }); + }); +}); diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts new file mode 100644 index 0000000000..f4eafdd53d --- /dev/null +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -0,0 +1,120 @@ +import { + Animator, + AnimatorController, + Camera, + Entity, + Font, + MeshRenderer, + RenderTarget, + SkinnedMeshRenderer, + TextRenderer, + Texture2D +} from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine"; +import { describe, beforeAll, expect, it } from "vitest"; + +describe("Clone resource refCount", async function () { + let engine: WebGLEngine; + let rootEntity: Entity; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + rootEntity = engine.sceneManager.activeScene.createRootEntity(); + engine.run(); + }); + + it("renderer-private joint texture is not propagated into clones", () => { + const entity = rootEntity.createChild("skinnedSrc"); + const smr = entity.addComponent(SkinnedMeshRenderer); + // Simulate the state _update builds when the bone count exceeds the uniform budget. + const jointTexture = new Texture2D(engine, 4, 4); + jointTexture.isGCIgnored = true; + // @ts-ignore + smr._jointTexture = jointTexture; + smr.shaderData.setTexture("renderer_JointSampler", jointTexture); + expect(jointTexture.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + const clonedSmr = clone.getComponent(SkinnedMeshRenderer); + // The clone must not hold the source's private texture; it rebuilds its own on first update. + expect(clonedSmr.shaderData.getTexture("renderer_JointSampler") ?? null).eq(null); + expect(jointTexture.refCount).eq(1); + + // With no stray reference, the source's one-shot destroy() in _onDestroy succeeds (refCount is zero). + entity.destroy(); + expect(jointTexture.refCount).eq(0); + + clone.destroy(); + }); + + it("clone shares texture with balanced refCount (no leak)", () => { + const texture = new Texture2D(engine, 1, 1); + const entity = rootEntity.createChild("src"); + entity.addComponent(MeshRenderer).shaderData.setTexture("u_tex", texture); + expect(texture.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The clone owns an independent shaderData that references the shared texture → +1. + expect(texture.refCount).eq(2); + + clone.destroy(); + // Destroying the clone releases that reference → back to baseline, no leak. + expect(texture.refCount).eq(1); + + entity.destroy(); + }); + + it("camera renderTarget refCount stays balanced across clone/destroy", () => { + const rt = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4)); + const entity = rootEntity.createChild("cameraSrc"); + entity.addComponent(Camera).renderTarget = rt; + expect(rt.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The clone holds exactly one additional reference (acquired by the clone gate on the shared `_renderTarget` slot). + expect(rt.refCount).eq(2); + + clone.destroy(); + expect(rt.refCount).eq(1); + + entity.destroy(); + expect(rt.refCount).eq(0); + }); + + it("text renderer font refCount stays balanced across clone/destroy", () => { + const font = Font.createFromOS(engine, "Arial-CloneTest"); + const entity = rootEntity.createChild("textSrc"); + entity.addComponent(TextRenderer).font = font; + const baseline = font.refCount; + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The clone holds exactly one additional reference (acquired by the clone gate on the shared `_font` slot). + expect(font.refCount).eq(baseline + 1); + + clone.destroy(); + expect(font.refCount).eq(baseline); + + entity.destroy(); + }); + + it("animator controller refCount stays balanced across clone/destroy", () => { + const controller = new AnimatorController(engine); + const entity = rootEntity.createChild("animatorSrc"); + entity.addComponent(Animator).animatorController = controller; + const baseline = controller.refCount; + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The clone holds one additional reference (clone gate on the shared `_animatorController` slot; _cloneTo only re-registers the change flag). + expect(controller.refCount).eq(baseline + 1); + + clone.destroy(); + expect(controller.refCount).eq(baseline); + + entity.destroy(); + }); +}); diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts deleted file mode 100644 index 8cf3dd9d55..0000000000 --- a/tests/src/core/CloneUtils.test.ts +++ /dev/null @@ -1,870 +0,0 @@ -import { Entity, MeshRenderer, Script, Signal, assignmentClone, deepClone, ignoreClone } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; -import { describe, expect, it } from "vitest"; - -class TestScript extends Script { - targetEntity: Entity; - targetRenderer: MeshRenderer; - externalEntity: Entity; - externalRenderer: MeshRenderer; - deepChild: Entity; - selfRef: Entity; - speed: number; - name2: string; - flag: boolean; - data: object; -} - -/** Script with multiple entity/component refs pointing to different nodes */ -class MultiRefScript extends Script { - entityA: Entity; - entityB: Entity; - rendererA: MeshRenderer; - rendererB: MeshRenderer; -} - -/** Script where the same entity is referenced by multiple properties */ -class DuplicateRefScript extends Script { - ref1: Entity; - ref2: Entity; -} - -/** Script with null/undefined entity/component refs */ -class NullRefScript extends Script { - nullEntity: Entity = null; - undefinedEntity: Entity; - nullRenderer: MeshRenderer = null; - someNumber: number = 0; -} - -/** Script referencing a sibling entity (not parent/child, but sibling under clone root) */ -class SiblingRefScript extends Script { - sibling: Entity; - siblingRenderer: MeshRenderer; -} - -/** Script with a mix of decorated and undecorated entity refs */ -class DecoratedRefScript extends Script { - // Undecorated — should auto-remap via _remap - autoRemapEntity: Entity; - - // @assignmentClone — should still auto-remap since _remap takes priority - @assignmentClone - assignedEntity: Entity; - - // @ignoreClone — should still auto-remap since _remap takes priority - @ignoreClone - ignoredEntity: Entity; -} - -/** Script with a @deepClone array of entities */ -class ArrayRefScript extends Script { - @deepClone - entities: Entity[] = []; -} - -/** Script with Component self-reference */ -class SelfComponentRefScript extends Script { - selfScript: SelfComponentRefScript; -} - -/** Script referencing another Script on a different entity */ -class CrossScriptRefScript extends Script { - otherScript: TestScript; -} - -/** Script with a nested plain object containing entity refs */ -class NestedObjectScript extends Script { - @deepClone - config: { target: Entity; label: string } = { target: null, label: "" }; -} - -/** Script for testing multiple same-type components on one entity */ -class CounterScript extends Script { - value: number = 0; - partner: CounterScript; - targetEntity: Entity; -} - -/** Script that references a CounterScript */ -class CounterRefScript extends Script { - counter: CounterScript; -} - -/** Handler script used for Signal structured binding tests */ -class ClickHandler extends Script { - callCount = 0; - lastPrefix: string = ""; - - handleClick(): void { - this.callCount++; - } - - handleClickWithPrefix(arg: number, prefix: string): void { - this.callCount++; - this.lastPrefix = prefix; - } -} - -/** Script with a Signal property */ -class SignalScript extends Script { - @deepClone - readonly onFire = new Signal<[number]>(); -} - -describe("Clone remap", async () => { - const engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); - const scene = engine.sceneManager.activeScene; - engine.run(); - - describe("Basic Entity/Component remap", () => { - it("script undecorated Entity ref should remap to cloned entity", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(TestScript); - script.targetEntity = child; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - const clonedChild = clonedParent.children[0]; - - expect(clonedScript.targetEntity).not.eq(child); - expect(clonedScript.targetEntity).eq(clonedChild); - expect(clonedScript.targetEntity.name).eq("child"); - - rootEntity.destroy(); - }); - - it("script undecorated Component ref should remap to cloned component", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const meshRenderer = child.addComponent(MeshRenderer); - const script = parent.addComponent(TestScript); - script.targetRenderer = meshRenderer; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - const clonedChild = clonedParent.children[0]; - const clonedMeshRenderer = clonedChild.getComponent(MeshRenderer); - - expect(clonedScript.targetRenderer).not.eq(meshRenderer); - expect(clonedScript.targetRenderer).eq(clonedMeshRenderer); - - rootEntity.destroy(); - }); - - it("script ref to entity outside hierarchy should keep original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const script = parent.addComponent(TestScript); - script.externalEntity = external; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - - expect(clonedScript.externalEntity).eq(external); - - rootEntity.destroy(); - }); - - it("script ref to component outside hierarchy should keep original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const externalMR = external.addComponent(MeshRenderer); - const script = parent.addComponent(TestScript); - script.externalRenderer = externalMR; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - - expect(clonedScript.externalRenderer).eq(externalMR); - - rootEntity.destroy(); - }); - - it("deep hierarchy entity ref should remap correctly", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const grandchild = child.createChild("grandchild"); - const script = parent.addComponent(TestScript); - script.deepChild = grandchild; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - const clonedGrandchild = clonedParent.children[0].children[0]; - - expect(clonedScript.deepChild).not.eq(grandchild); - expect(clonedScript.deepChild).eq(clonedGrandchild); - expect(clonedScript.deepChild.name).eq("grandchild"); - - rootEntity.destroy(); - }); - - it("script ref to self entity (clone root) should remap", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(TestScript); - script.selfRef = parent; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - - expect(clonedScript.selfRef).not.eq(parent); - expect(clonedScript.selfRef).eq(clonedParent); - - rootEntity.destroy(); - }); - - it("primitive and plain object props should not be affected", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(TestScript); - const obj = { x: 1 }; - script.speed = 42; - script.name2 = "test"; - script.flag = true; - script.data = obj; - - const clonedParent = parent.clone(); - const clonedScript = clonedParent.getComponent(TestScript); - - expect(clonedScript.speed).eq(42); - expect(clonedScript.name2).eq("test"); - expect(clonedScript.flag).eq(true); - expect(clonedScript.data).eq(obj); - - rootEntity.destroy(); - }); - }); - - describe("Multiple and duplicate refs", () => { - it("multiple entity/component refs on same script all remap independently", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const childA = parent.createChild("childA"); - const childB = parent.createChild("childB"); - const mrA = childA.addComponent(MeshRenderer); - const mrB = childB.addComponent(MeshRenderer); - const script = parent.addComponent(MultiRefScript); - script.entityA = childA; - script.entityB = childB; - script.rendererA = mrA; - script.rendererB = mrB; - - const cloned = parent.clone(); - const cs = cloned.getComponent(MultiRefScript); - - expect(cs.entityA).not.eq(childA); - expect(cs.entityB).not.eq(childB); - expect(cs.entityA.name).eq("childA"); - expect(cs.entityB.name).eq("childB"); - expect(cs.entityA).eq(cloned.children[0]); - expect(cs.entityB).eq(cloned.children[1]); - expect(cs.rendererA).eq(cloned.children[0].getComponent(MeshRenderer)); - expect(cs.rendererB).eq(cloned.children[1].getComponent(MeshRenderer)); - - rootEntity.destroy(); - }); - - it("two properties referencing the same entity both remap to the same cloned entity", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(DuplicateRefScript); - script.ref1 = child; - script.ref2 = child; - - const cloned = parent.clone(); - const cs = cloned.getComponent(DuplicateRefScript); - - expect(cs.ref1).not.eq(child); - expect(cs.ref1).eq(cs.ref2); - expect(cs.ref1).eq(cloned.children[0]); - - rootEntity.destroy(); - }); - }); - - describe("Null and undefined refs", () => { - it("null entity/component refs should not crash and remain null", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(NullRefScript); - - const cloned = parent.clone(); - const cs = cloned.getComponent(NullRefScript); - - expect(cs.nullEntity).eq(null); - expect(cs.nullRenderer).eq(null); - expect(cs.someNumber).eq(0); - - rootEntity.destroy(); - }); - }); - - describe("Sibling entity refs", () => { - it("ref to sibling entity under clone root should remap", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const childA = parent.createChild("childA"); - const childB = parent.createChild("childB"); - const mrB = childB.addComponent(MeshRenderer); - const script = childA.addComponent(SiblingRefScript); - script.sibling = childB; - script.siblingRenderer = mrB; - - const cloned = parent.clone(); - const clonedChildA = cloned.children[0]; - const clonedChildB = cloned.children[1]; - const cs = clonedChildA.getComponent(SiblingRefScript); - - expect(cs.sibling).not.eq(childB); - expect(cs.sibling).eq(clonedChildB); - expect(cs.siblingRenderer).not.eq(mrB); - expect(cs.siblingRenderer).eq(clonedChildB.getComponent(MeshRenderer)); - - rootEntity.destroy(); - }); - }); - - describe("Clone decorator interaction with _remap", () => { - it("@assignmentClone entity ref still gets remapped via _remap priority", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(DecoratedRefScript); - script.assignedEntity = child; - - const cloned = parent.clone(); - const cs = cloned.getComponent(DecoratedRefScript); - - expect(cs.assignedEntity).not.eq(child); - expect(cs.assignedEntity).eq(cloned.children[0]); - - rootEntity.destroy(); - }); - - it("@ignoreClone entity ref still gets remapped via _remap priority", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(DecoratedRefScript); - script.ignoredEntity = child; - - const cloned = parent.clone(); - const cs = cloned.getComponent(DecoratedRefScript); - - expect(cs.ignoredEntity).not.eq(child); - expect(cs.ignoredEntity).eq(cloned.children[0]); - - rootEntity.destroy(); - }); - - it("undecorated entity ref remaps correctly", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(DecoratedRefScript); - script.autoRemapEntity = child; - - const cloned = parent.clone(); - const cs = cloned.getComponent(DecoratedRefScript); - - expect(cs.autoRemapEntity).not.eq(child); - expect(cs.autoRemapEntity).eq(cloned.children[0]); - - rootEntity.destroy(); - }); - - it("@ignoreClone entity ref outside hierarchy stays original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const script = parent.addComponent(DecoratedRefScript); - script.ignoredEntity = external; - - const cloned = parent.clone(); - const cs = cloned.getComponent(DecoratedRefScript); - - expect(cs.ignoredEntity).eq(external); - - rootEntity.destroy(); - }); - }); - - describe("@deepClone array of entities", () => { - it("deep cloned entity array should remap internal refs", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const childA = parent.createChild("childA"); - const childB = parent.createChild("childB"); - const script = parent.addComponent(ArrayRefScript); - script.entities = [childA, childB]; - - const cloned = parent.clone(); - const cs = cloned.getComponent(ArrayRefScript); - - expect(cs.entities).not.eq(script.entities); - expect(cs.entities.length).eq(2); - expect(cs.entities[0]).not.eq(childA); - expect(cs.entities[1]).not.eq(childB); - expect(cs.entities[0]).eq(cloned.children[0]); - expect(cs.entities[1]).eq(cloned.children[1]); - - rootEntity.destroy(); - }); - - it("deep cloned entity array with external ref keeps original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const external = rootEntity.createChild("external"); - const script = parent.addComponent(ArrayRefScript); - script.entities = [child, external]; - - const cloned = parent.clone(); - const cs = cloned.getComponent(ArrayRefScript); - - expect(cs.entities[0]).eq(cloned.children[0]); - expect(cs.entities[1]).eq(external); - - rootEntity.destroy(); - }); - - it("deep cloned empty entity array stays empty", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(ArrayRefScript); - script.entities = []; - - const cloned = parent.clone(); - const cs = cloned.getComponent(ArrayRefScript); - - expect(cs.entities).not.eq(script.entities); - expect(cs.entities.length).eq(0); - - rootEntity.destroy(); - }); - }); - - describe("Component self and cross references", () => { - it("script referencing itself should remap to cloned script", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(SelfComponentRefScript); - script.selfScript = script; - - const cloned = parent.clone(); - const cs = cloned.getComponent(SelfComponentRefScript); - - expect(cs.selfScript).not.eq(script); - expect(cs.selfScript).eq(cs); - - rootEntity.destroy(); - }); - - it("script referencing another script on child entity should remap", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const childScript = child.addComponent(TestScript); - const script = parent.addComponent(CrossScriptRefScript); - script.otherScript = childScript; - - const cloned = parent.clone(); - const cs = cloned.getComponent(CrossScriptRefScript); - const clonedChildScript = cloned.children[0].getComponent(TestScript); - - expect(cs.otherScript).not.eq(childScript); - expect(cs.otherScript).eq(clonedChildScript); - - rootEntity.destroy(); - }); - - it("script referencing external script should keep original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const externalScript = external.addComponent(TestScript); - const script = parent.addComponent(CrossScriptRefScript); - script.otherScript = externalScript; - - const cloned = parent.clone(); - const cs = cloned.getComponent(CrossScriptRefScript); - - expect(cs.otherScript).eq(externalScript); - - rootEntity.destroy(); - }); - }); - - describe("Nested @deepClone object with entity refs", () => { - it("entity ref inside deep cloned plain object should remap", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = parent.addComponent(NestedObjectScript); - script.config = { target: child, label: "hello" }; - - const cloned = parent.clone(); - const cs = cloned.getComponent(NestedObjectScript); - - expect(cs.config).not.eq(script.config); - expect(cs.config.label).eq("hello"); - expect(cs.config.target).not.eq(child); - expect(cs.config.target).eq(cloned.children[0]); - - rootEntity.destroy(); - }); - - it("entity ref inside deep cloned object pointing outside keeps original", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const script = parent.addComponent(NestedObjectScript); - script.config = { target: external, label: "ext" }; - - const cloned = parent.clone(); - const cs = cloned.getComponent(NestedObjectScript); - - expect(cs.config.target).eq(external); - expect(cs.config.label).eq("ext"); - - rootEntity.destroy(); - }); - }); - - describe("Signal clone with structured bindings", () => { - it("@deepClone Signal should not copy closure listeners", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(SignalScript); - let called = false; - script.onFire.on(() => { - called = true; - }); - - const cloned = parent.clone(); - const cs = cloned.getComponent(SignalScript); - - expect(cs.onFire).not.eq(script.onFire); - cs.onFire.invoke(1); - expect(called).eq(false); - - rootEntity.destroy(); - }); - - it("@deepClone Signal should remap structured binding target to cloned hierarchy", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const handlerEntity = parent.createChild("handler"); - const handler = handlerEntity.addComponent(ClickHandler); - const script = parent.addComponent(SignalScript); - script.onFire.on(handler, "handleClick"); - - const cloned = parent.clone(); - const cs = cloned.getComponent(SignalScript); - const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); - - cs.onFire.invoke(1); - expect(clonedHandler.callCount).eq(1); - expect(handler.callCount).eq(0); - - rootEntity.destroy(); - }); - - it("@deepClone Signal should keep external structured binding target", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const external = rootEntity.createChild("external"); - const externalHandler = external.addComponent(ClickHandler); - const script = parent.addComponent(SignalScript); - script.onFire.on(externalHandler, "handleClick"); - - const cloned = parent.clone(); - const cs = cloned.getComponent(SignalScript); - - cs.onFire.invoke(1); - expect(externalHandler.callCount).eq(1); - - rootEntity.destroy(); - }); - - it("@deepClone Signal should remap structured binding with pre-resolved args", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const handlerEntity = parent.createChild("handler"); - const handler = handlerEntity.addComponent(ClickHandler); - const script = parent.addComponent(SignalScript); - script.onFire.on(handler, "handleClickWithPrefix", "myPrefix"); - - const cloned = parent.clone(); - const cs = cloned.getComponent(SignalScript); - const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); - - cs.onFire.invoke(1); - expect(clonedHandler.callCount).eq(1); - expect(clonedHandler.lastPrefix).eq("myPrefix"); - expect(handler.callCount).eq(0); - - rootEntity.destroy(); - }); - - it("@deepClone Signal should preserve once flag on structured binding", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const handlerEntity = parent.createChild("handler"); - const handler = handlerEntity.addComponent(ClickHandler); - const script = parent.addComponent(SignalScript); - script.onFire.once(handler, "handleClick"); - - const cloned = parent.clone(); - const cs = cloned.getComponent(SignalScript); - const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler); - - cs.onFire.invoke(1); - expect(clonedHandler.callCount).eq(1); - cs.onFire.invoke(2); - expect(clonedHandler.callCount).eq(1); // once: removed after first call - - rootEntity.destroy(); - }); - }); - - describe("Clone hierarchy integrity", () => { - it("clone preserves children count and names", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - parent.createChild("a"); - parent.createChild("b"); - parent.createChild("c"); - - const cloned = parent.clone(); - expect(cloned.children.length).eq(3); - expect(cloned.children[0].name).eq("a"); - expect(cloned.children[1].name).eq("b"); - expect(cloned.children[2].name).eq("c"); - - rootEntity.destroy(); - }); - - it("clone of deeply nested hierarchy preserves structure", () => { - const rootEntity = scene.createRootEntity("root"); - const a = rootEntity.createChild("a"); - const b = a.createChild("b"); - const c = b.createChild("c"); - const d = c.createChild("d"); - - const cloned = a.clone(); - expect(cloned.children[0].name).eq("b"); - expect(cloned.children[0].children[0].name).eq("c"); - expect(cloned.children[0].children[0].children[0].name).eq("d"); - - rootEntity.destroy(); - }); - - it("script on child entity with ref to parent should remap", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const script = child.addComponent(TestScript); - script.targetEntity = parent; - - const cloned = parent.clone(); - const clonedChild = cloned.children[0]; - const cs = clonedChild.getComponent(TestScript); - - expect(cs.targetEntity).not.eq(parent); - expect(cs.targetEntity).eq(cloned); - - rootEntity.destroy(); - }); - - it("multiple scripts on different entities with cross-refs all remap correctly", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const childA = parent.createChild("childA"); - const childB = parent.createChild("childB"); - - const scriptA = childA.addComponent(TestScript); - scriptA.targetEntity = childB; - - const scriptB = childB.addComponent(TestScript); - scriptB.targetEntity = childA; - - const cloned = parent.clone(); - const clonedA = cloned.children[0]; - const clonedB = cloned.children[1]; - const csA = clonedA.getComponent(TestScript); - const csB = clonedB.getComponent(TestScript); - - expect(csA.targetEntity).eq(clonedB); - expect(csB.targetEntity).eq(clonedA); - - rootEntity.destroy(); - }); - }); - - describe("Single entity with multiple same-type components", () => { - it("clone preserves multiple same-type components with correct state", () => { - const rootEntity = scene.createRootEntity("root"); - const entity = rootEntity.createChild("entity"); - const script1 = entity.addComponent(CounterScript); - const script2 = entity.addComponent(CounterScript); - script1.value = 10; - script2.value = 20; - - const cloned = entity.clone(); - const clonedScripts = cloned.getComponents(CounterScript, []); - - expect(clonedScripts.length).eq(2); - expect(clonedScripts[0].value).eq(10); - expect(clonedScripts[1].value).eq(20); - expect(clonedScripts[0]).not.eq(script1); - expect(clonedScripts[1]).not.eq(script2); - - rootEntity.destroy(); - }); - - it("ref to second component of same type should remap correctly", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const counter1 = child.addComponent(CounterScript); - const counter2 = child.addComponent(CounterScript); - counter1.value = 1; - counter2.value = 2; - - const refScript = parent.addComponent(CounterRefScript); - refScript.counter = counter2; - - const cloned = parent.clone(); - const clonedRef = cloned.getComponent(CounterRefScript); - const clonedCounters = cloned.children[0].getComponents(CounterScript, []); - - expect(clonedRef.counter).not.eq(counter2); - expect(clonedRef.counter).eq(clonedCounters[1]); - expect(clonedRef.counter.value).eq(2); - - rootEntity.destroy(); - }); - - it("ref to first component of same type should remap correctly", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const counter1 = child.addComponent(CounterScript); - const counter2 = child.addComponent(CounterScript); - counter1.value = 100; - counter2.value = 200; - - const refScript = parent.addComponent(CounterRefScript); - refScript.counter = counter1; - - const cloned = parent.clone(); - const clonedRef = cloned.getComponent(CounterRefScript); - const clonedCounters = cloned.children[0].getComponents(CounterScript, []); - - expect(clonedRef.counter).not.eq(counter1); - expect(clonedRef.counter).eq(clonedCounters[0]); - expect(clonedRef.counter.value).eq(100); - - rootEntity.destroy(); - }); - - it("cross-references between multiple same-type components on same entity", () => { - const rootEntity = scene.createRootEntity("root"); - const entity = rootEntity.createChild("entity"); - const script1 = entity.addComponent(CounterScript); - const script2 = entity.addComponent(CounterScript); - script1.value = 1; - script2.value = 2; - script1.partner = script2; - script2.partner = script1; - - const cloned = entity.clone(); - const clonedScripts = cloned.getComponents(CounterScript, []); - - expect(clonedScripts[0].partner).eq(clonedScripts[1]); - expect(clonedScripts[1].partner).eq(clonedScripts[0]); - expect(clonedScripts[0].partner).not.eq(script2); - expect(clonedScripts[1].partner).not.eq(script1); - - rootEntity.destroy(); - }); - - it("self-reference among multiple same-type components remaps to correct clone", () => { - const rootEntity = scene.createRootEntity("root"); - const entity = rootEntity.createChild("entity"); - const script1 = entity.addComponent(CounterScript); - const script2 = entity.addComponent(CounterScript); - script1.partner = script1; - script2.partner = script2; - - const cloned = entity.clone(); - const clonedScripts = cloned.getComponents(CounterScript, []); - - expect(clonedScripts[0].partner).eq(clonedScripts[0]); - expect(clonedScripts[1].partner).eq(clonedScripts[1]); - - rootEntity.destroy(); - }); - - it("multiple same-type components with entity refs all remap independently", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const childA = parent.createChild("childA"); - const childB = parent.createChild("childB"); - const script1 = parent.addComponent(CounterScript); - const script2 = parent.addComponent(CounterScript); - script1.targetEntity = childA; - script2.targetEntity = childB; - - const cloned = parent.clone(); - const clonedScripts = cloned.getComponents(CounterScript, []); - - expect(clonedScripts[0].targetEntity).eq(cloned.children[0]); - expect(clonedScripts[1].targetEntity).eq(cloned.children[1]); - expect(clonedScripts[0].targetEntity.name).eq("childA"); - expect(clonedScripts[1].targetEntity.name).eq("childB"); - - rootEntity.destroy(); - }); - - it("@deepClone array referencing specific component among same-type siblings", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const child = parent.createChild("child"); - const counter1 = child.addComponent(CounterScript); - const counter2 = child.addComponent(CounterScript); - const counter3 = child.addComponent(CounterScript); - counter1.value = 1; - counter2.value = 2; - counter3.value = 3; - - const arrayScript = parent.addComponent(ArrayRefScript); - // Note: ArrayRefScript uses Entity[], but we test component indexing - // via direct component references instead - const refScript1 = parent.addComponent(CounterRefScript); - const refScript2 = parent.addComponent(CounterRefScript); - refScript1.counter = counter1; - refScript2.counter = counter3; - - const cloned = parent.clone(); - const clonedRefs = cloned.getComponents(CounterRefScript, []); - const clonedCounters = cloned.children[0].getComponents(CounterScript, []); - - expect(clonedRefs[0].counter).eq(clonedCounters[0]); - expect(clonedRefs[0].counter.value).eq(1); - expect(clonedRefs[1].counter).eq(clonedCounters[2]); - expect(clonedRefs[1].counter.value).eq(3); - - rootEntity.destroy(); - }); - }); -}); diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index b34a94cb0a..67bec83495 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -1,5 +1,5 @@ import { Quaternion } from "@galacean/engine"; -import { DynamicCollider, Entity, EntityModifyFlags, Scene, Script } from "@galacean/engine-core"; +import { DynamicCollider, Entity, EntityModifyFlags, Logger, Scene, Script } from "@galacean/engine-core"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { WebGLEngine } from "@galacean/engine"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -328,8 +328,46 @@ describe("Entity", async () => { child.parent = parent; const child2 = new Entity(engine, "child2"); child2.parent = parent; + + const parentModifyCount = [0, 0, 0]; + const childModifyCount = [0, 0, 0]; + const child2ModifyCount = [0, 0, 0]; + const childCountsDuringChildModify: number[] = []; + const remainingChildrenConsistent: boolean[] = []; + const detachedChildCounts: number[] = []; + // @ts-ignore + parent._registerModifyListener((flag: EntityModifyFlags) => { + ++parentModifyCount[flag]; + if (flag === EntityModifyFlags.Child) { + childCountsDuringChildModify.push(parent.children.length); + remainingChildrenConsistent.push(parent.children.every((child) => child.parent === parent)); + detachedChildCounts.push(Number(child.parent === null) + Number(child2.parent === null)); + } + }); + // @ts-ignore + child._registerModifyListener((flag: EntityModifyFlags) => ++childModifyCount[flag]); + // @ts-ignore + child2._registerModifyListener((flag: EntityModifyFlags) => ++child2ModifyCount[flag]); + parent.clearChildren(); expect(parent.children.length).eq(0); + + // Clearing children is equivalent to removing them from back to front. Each removal + // dispatches after the hierarchy is updated but before inactive listeners are cleaned up. + expect(parentModifyCount[EntityModifyFlags.Child]).eq(2); + expect(childCountsDuringChildModify).deep.eq([1, 0]); + expect(remainingChildrenConsistent).deep.eq([true, true]); + expect(detachedChildCounts).deep.eq([1, 2]); + // Each detached child should receive a `Parent` modify event. + expect(childModifyCount[EntityModifyFlags.Parent]).eq(1); + expect(child2ModifyCount[EntityModifyFlags.Parent]).eq(1); + // Sibling index must be reset so the entity is treated as lonely afterwards. + expect(child.siblingIndex).eq(-1); + expect(child2.siblingIndex).eq(-1); + + // Clearing an empty hierarchy should not dispatch another modify event. + parent.clearChildren(); + expect(parentModifyCount[EntityModifyFlags.Child]).eq(2); }); it("sibling index", () => { const root = scene.createRootEntity(); @@ -395,12 +433,16 @@ describe("Entity", async () => { }; expect(siblingIndexBadFn).to.throw(); - // thorw error when set lonely entity + // setting sibling index on a lonely entity (no parent, not in scene root) warns instead of throwing const entityX = new Entity(engine, "entityX"); - var lonelyBadFn = function () { + const warnSpy = vi.spyOn(Logger, "warn").mockImplementation(() => {}); + var lonelyFn = function () { entityX.siblingIndex = 1; }; - expect(lonelyBadFn).to.throw(); + expect(lonelyFn).not.to.throw(); + expect(entityX.siblingIndex).eq(-1); + expect(warnSpy).toHaveBeenCalledOnce(); + warnSpy.mockRestore(); }); it("isRoot", () => { diff --git a/tests/src/core/RefCountContract.test.ts b/tests/src/core/RefCountContract.test.ts new file mode 100644 index 0000000000..392aeb1520 --- /dev/null +++ b/tests/src/core/RefCountContract.test.ts @@ -0,0 +1,266 @@ +import { + Animator, + AnimatorController, + AudioClip, + AudioSource, + Camera, + Entity, + MeshRenderer, + MeshShape, + ModelMesh, + ParticleRenderer, + RenderTarget, + Sprite, + SpriteMask, + SpriteRenderer, + Texture2D +} from "@galacean/engine-core"; +import { Vector3 } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * Slot-ownership contract: a component top-level field sharing a ref-counted resource owns one + * reference — acquired by the setter (user path) or the clone gate (clone path), transferred by + * setter churn (-old/+new), and released by the component's destroy path. + * Every suite below asserts the full lifecycle: baseline → clone +1 → churn → destroy release. + */ +describe("RefCount slot-ownership contract", () => { + let engine: WebGLEngine; + let rootEntity: Entity; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + rootEntity = engine.sceneManager.activeScene.createRootEntity(); + engine.run(); + }); + + describe("Camera.renderTarget", () => { + it("clone acquires, setter churn transfers, destroy releases", () => { + const rtA = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4)); + const rtB = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4)); + const entity = rootEntity.createChild("cameraSlot"); + entity.addComponent(Camera).renderTarget = rtA; + expect(rtA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(rtA.refCount).eq(2); + + clone.getComponent(Camera).renderTarget = rtB; + expect(rtA.refCount).eq(1); + expect(rtB.refCount).eq(1); + + clone.destroy(); + expect(rtB.refCount).eq(0); + expect(rtA.refCount).eq(1); + + entity.destroy(); + expect(rtA.refCount).eq(0); + }); + }); + + describe("Animator.animatorController", () => { + it("clone acquires, setter churn transfers, destroy releases", () => { + const controllerA = new AnimatorController(engine); + const controllerB = new AnimatorController(engine); + const entity = rootEntity.createChild("animatorSlot"); + entity.addComponent(Animator).animatorController = controllerA; + expect(controllerA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(controllerA.refCount).eq(2); + + clone.getComponent(Animator).animatorController = controllerB; + expect(controllerA.refCount).eq(1); + expect(controllerB.refCount).eq(1); + + clone.destroy(); + expect(controllerB.refCount).eq(0); + expect(controllerA.refCount).eq(1); + + entity.destroy(); + expect(controllerA.refCount).eq(0); + }); + }); + + describe("AudioSource.clip", () => { + it("clone acquires, setter churn transfers, destroy releases", () => { + const clipA = new AudioClip(engine, "clipA"); + const clipB = new AudioClip(engine, "clipB"); + const entity = rootEntity.createChild("audioSlot"); + entity.addComponent(AudioSource).clip = clipA; + expect(clipA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(clipA.refCount).eq(2); + + clone.getComponent(AudioSource).clip = clipB; + expect(clipA.refCount).eq(1); + expect(clipB.refCount).eq(1); + + clone.destroy(); + expect(clipB.refCount).eq(0); + expect(clipA.refCount).eq(1); + + entity.destroy(); + expect(clipA.refCount).eq(0); + }); + }); + + describe("SpriteRenderer.sprite", () => { + it("clone acquires, setter churn transfers, destroy releases", () => { + const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1)); + const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1)); + const entity = rootEntity.createChild("spriteRendererSlot"); + entity.addComponent(SpriteRenderer).sprite = spriteA; + expect(spriteA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(spriteA.refCount).eq(2); + + clone.getComponent(SpriteRenderer).sprite = spriteB; + expect(spriteA.refCount).eq(1); + expect(spriteB.refCount).eq(1); + + clone.destroy(); + expect(spriteB.refCount).eq(0); + expect(spriteA.refCount).eq(1); + + entity.destroy(); + expect(spriteA.refCount).eq(0); + }); + }); + + describe("SpriteMask.sprite", () => { + it("clone acquires, setter churn transfers, destroy releases", () => { + const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1)); + const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1)); + const entity = rootEntity.createChild("spriteMaskSlot"); + entity.addComponent(SpriteMask).sprite = spriteA; + expect(spriteA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(spriteA.refCount).eq(2); + + clone.getComponent(SpriteMask).sprite = spriteB; + expect(spriteA.refCount).eq(1); + expect(spriteB.refCount).eq(1); + + clone.destroy(); + expect(spriteB.refCount).eq(0); + expect(spriteA.refCount).eq(1); + + entity.destroy(); + expect(spriteA.refCount).eq(0); + }); + }); + + describe("MeshRenderer.mesh (setter-mode slot)", () => { + it("clone acquires via the setter in _cloneTo, churn transfers, destroy releases", () => { + const meshA = new ModelMesh(engine); + const meshB = new ModelMesh(engine); + const entity = rootEntity.createChild("meshRendererSlot"); + entity.addComponent(MeshRenderer).mesh = meshA; + expect(meshA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(meshA.refCount).eq(2); + + clone.getComponent(MeshRenderer).mesh = meshB; + expect(meshA.refCount).eq(1); + expect(meshB.refCount).eq(1); + + clone.destroy(); + expect(meshB.refCount).eq(0); + expect(meshA.refCount).eq(1); + + entity.destroy(); + expect(meshA.refCount).eq(0); + }); + }); + + describe("Particle MeshShape.mesh", () => { + function createShapeMesh(): ModelMesh { + const mesh = new ModelMesh(engine); + mesh.setPositions([new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(0, 1, 0)]); + mesh.setNormals([new Vector3(0, 0, 1), new Vector3(0, 0, 1), new Vector3(0, 0, 1)]); + mesh.uploadData(false); + return mesh; + } + + it("setter churn transfers the count", () => { + const meshA = createShapeMesh(); + const meshB = createShapeMesh(); + const shape = new MeshShape(); + shape.mesh = meshA; + expect(meshA.refCount).eq(1); + + shape.mesh = meshB; + expect(meshA.refCount).eq(0); + expect(meshB.refCount).eq(1); + + shape.mesh = null; + expect(meshB.refCount).eq(0); + }); + + it("destroying the hosting renderer releases the shape's mesh", () => { + const mesh = createShapeMesh(); + const shape = new MeshShape(); + shape.mesh = mesh; + const entity = rootEntity.createChild("particleShapeSlot"); + entity.addComponent(ParticleRenderer).generator.emission.shape = shape; + expect(mesh.refCount).eq(1); + + entity.destroy(); + expect(mesh.refCount).eq(0); + }); + + it("clone acquires via the shape's _cloneTo, destroy releases", () => { + const mesh = createShapeMesh(); + const shape = new MeshShape(); + shape.mesh = mesh; + const entity = rootEntity.createChild("particleShapeClone"); + entity.addComponent(ParticleRenderer).generator.emission.shape = shape; + expect(mesh.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(mesh.refCount).eq(2); + + clone.destroy(); + expect(mesh.refCount).eq(1); + + entity.destroy(); + expect(mesh.refCount).eq(0); + }); + }); + + describe("Template-marked source", () => { + it("each clone counts the template resource; the suppressed template itself never does", () => { + const template = engine.createEntity("template"); + const templateResource = new Texture2D(engine, 1, 1); + (template as any)._markAsTemplate(templateResource); + expect(templateResource.refCount).eq(0); + + const instA = template.clone(); + rootEntity.addChild(instA); + expect(templateResource.refCount).eq(1); + + const instB = template.clone(); + expect(templateResource.refCount).eq(2); + + instA.destroy(); + instB.destroy(); + expect(templateResource.refCount).eq(0); + + template.destroy(); + expect(templateResource.refCount).eq(0); + }); + }); +}); diff --git a/tests/src/core/Scene.test.ts b/tests/src/core/Scene.test.ts index c932fbd9db..22c3dbbf55 100644 --- a/tests/src/core/Scene.test.ts +++ b/tests/src/core/Scene.test.ts @@ -1,6 +1,6 @@ import { BackgroundMode, Engine, Entity, Scene, TextureFormat, Texture2D } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; -import { describe, beforeAll, beforeEach, expect, it } from "vitest"; +import { describe, beforeAll, beforeEach, expect, it, vi } from "vitest"; describe("Scene", () => { let engine: Engine; @@ -202,4 +202,37 @@ describe("Scene", () => { expect(scene.rootEntitiesCount).eq(0); }); }); + + describe("loadScene", () => { + it("destroyOldScene destroys every previous scene during safe iteration", async () => { + // Dedicated engine so destroying old scenes does not disturb the shared one. + const localEngine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const sceneManager = localEngine.sceneManager; + + // Engine starts with one default scene; add two more so multiple scenes exist. + const old0 = sceneManager.scenes[0]; + const old1 = new Scene(localEngine, "old1"); + const old2 = new Scene(localEngine, "old2"); + sceneManager.addScene(old1); + sceneManager.addScene(old2); + expect(sceneManager.scenes.length).eq(3); + + // Resolve the load with a fresh scene without hitting real resources. + const newScene = new Scene(localEngine, "newScene"); + vi.spyOn(localEngine.resourceManager, "load").mockReturnValue(Promise.resolve(newScene) as any); + + await sceneManager.loadScene("mock://scene", true); + + // Destroying a scene removes it from `_scenes` mid-loop; iterating the live array + // (`getArray`) would skip scenes, so every previous scene must still be destroyed. + expect(old0.destroyed).eq(true); + expect(old1.destroyed).eq(true); + expect(old2.destroyed).eq(true); + // Only the newly loaded scene remains. + expect(sceneManager.scenes.length).eq(1); + expect(sceneManager.scenes[0]).eq(newScene); + + localEngine.destroy(); + }); + }); }); diff --git a/tests/src/core/ShaderDataRefCount.test.ts b/tests/src/core/ShaderDataRefCount.test.ts new file mode 100644 index 0000000000..1a7560e3d2 --- /dev/null +++ b/tests/src/core/ShaderDataRefCount.test.ts @@ -0,0 +1,183 @@ +import { BlinnPhongMaterial, Entity, MeshRenderer, Texture2D } from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * ShaderData is the cascade hub of ref counting: a texture set on a ShaderData owns + * `host.refCount` references, and every change of the host's count propagates to its textures + * (`ShaderData._addReferCount`), as does `Material._addReferCount` to shaderData + shader. + */ +describe("ShaderData / Material refCount cascade", () => { + let engine: WebGLEngine; + let rootEntity: Entity; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + rootEntity = engine.sceneManager.activeScene.createRootEntity(); + engine.run(); + }); + + it("setTexture swap / clear transfers counts by the host's refCount", () => { + const material = new BlinnPhongMaterial(engine); + const entity = rootEntity.createChild("swapHost"); + entity.addComponent(MeshRenderer).setMaterial(material); + expect(material.refCount).eq(1); + + const texA = new Texture2D(engine, 1, 1); + const texB = new Texture2D(engine, 1, 1); + material.shaderData.setTexture("u_custom", texA); + expect(texA.refCount).eq(1); + + material.shaderData.setTexture("u_custom", texB); + expect(texA.refCount).eq(0); + expect(texB.refCount).eq(1); + + material.shaderData.setTexture("u_custom", null); + expect(texB.refCount).eq(0); + + entity.destroy(); + expect(material.refCount).eq(0); + }); + + it("a texture set on a doubly-referenced material owns two counts; dropping one reference cascades", () => { + const material = new BlinnPhongMaterial(engine); + const other = new BlinnPhongMaterial(engine); + const entityA = rootEntity.createChild("hostA"); + const entityB = rootEntity.createChild("hostB"); + entityA.addComponent(MeshRenderer).setMaterial(material); + entityB.addComponent(MeshRenderer).setMaterial(material); + expect(material.refCount).eq(2); + + const tex = new Texture2D(engine, 1, 1); + material.shaderData.setTexture("u_custom", tex); + expect(tex.refCount).eq(2); + + // Dropping one material reference cascades -1 through shaderData to its textures. + entityB.getComponent(MeshRenderer).setMaterial(other); + expect(material.refCount).eq(1); + expect(tex.refCount).eq(1); + + entityA.destroy(); + expect(material.refCount).eq(0); + expect(tex.refCount).eq(0); + entityB.destroy(); + expect(other.refCount).eq(0); + }); + + it("cloning a renderer keeps the shared material and its textures balanced", () => { + const material = new BlinnPhongMaterial(engine); + const tex = new Texture2D(engine, 1, 1); + const entity = rootEntity.createChild("cloneHost"); + entity.addComponent(MeshRenderer).setMaterial(material); + material.shaderData.setTexture("u_custom", tex); + expect(material.refCount).eq(1); + expect(tex.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The clone shares the material (+1), which cascades +1 to its textures. + expect(material.refCount).eq(2); + expect(tex.refCount).eq(2); + + clone.destroy(); + expect(material.refCount).eq(1); + expect(tex.refCount).eq(1); + + entity.destroy(); + expect(material.refCount).eq(0); + expect(tex.refCount).eq(0); + }); + + it("instance materials release on destroy and stay balanced across clone", () => { + const material = new BlinnPhongMaterial(engine); + const entity = rootEntity.createChild("instanceHost"); + const renderer = entity.addComponent(MeshRenderer); + renderer.setMaterial(material); + + const instanced = renderer.getInstanceMaterial(); + expect(instanced).not.eq(material); + // Instancing replaces the slot: the original material is released, the instance is owned. + expect(material.refCount).eq(0); + expect(instanced.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + expect(instanced.refCount).eq(2); + + clone.destroy(); + expect(instanced.refCount).eq(1); + + entity.destroy(); + expect(instanced.refCount).eq(0); + }); + + it("texture-array entries cascade with the host's refCount like single textures", () => { + const material = new BlinnPhongMaterial(engine); + const entityA = rootEntity.createChild("texArrHostA"); + entityA.addComponent(MeshRenderer).setMaterial(material); + expect(material.refCount).eq(1); + + const texA = new Texture2D(engine, 1, 1); + const texB = new Texture2D(engine, 1, 1); + material.shaderData.setTextureArray("u_texArr", [texA, texB]); + expect(texA.refCount).eq(1); + expect(texB.refCount).eq(1); + + // A second owner of the material cascades +1 through the array entries. + const entityB = rootEntity.createChild("texArrHostB"); + entityB.addComponent(MeshRenderer).setMaterial(material); + expect(texA.refCount).eq(2); + expect(texB.refCount).eq(2); + + entityB.destroy(); + expect(texA.refCount).eq(1); + + entityA.destroy(); + expect(texA.refCount).eq(0); + expect(texB.refCount).eq(0); + }); + + it("cloning a shaderData counts the shared entries of a texture array", () => { + const texA = new Texture2D(engine, 1, 1); + const texB = new Texture2D(engine, 1, 1); + const entity = rootEntity.createChild("texArrCloneSrc"); + entity.addComponent(MeshRenderer).shaderData.setTextureArray("u_rendererTexArr", [texA, texB]); + expect(texA.refCount).eq(1); + + const clone = entity.clone(); + rootEntity.addChild(clone); + // The cloned array is a fresh container sharing the same counted entries. + expect(texA.refCount).eq(2); + expect(texB.refCount).eq(2); + + clone.destroy(); + expect(texA.refCount).eq(1); + + entity.destroy(); + expect(texA.refCount).eq(0); + expect(texB.refCount).eq(0); + }); + + it("cloneTo into a referenced, populated target keeps the displaced entry's count", () => { + const matA = new BlinnPhongMaterial(engine); + const matB = new BlinnPhongMaterial(engine); + const host = rootEntity.createChild("cloneToPopulated"); + host.addComponent(MeshRenderer).setMaterial(matB); + const texA = new Texture2D(engine, 1, 1); + const texB = new Texture2D(engine, 1, 1); + matA.shaderData.setTexture("u_custom", texA); + matB.shaderData.setTexture("u_custom", texB); + expect(texB.refCount).eq(1); + + matA.shaderData.cloneTo(matB.shaderData); + + // Pins current semantics: the incoming entry gains the target's count, while the displaced + // entry keeps the count it held — cloneTo does not release what it overwrites. + expect(texA.refCount).eq(1); + expect(texB.refCount).eq(1); + + host.destroy(); + expect(texA.refCount).eq(0); + expect(texB.refCount).eq(1); + }); +}); diff --git a/tests/src/core/Signal.test.ts b/tests/src/core/Signal.test.ts index 82a4fef4f4..c3c255ab08 100644 --- a/tests/src/core/Signal.test.ts +++ b/tests/src/core/Signal.test.ts @@ -1,4 +1,4 @@ -import { Script, Signal } from "@galacean/engine-core"; +import { Entity, Script, Signal } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it, vi } from "vitest"; @@ -332,6 +332,17 @@ describe("Signal", async () => { // ---- Clone ---- + /** Build the identity map (source entity/component -> clone) the engine passes to `_cloneTo`. */ + function buildCloneMap(src: Entity, target: Entity, map = new Map()): Map { + map.set(src, target); + // @ts-ignore + const srcComponents = src._components, + targetComponents = target._components; + for (let i = 0; i < srcComponents.length; i++) map.set(srcComponents[i], targetComponents[i]); + for (let i = 0; i < src.children.length; i++) buildCloneMap(src.children[i], target.children[i], map); + return map; + } + it("clone: closure-based listeners are not cloned", () => { const signal = new Signal<[number]>(); const targetSignal = new Signal<[number]>(); @@ -341,7 +352,7 @@ describe("Signal", async () => { const srcRoot = root.createChild("clSrc1"); const targetRoot = srcRoot.clone(); // @ts-ignore - signal._cloneTo(targetSignal, srcRoot, targetRoot); + signal._cloneTo(targetSignal, buildCloneMap(srcRoot, targetRoot)); // Closure listeners should NOT be copied to clone targetSignal.invoke(42); @@ -362,7 +373,7 @@ describe("Signal", async () => { const targetRoot = srcRoot.clone(); // @ts-ignore - signal._cloneTo(targetSignal, srcRoot, targetRoot); + signal._cloneTo(targetSignal, buildCloneMap(srcRoot, targetRoot)); const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler); targetSignal.invoke(); @@ -385,7 +396,7 @@ describe("Signal", async () => { const targetRoot = srcRoot.clone(); // @ts-ignore - signal._cloneTo(targetSignal, srcRoot, targetRoot); + signal._cloneTo(targetSignal, buildCloneMap(srcRoot, targetRoot)); targetSignal.invoke(); expect(externalHandler.callCount).toBe(1); @@ -408,7 +419,7 @@ describe("Signal", async () => { const targetRoot = srcRoot.clone(); // @ts-ignore - signal._cloneTo(targetSignal, srcRoot, targetRoot); + signal._cloneTo(targetSignal, buildCloneMap(srcRoot, targetRoot)); const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler); targetSignal.invoke(); @@ -431,7 +442,7 @@ describe("Signal", async () => { const targetRoot = srcRoot.clone(); // @ts-ignore - signal._cloneTo(targetSignal, srcRoot, targetRoot); + signal._cloneTo(targetSignal, buildCloneMap(srcRoot, targetRoot)); const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler); targetSignal.invoke(); diff --git a/tests/src/core/SkinnedMeshRenderer.test.ts b/tests/src/core/SkinnedMeshRenderer.test.ts index 6c29b24331..658e6abed7 100644 --- a/tests/src/core/SkinnedMeshRenderer.test.ts +++ b/tests/src/core/SkinnedMeshRenderer.test.ts @@ -128,4 +128,57 @@ describe("SkinnedMeshRenderer", async () => { skinnedMeshRenderer.blendShapeWeights ); }); + + it("clone skin", () => { + const entity = rootEntity.createChild("SkinCloneRoot"); + const bone0 = entity.createChild("Bone0"); + const bone1 = entity.createChild("Bone1"); + + const modelMesh = new ModelMesh(engine); + modelMesh.setPositions([new Vector3(0, 0, 0), new Vector3(0, 1, 0), new Vector3(1, 1, 0)]); + + const skinnedMeshRenderer = entity.addComponent(SkinnedMeshRenderer); + skinnedMeshRenderer.mesh = modelMesh; + + const skin = new Skin("CloneSkin"); + skin.rootBone = bone0; + skin.bones = [bone0, bone1]; + skin.inverseBindMatrices = [ + new Matrix(), + new Matrix(2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 1, 2, 3, 1) + ]; + skinnedMeshRenderer.skin = skin; + + const cloneEntity = entity.clone(); + const cloneSkin = cloneEntity.getComponent(SkinnedMeshRenderer).skin; + + // The skin itself is deep cloned. + expect(cloneSkin).to.be.not.equal(skin); + + // Entity references are remapped into the cloned subtree, not shared with the source. + const cloneBone0 = cloneEntity.findByName("Bone0"); + const cloneBone1 = cloneEntity.findByName("Bone1"); + expect(cloneSkin.rootBone).to.be.equal(cloneBone0); + expect(cloneSkin.rootBone).to.be.not.equal(bone0); + expect(cloneSkin.bones.length).to.be.equal(2); + expect(cloneSkin.bones[0]).to.be.equal(cloneBone0); + expect(cloneSkin.bones[1]).to.be.equal(cloneBone1); + expect(cloneSkin.bones[0]).to.be.not.equal(bone0); + expect(cloneSkin.bones[1]).to.be.not.equal(bone1); + + // Inverse bind matrices are independent deep copies with equal values. + expect(cloneSkin.inverseBindMatrices).to.be.not.equal(skin.inverseBindMatrices); + expect(cloneSkin.inverseBindMatrices.length).to.be.equal(2); + expect(cloneSkin.inverseBindMatrices[0]).to.be.not.equal(skin.inverseBindMatrices[0]); + expect(cloneSkin.inverseBindMatrices[1]).to.be.not.equal(skin.inverseBindMatrices[1]); + expect(cloneSkin.inverseBindMatrices[0].elements).to.be.deep.equal(skin.inverseBindMatrices[0].elements); + expect(cloneSkin.inverseBindMatrices[1].elements).to.be.deep.equal(skin.inverseBindMatrices[1].elements); + + // The clone keeps its own update flag manager. + // @ts-ignore + expect(cloneSkin._updatedManager).to.be.not.equal(skin._updatedManager); + + entity.destroy(); + cloneEntity.destroy(); + }); }); diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index db08e68b08..370582ba77 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -2,6 +2,8 @@ import { RendererUpdateFlags, Sprite, SpriteDrawMode, + SpriteFilledMode, + SpriteFilledOrigin, SpriteMaskInteraction, SpriteMaskLayer, SpriteRenderer, @@ -1578,6 +1580,436 @@ describe("SpriteRenderer", async () => { expect(spriteRenderer.bounds.min).to.deep.eq(new Vector3(-0.5, -1, 0)); expect(spriteRenderer.bounds.max).to.deep.eq(new Vector3(0.5, 1, 0)); }); + + it("get set filled properties", () => { + const rootEntity = scene.getRootEntity(); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.drawMode = SpriteDrawMode.Filled; + + // filledAmount is clamped to [0, 1] + spriteRenderer.filledAmount = 2; + expect(spriteRenderer.filledAmount).to.eq(1); + spriteRenderer.filledAmount = -1; + expect(spriteRenderer.filledAmount).to.eq(0); + spriteRenderer.filledAmount = 0.5; + expect(spriteRenderer.filledAmount).to.eq(0.5); + + spriteRenderer.filledClockWise = false; + expect(spriteRenderer.filledClockWise).to.eq(false); + + // Changing filledMode resets filledOrigin to a valid default for the new mode + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + expect(spriteRenderer.filledMode).to.eq(SpriteFilledMode.Horizontal); + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Left); + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.BottomLeft); + spriteRenderer.filledMode = SpriteFilledMode.Radial180; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + spriteRenderer.filledMode = SpriteFilledMode.Radial360; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + + // A valid origin for the current mode (Radial360 accepts edges) is kept + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Top); + // An origin invalid for the current mode snaps to the mode's default + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.Bottom); + + // Corner-based modes validate origin the same way + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; // edge origin invalid for Radial90 → snap default + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.BottomLeft); + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; // valid corner → kept + expect(spriteRenderer.filledOrigin).to.eq(SpriteFilledOrigin.TopRight); + }); + + it("draw Filled Sprite (linear)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal, default origin Left → fill the left half + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(2, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(0.5, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(0.5, 0))).to.eq(true); + expect([...subChunk.indices]).to.eql([0, 1, 2, 2, 1, 3]); + + // Vertical, default origin Bottom → fill the bottom half + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 2.5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0.5))).to.eq(true); + }); + + it("draw Filled Sprite (radial amount bounds)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + + // Radial360 full fill → 4 quadrants (4 quads × 6 indices) + spriteRenderer.filledMode = SpriteFilledMode.Radial360; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(24); + + // amount 0 → nothing is drawn + spriteRenderer.filledAmount = 0; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(0); + + // Radial90 full fill → single quad + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + }); + + it("draw Filled Sprite falls back to the default origin for an unsupported origin", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + // The radial cut depends only on amount/clockwise, so all origins emit one triangle here. + const snapshotGeom = () => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < 3; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + // Radial90 with its default corner origin (BottomLeft) + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + const bottomLeftGeom = snapshotGeom(); + + // A different valid corner origin produces different geometry + spriteRenderer.filledOrigin = SpriteFilledOrigin.TopRight; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshotGeom()).to.not.eql(bottomLeftGeom); + + // An unsupported edge origin must fall back to the default (BottomLeft), + // not silently reuse the previous (TopRight) geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshotGeom()).to.eql(bottomLeftGeom); + }); + + it("filled mode refreshes UV when the atlas region changes", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 1; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + // U of the right (RB) vertex, which equals the sprite's right UV for a full horizontal fill. + const rightU = () => vertices[subChunk.vertexArea.start + 9 + 3]; + + // @ts-ignore + spriteRenderer._render(context); + const uvBefore = rightU(); + + // Changing the atlas region dispatches SpriteModifyFlags.atlasRegion. Filled mode bakes + // UVs inside updatePositions, so the renderer must re-run it instead of a no-op updateUVs. + sprite.atlasRegion = new Rect(0, 0, 0.5, 0.5); + // @ts-ignore + spriteRenderer._render(context); + expect(rightU()).to.not.eq(uvBefore); + }); + + it("draw Filled Sprite (linear, non-default origin)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal with origin Right → fill the right half (originIsStart === false branch) + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Right; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readVertex(0).pos, new Vector3(2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(2, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0.5, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 1))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0.5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0))).to.eq(true); + + // Vertical with origin Top → fill the top half + spriteRenderer.filledMode = SpriteFilledMode.Vertical; + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + spriteRenderer.filledAmount = 0.5; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(4, 2.5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(4, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(1, 0.5))).to.eq(true); + expect(Vector2.equals(readVertex(2).uv, new Vector2(0, 0))).to.eq(true); + expect(Vector2.equals(readVertex(3).uv, new Vector2(1, 0))).to.eq(true); + }); + + it("draw Filled Sprite (Radial180 + unsupported origin fallback)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readPos = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]); + }; + const snapshot = (n: number) => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < n; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + // Radial180, default origin Bottom, full fill → two quads (12 indices) + spriteRenderer.filledMode = SpriteFilledMode.Radial180; + spriteRenderer.filledAmount = 1; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(12); + // The first emitted vertex is the fill center = bottom-edge midpoint. + expect(Vector3.equals(readPos(0), new Vector3(2, 0, 0))).to.eq(true); + const bottomGeom = snapshot(8); + + // A valid non-default origin (Top, center = top-edge midpoint) produces different geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.Top; + // @ts-ignore + spriteRenderer._render(context); + expect(Vector3.equals(readPos(0), new Vector3(2, 5, 0))).to.eq(true); + expect(snapshot(8)).to.not.eql(bottomGeom); + + // An unsupported corner origin (BottomLeft) must fall back to the default Bottom, + // not silently reuse the previous Top geometry. + spriteRenderer.filledOrigin = SpriteFilledOrigin.BottomLeft; + // @ts-ignore + spriteRenderer._render(context); + expect(snapshot(8)).to.eql(bottomGeom); + }); + + it("draw Filled Sprite (counter-clockwise radial differs from clockwise)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const snapshot3 = () => { + const start = subChunk.vertexArea.start; + const out: number[] = []; + for (let i = 0; i < 3; ++i) { + const o = start + i * 9; + out.push(vertices[o], vertices[o + 1], vertices[o + 2], vertices[o + 3], vertices[o + 4]); + } + return out; + }; + + spriteRenderer.filledMode = SpriteFilledMode.Radial90; + spriteRenderer.filledAmount = 0.5; + + // Clockwise (default) cuts the [45, 90] sector → one triangle. + spriteRenderer.filledClockWise = true; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(3); + const cwGeom = snapshot3(); + + // Counter-clockwise cuts the [0, 45] sector → a different triangle. + spriteRenderer.filledClockWise = false; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(3); + expect(snapshot3()).to.not.eql(cwGeom); + }); + + it("draw Filled Sprite (flipX mirrors positions, keeps UVs)", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const vertices = subChunk.chunk.vertices; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + const readVertex = (i: number) => { + const o = subChunk.vertexArea.start + i * 9; + return { + pos: new Vector3(vertices[o], vertices[o + 1], vertices[o + 2]), + uv: new Vector2(vertices[o + 3], vertices[o + 4]) + }; + }; + + // Horizontal (origin Left) half fill with flipX → positions mirror to negative x, UVs unchanged. + spriteRenderer.filledMode = SpriteFilledMode.Horizontal; + spriteRenderer.filledAmount = 0.5; + spriteRenderer.flipX = true; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length).to.eq(6); + expect(Vector3.equals(readVertex(0).pos, new Vector3(0, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(1).pos, new Vector3(-2, 0, 0))).to.eq(true); + expect(Vector3.equals(readVertex(2).pos, new Vector3(0, 5, 0))).to.eq(true); + expect(Vector3.equals(readVertex(3).pos, new Vector3(-2, 5, 0))).to.eq(true); + expect(Vector2.equals(readVertex(0).uv, new Vector2(0, 1))).to.eq(true); + expect(Vector2.equals(readVertex(1).uv, new Vector2(0.5, 1))).to.eq(true); + }); + + it("filled indices count across modes and amounts", () => { + const rootEntity = scene.getRootEntity(); + const texture2D = new Texture2D(engine, 200, 300, TextureFormat.R8G8B8A8, false); + const sprite = new Sprite(engine, texture2D); + const spriteRenderer = rootEntity.addComponent(SpriteRenderer); + spriteRenderer.sprite = sprite; + spriteRenderer.drawMode = SpriteDrawMode.Filled; + spriteRenderer.width = 4; + spriteRenderer.height = 5; + sprite.pivot = new Vector2(0, 0); + // @ts-ignore + const subChunk = spriteRenderer._subChunk; + const context = { camera: { engine: engine, _renderPipeline: { pushRenderElement: () => {} } } }; + + // Each mode runs with its default origin and clockwise=true; indices count locks the + // triangle/quad topology produced at amount 0 / 0.25 / 0.5 / 0.75 / 1. + const amounts = [0, 0.25, 0.5, 0.75, 1]; + const cases: Array<[SpriteFilledMode, number[]]> = [ + [SpriteFilledMode.Horizontal, [0, 6, 6, 6, 6]], + [SpriteFilledMode.Vertical, [0, 6, 6, 6, 6]], + [SpriteFilledMode.Radial90, [0, 3, 3, 6, 6]], + [SpriteFilledMode.Radial180, [0, 3, 6, 9, 12]], + [SpriteFilledMode.Radial360, [0, 6, 12, 18, 24]] + ]; + for (const [mode, expected] of cases) { + spriteRenderer.filledMode = mode; + for (let i = 0; i < amounts.length; ++i) { + spriteRenderer.filledAmount = amounts[i]; + // @ts-ignore + spriteRenderer._render(context); + expect(subChunk.indices.length, `mode=${mode} amount=${amounts[i]}`).to.eq(expected[i]); + } + } + }); }); /** diff --git a/tests/src/core/Trail.test.ts b/tests/src/core/Trail.test.ts index 7d1e8be7e8..6dd55f0f3a 100644 --- a/tests/src/core/Trail.test.ts +++ b/tests/src/core/Trail.test.ts @@ -165,6 +165,62 @@ describe("Trail", async () => { expect(trailRenderer.destroyed).to.eq(true); }); + it("clone", () => { + const rootEntity = scene.getRootEntity(); + const trailEntity = rootEntity.createChild("trailCloneSrc"); + const trailRenderer = trailEntity.addComponent(TrailRenderer); + + trailRenderer.widthCurve = new ParticleCurve(new CurveKey(0, 0.5), new CurveKey(0.6, 2), new CurveKey(1, 0)); + trailRenderer.colorGradient = new ParticleGradient( + [new GradientColorKey(0, new Color(1, 0, 0, 1)), new GradientColorKey(1, new Color(0, 0, 1, 1))], + [new GradientAlphaKey(0, 0.8), new GradientAlphaKey(1, 0.2)] + ); + trailRenderer.textureScale = new Vector2(2.0, 0.5); + + const cloneEntity = trailEntity.clone(); + const cloneTrail = cloneEntity.getComponent(TrailRenderer); + + // widthCurve is an independent deep copy with equal keys. + expect(cloneTrail.widthCurve).not.to.eq(trailRenderer.widthCurve); + expect(cloneTrail.widthCurve.keys.length).to.eq(3); + for (let i = 0; i < 3; i++) { + expect(cloneTrail.widthCurve.keys[i]).not.to.eq(trailRenderer.widthCurve.keys[i]); + expect(cloneTrail.widthCurve.keys[i].time).to.eq(trailRenderer.widthCurve.keys[i].time); + expect(cloneTrail.widthCurve.keys[i].value).to.eq(trailRenderer.widthCurve.keys[i].value); + } + cloneTrail.widthCurve.keys[0].value = 9; + expect(trailRenderer.widthCurve.keys[0].value).to.eq(0.5); + + // colorGradient is an independent deep copy with equal color/alpha keys. + expect(cloneTrail.colorGradient).not.to.eq(trailRenderer.colorGradient); + expect(cloneTrail.colorGradient.colorKeys.length).to.eq(2); + expect(cloneTrail.colorGradient.alphaKeys.length).to.eq(2); + for (let i = 0; i < 2; i++) { + expect(cloneTrail.colorGradient.colorKeys[i]).not.to.eq(trailRenderer.colorGradient.colorKeys[i]); + expect(cloneTrail.colorGradient.colorKeys[i].color).not.to.eq(trailRenderer.colorGradient.colorKeys[i].color); + expect( + Color.equals(cloneTrail.colorGradient.colorKeys[i].color, trailRenderer.colorGradient.colorKeys[i].color) + ).to.eq(true); + expect(cloneTrail.colorGradient.colorKeys[i].time).to.eq(trailRenderer.colorGradient.colorKeys[i].time); + expect(cloneTrail.colorGradient.alphaKeys[i]).not.to.eq(trailRenderer.colorGradient.alphaKeys[i]); + expect(cloneTrail.colorGradient.alphaKeys[i].alpha).to.eq(trailRenderer.colorGradient.alphaKeys[i].alpha); + expect(cloneTrail.colorGradient.alphaKeys[i].time).to.eq(trailRenderer.colorGradient.alphaKeys[i].time); + } + cloneTrail.colorGradient.alphaKeys[0].alpha = 0.1; + expect(trailRenderer.colorGradient.alphaKeys[0].alpha).to.eq(0.8); + + // textureScale is an independent copy with equal values. + expect(cloneTrail.textureScale).not.to.eq(trailRenderer.textureScale); + expect(cloneTrail.textureScale.x).to.eq(2.0); + expect(cloneTrail.textureScale.y).to.eq(0.5); + cloneTrail.textureScale.set(3.0, 3.0); + expect(trailRenderer.textureScale.x).to.eq(2.0); + expect(trailRenderer.textureScale.y).to.eq(0.5); + + trailEntity.destroy(); + cloneEntity.destroy(); + }); + it("bounds", () => { const rootEntity = scene.getRootEntity(); const trailEntity = rootEntity.createChild("trail"); diff --git a/tests/src/core/Transform.test.ts b/tests/src/core/Transform.test.ts index 980edd3572..f96f29afda 100644 --- a/tests/src/core/Transform.test.ts +++ b/tests/src/core/Transform.test.ts @@ -1,4 +1,13 @@ -import { deepClone, Entity, Scene, Script, Transform } from "@galacean/engine-core"; +import { + deepClone, + dependentComponents, + DependentMode, + Entity, + MeshRenderer, + Scene, + Script, + Transform +} from "@galacean/engine-core"; import { Vector2, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; @@ -112,16 +121,115 @@ describe("Transform test", function () { // Add component const preTransform0 = entity0.transform; + const meshRenderer = entity0.addComponent(MeshRenderer); + const transformIndex = entity0._components.indexOf(preTransform0); entity0.addComponent(SubClassOfTransform); expect(preTransform0.destroyed).to.equal(true); expect(entity0.transform instanceof Transform).to.equal(true); expect(entity0.transform instanceof SubClassOfTransform).to.equal(true); + expect(entity0._components[transformIndex]).to.equal(entity0.transform); + expect(entity0._components.indexOf(meshRenderer)).to.equal(1); + expect(entity0.transform.position).to.deep.include({ x: 1, y: 2, z: 3 }); + expect(entity0.transform.rotation.x).to.be.approximately(0, 1e-6); + expect(entity0.transform.rotation.y).to.be.approximately(45, 1e-6); + expect(entity0.transform.rotation.z).to.be.approximately(0, 1e-6); + expect(entity0.transform.scale).to.deep.include({ x: 1, y: 2, z: 3 }); const preTransform1 = entity1.transform; + const meshRenderer1 = entity1.addComponent(MeshRenderer); + const transformIndex1 = entity1._components.indexOf(preTransform1); entity1.addComponent(Transform); expect(preTransform1.destroyed).to.equal(true); expect(entity1.transform instanceof Transform).to.equal(true); expect(entity1.transform instanceof SubClassOfTransform).to.equal(false); + expect(entity1._components[transformIndex1]).to.equal(entity1.transform); + expect(entity1._components.indexOf(meshRenderer1)).to.equal(1); + expect(entity1.transform.position).to.deep.include({ x: 4, y: 5, z: 6 }); + expect(entity1.transform.rotation.x).to.be.approximately(0, 1e-6); + expect(entity1.transform.rotation.y).to.be.approximately(90, 1e-6); + expect(entity1.transform.rotation.z).to.be.approximately(0, 1e-6); + expect(entity1.transform.scale).to.deep.include({ x: 4, y: 5, z: 6 }); + }); + + it("creates the unique Transform before constructor components", () => { + const entityWithRenderer = new Entity(engine, "entity-with-renderer", MeshRenderer); + expect(entityWithRenderer._components[0]).to.equal(entityWithRenderer.transform); + expect(entityWithRenderer.getComponent(MeshRenderer)).not.to.equal(null); + + const entityWithLateTransform = new Entity( + engine, + "entity-with-late-transform", + MeshRenderer, + Transform, + SubClassOfTransform + ); + const transforms: Transform[] = []; + entityWithLateTransform.getComponents(Transform, transforms); + expect(transforms).to.deep.equal([entityWithLateTransform.transform]); + expect(entityWithLateTransform.transform).to.be.instanceOf(SubClassOfTransform); + expect(entityWithLateTransform._components[0]).to.equal(entityWithLateTransform.transform); + expect(entityWithLateTransform._components[1]).to.be.instanceOf(MeshRenderer); + + const clone = entityWithLateTransform.clone(); + expect(clone.transform).to.be.instanceOf(SubClassOfTransform); + expect(clone._components.map((component) => component.constructor)).to.deep.equal( + entityWithLateTransform._components.map((component) => component.constructor) + ); + }); + + it("keeps the Transform slot unique while destruction is deferred", () => { + const deferredEntity = new Entity(engine, "deferred-transform"); + const previous = deferredEntity.transform; + let replacement: SubClassOfTransform; + + engine._frameInProcess = true; + try { + replacement = deferredEntity.addComponent(SubClassOfTransform); + const transforms: Transform[] = []; + deferredEntity.getComponents(Transform, transforms); + expect(previous.pendingDestroy).to.equal(true); + expect(transforms).to.deep.equal([replacement]); + expect(deferredEntity._components[0]).to.equal(replacement); + } finally { + engine._frameInProcess = false; + previous.destroy(); + } + }); + + it("rolls back Transform replacement when a dependency prevents it", () => { + const dependentEntity = new Entity(engine, "dependent-transform", SubClassOfTransform); + const previous = dependentEntity.transform; + dependentEntity.addComponent(RequiresSubClassOfTransform); + + expect(() => dependentEntity.addComponent(Transform)).to.throw( + "Should remove RequiresSubClassOfTransform before remove SubClassOfTransform" + ); + const transforms: Transform[] = []; + dependentEntity.getComponents(Transform, transforms); + expect(dependentEntity.transform).to.equal(previous); + expect(transforms).to.deep.equal([previous]); + expect(dependentEntity._components[0]).to.equal(previous); + }); + + it("checks dependencies declared by a replacement Transform", () => { + const dependentEntity = new Entity(engine, "check-only-dependent-transform"); + const previous = dependentEntity.transform; + + expect(() => dependentEntity.addComponent(CheckOnlyDependentTransform)).to.throw( + "Should add MeshRenderer before adding CheckOnlyDependentTransform" + ); + expect(dependentEntity.transform).to.equal(previous); + expect(dependentEntity._components).to.deep.equal([previous]); + }); + + it("auto adds dependencies declared by a replacement Transform", () => { + const dependentEntity = new Entity(engine, "auto-add-dependent-transform"); + const replacement = dependentEntity.addComponent(AutoAddDependentTransform); + + expect(dependentEntity.transform).to.equal(replacement); + expect(dependentEntity._components[0]).to.equal(replacement); + expect(dependentEntity._components[1]).to.be.instanceOf(MeshRenderer); + expect(dependentEntity.getComponent(MeshRenderer)).not.to.equal(null); }); it("clone with worldMatrix listener should not produce stale parent cache after reparent", () => { @@ -191,3 +299,12 @@ class SubClassOfTransform extends Transform { @deepClone size: Vector2 = new Vector2(); } + +@dependentComponents(SubClassOfTransform, DependentMode.CheckOnly) +class RequiresSubClassOfTransform extends Script {} + +@dependentComponents(MeshRenderer, DependentMode.CheckOnly) +class CheckOnlyDependentTransform extends Transform {} + +@dependentComponents(MeshRenderer, DependentMode.AutoAdd) +class AutoAddDependentTransform extends Transform {} diff --git a/tests/src/core/input/InputManager.test.ts b/tests/src/core/input/InputManager.test.ts index c552d39465..a7b21634f1 100644 --- a/tests/src/core/input/InputManager.test.ts +++ b/tests/src/core/input/InputManager.test.ts @@ -173,6 +173,41 @@ describe("InputManager", async () => { engine.update(); }); + it("pointer pressedPosition", () => { + // @ts-ignore + const { _pointerManager: pointerManager } = inputManager; + const { _target: target } = pointerManager; + const { left, top } = target.getBoundingClientRect(); + + // Frame 1: a pointerdown and a move to a different position are batched into one frame. + target.dispatchEvent(generatePointerEvent("pointerdown", 5, left + 1, top + 1)); + target.dispatchEvent(generatePointerEvent("pointermove", 5, left + 3, top + 3)); + engine.update(); + const pointer = inputManager.pointers[0]; + // @ts-ignore + const uniqueID = pointer._uniqueID; + const position1 = pointer.position.clone(); + const pressedPosition1 = pointer.pressedPosition.clone(); + + // Frame 2: keep dragging to yet another position. + target.dispatchEvent(generatePointerEvent("pointermove", 5, left + 6, top + 6)); + engine.update(); + const position2 = pointer.position.clone(); + const pressedPosition2 = pointer.pressedPosition.clone(); + + // Release the pointer BEFORE asserting, so a failing expectation cannot leak it into later tests. + target.dispatchEvent(generatePointerEvent("pointerleave", 5, left + 6, top + 6, -1, 0)); + engine.update(); + + expect(uniqueID).to.eq(5); + // `position` always tracks the frame's latest event... + expect(position1).to.deep.eq(new Vector2(3 * 2, 3 * 2)); + expect(position2).to.deep.eq(new Vector2(6 * 2, 6 * 2)); + // ...while `pressedPosition` stays at the pointerdown location regardless of later moves. + expect(pressedPosition1).to.deep.eq(new Vector2(1 * 2, 1 * 2)); + expect(pressedPosition2).to.deep.eq(new Vector2(1 * 2, 1 * 2)); + }); + it("keyboard", () => { // @ts-ignore const { _keyboardManager: keyboardManager } = inputManager; @@ -247,6 +282,31 @@ describe("InputManager", async () => { expect(inputManager.wheelDelta).to.deep.eq(new Vector3(1, 2, 3)); }); + it("gc reclaims the pointer event data pool", () => { + // @ts-ignore + const { _pointerManager: pointerManager } = inputManager; + const { _target: target } = pointerManager; + // @ts-ignore + const elements = pointerManager._eventPool._elements; + const { left, top } = target.getBoundingClientRect(); + + // Pressing on the box allocates event data into the pool. + target.dispatchEvent(generatePointerEvent("pointerdown", 6, left + 2, top + 2)); + engine.update(); + expect(elements.length).to.be.greaterThan(0); + + // Engine gc reclaims the pool, releasing the entity references it held. + // @ts-ignore + engine._pendingGC(); + expect(elements.length).to.eq(0); + + // The pool keeps working after gc. + target.dispatchEvent(generatePointerEvent("pointerup", 6, left + 2, top + 2, 0, 0)); + target.dispatchEvent(generatePointerEvent("pointerleave", 6, left + 2, top + 2, -1, 0)); + engine.update(); + expect(elements.length).to.be.greaterThan(0); + }); + it("destroy", () => { engine.destroy(); // @ts-ignore diff --git a/tests/src/core/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts index 540f3b0461..2cd348297f 100644 --- a/tests/src/core/particle/CustomData.test.ts +++ b/tests/src/core/particle/CustomData.test.ts @@ -306,11 +306,9 @@ describe("CustomDataModule", function () { }); it("clones deep — entries detached, internal caches rebuilt", function () { - // Bug guard: CloneManager can't recurse into Map entries, so the default - // field-by-field clone would leave `cloned.curves === source.curves` - // (mutation aliasing) and an empty `_curveStreams` (silent no-op - // _updateShaderData). The module's `_cloneTo` hook deep-clones each - // entry and rebuilds the internal caches via addCurve / addGradient. + // The maps and the stream caches are all cloned by the gate: fresh Maps holding deep-cloned + // entries, and stream objects whose `curve` / `gradient` resolve through the identity map to + // those same clones (asserted below), so `_updateShaderData` uploads what the maps hold. const scene = engine.sceneManager.activeScene; const sourceEntity = scene.createRootEntity("source-particle"); const sourceRenderer = sourceEntity.addComponent(ParticleRenderer); @@ -334,13 +332,44 @@ describe("CustomDataModule", function () { expect(clonedCustomData.gradients.get("Tint")).to.not.eq(sourceCustomData.gradients.get("Tint")); expect(clonedCustomData.gradients.get("Tint")!.constantMax.r).to.be.closeTo(1, 1e-6); - // Internal caches are rebuilt — _updateShaderData would now upload uniforms. - //@ts-ignore - inspecting private internal cache - const clonedCurveStreams = (clonedCustomData as any)._curveStreams as { name: string }[]; - //@ts-ignore - const clonedGradientStreams = (clonedCustomData as any)._gradientStreams as { name: string }[]; - expect(clonedCurveStreams.map((s) => s.name)).to.deep.eq(["Intensity"]); - expect(clonedGradientStreams.map((s) => s.name)).to.deep.eq(["Tint"]); + // Internal caches are rebuilt — _updateShaderData would now upload uniforms. Every stream + // field is checked against the source's, each with the comparison its role demands. + const expectStreamsEquivalent = (key: string, entryKey: string, mapKey: string, names: string[]) => { + const sourceStreams = (sourceCustomData as any)[key]; + const clonedStreams = (clonedCustomData as any)[key]; + expect(clonedStreams.map((s: any) => s.name)).to.deep.eq(names); + expect(clonedStreams.length).to.eq(sourceStreams.length); + + for (let i = 0; i < sourceStreams.length; i++) { + const sourceStream = sourceStreams[i]; + const clonedStream = clonedStreams[i]; + expect(clonedStream.name).to.eq(sourceStream.name); + expect(clonedStream.lastMode).to.eq(sourceStream.lastMode); + + // A ShaderProperty is registered globally by name, so the clone must hold the very same + // instance. Identity, not deep equality — a structurally identical copy would pass the + // latter while breaking uniform lookup. + const propKeys = Object.keys(sourceStream).filter((k) => k.startsWith("prop")); + expect(propKeys.length).to.be.greaterThan(0); + for (const propKey of propKeys) { + expect(clonedStream[propKey]).to.eq(sourceStream[propKey]); + } + + // The keyframe-count cache is per-instance scratch: same values, own vector. + if (sourceStream.keysCountCache) { + expect(Array.from(clonedStream.keysCountCache)).to.deep.eq(Array.from(sourceStream.keysCountCache)); + expect(clonedStream.keysCountCache).to.not.eq(sourceStream.keysCountCache); + } + + // The stream points at the very object its own map holds (the identity map collapses both + // references onto one clone), and that clone is distinct from the source's entry. + expect(clonedStream[entryKey]).to.eq((clonedCustomData as any)[mapKey].get(clonedStream.name)); + expect(clonedStream[entryKey]).to.not.eq(sourceStream[entryKey]); + } + }; + + expectStreamsEquivalent("_curveStreams", "curve", "_curves", ["Intensity"]); + expectStreamsEquivalent("_gradientStreams", "gradient", "_gradients", ["Tint"]); // Mutation isolation: bumping the clone does not bleed back into the source. clonedCustomData.curves.get("Intensity")!.constantMax = 0.1; diff --git a/tests/src/core/physics/ColliderShape.test.ts b/tests/src/core/physics/ColliderShape.test.ts index 008756681d..5b42fc15cc 100644 --- a/tests/src/core/physics/ColliderShape.test.ts +++ b/tests/src/core/physics/ColliderShape.test.ts @@ -356,4 +356,38 @@ describe("ColliderShape PhysX", () => { expect((newCollider3.shapes[0] as CapsuleColliderShape).radius).to.eq(2); expect((newCollider3.shapes[0] as CapsuleColliderShape).height).to.eq(3); }); + + it("cloned shapes are independent instances owned by the cloned collider", () => { + const boxShape = new BoxColliderShape(); + boxShape.size = new Vector3(2, 3, 4); + boxShape.position = new Vector3(1, 2, 3); + dynamicCollider.addShape(boxShape); + + const srcEntity = dynamicCollider.entity; + const clonedEntity = srcEntity.clone(); + srcEntity.parent.addChild(clonedEntity); + const clonedCollider = clonedEntity.getComponent(DynamicCollider); + const clonedShape = clonedCollider.shapes[0] as BoxColliderShape; + + // Instance independence — not the source's shape shared by reference. + expect(clonedShape).not.to.eq(boxShape); + expect(clonedShape.id).not.to.eq(boxShape.id); + // @ts-ignore + expect(clonedShape._nativeShape).not.to.eq(boxShape._nativeShape); + // Ownership: each shape belongs to its own collider. + expect(clonedShape.collider).to.eq(clonedCollider); + expect(boxShape.collider).to.eq(dynamicCollider); + // Values copied. + expect(clonedShape.size).to.deep.include({ x: 2, y: 3, z: 4 }); + expect(clonedShape.position).to.deep.include({ x: 1, y: 2, z: 3 }); + // Mutating the clone must not affect the source. + clonedShape.size = new Vector3(9, 9, 9); + expect(boxShape.size).to.deep.include({ x: 2, y: 3, z: 4 }); + + // Destroying the clone must not destroy the source's shape / native handle. + clonedEntity.destroy(); + expect(boxShape.collider).to.eq(dynamicCollider); + // @ts-ignore + expect(boxShape._nativeShape).not.to.eq(null); + }); }); diff --git a/tests/src/core/physics/Collision.test.ts b/tests/src/core/physics/Collision.test.ts index 54b8f3809c..812821639c 100644 --- a/tests/src/core/physics/Collision.test.ts +++ b/tests/src/core/physics/Collision.test.ts @@ -1,7 +1,16 @@ -import { BoxColliderShape, DynamicCollider, Entity, Engine, Script, StaticCollider } from "@galacean/engine-core"; +import { + BoxColliderShape, + DynamicCollider, + DynamicColliderConstraints, + Entity, + Engine, + Script, + SphereColliderShape, + StaticCollider +} from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Collision } from "packages/core/types/physics/Collision"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -22,6 +31,20 @@ describe("Collision", function () { return boxEntity; } + function addSphere(radius: number, pos: Vector3) { + const sphereEntity = rootEntity.createChild("SphereEntity"); + sphereEntity.transform.setPosition(pos.x, pos.y, pos.z); + + const sphereShape = new SphereColliderShape(); + sphereShape.material.dynamicFriction = 0; + sphereShape.material.staticFriction = 0; + sphereShape.radius = radius; + const sphereCollider = sphereEntity.addComponent(DynamicCollider); + sphereCollider.addShape(sphereShape); + sphereCollider.useGravity = false; + return sphereEntity; + } + function formatValue(value: number) { return Math.round(value * 100000) / 100000; } @@ -164,4 +187,217 @@ describe("Collision", function () { engine.sceneManager.activeScene.physics._update(1); }); }); + + it("reports contact normal from static other shape to dynamic self shape", function () { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const dynamicBox = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const staticBox = addBox(new Vector3(1, 1, 1), StaticCollider, new Vector3(0, 0, 0)); + + return new Promise((done) => { + dynamicBox.addComponent( + class extends Script { + onCollisionEnter(other: Collision): void { + expect(other.shape).toBe(staticBox.getComponent(StaticCollider).shapes[0]); + const contacts = []; + other.getContacts(contacts); + expect(contacts.length).toBeGreaterThan(0); + expect(formatValue(contacts[0].normal.x)).toBe(-1); + + done(); + } + } + ); + + dynamicBox.getComponent(DynamicCollider).applyForce(new Vector3(1000, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + }); + }); + + it("reports billiard hitBall sphere normal from kinematic other to dynamic target self", function () { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const targetBall = addSphere(0.5, new Vector3(0, 0, 0)); + const hitBall = addSphere(0.5, new Vector3(-3, 0, 0)); + const hitCollider = hitBall.getComponent(DynamicCollider); + hitCollider.isKinematic = true; + + return new Promise((done) => { + targetBall.addComponent( + class extends Script { + onCollisionEnter(other: Collision): void { + expect(other.shape).toBe(hitCollider.shapes[0]); + const contacts = []; + other.getContacts(contacts); + expect(contacts.length).toBeGreaterThan(0); + + const contactNormal = contacts[0].normal; + expect(formatValue(contactNormal.x)).toBe(1); + expect(formatValue(contactNormal.y)).toBe(0); + expect(formatValue(contactNormal.z)).toBe(0); + + const hitToTarget = new Vector3(); + Vector3.subtract(targetBall.transform.worldPosition, hitBall.transform.worldPosition, hitToTarget); + hitToTarget.normalize(); + expect(formatValue(Vector3.dot(contactNormal, hitToTarget))).toBe(1); + + const scaledNormal = new Vector3(); + Vector3.scale(contactNormal, 2 * Vector3.dot(hitToTarget, contactNormal), scaledNormal); + const reflected = new Vector3(); + Vector3.subtract(hitToTarget, scaledNormal, reflected); + reflected.normalize(); + expect(formatValue(reflected.x)).toBe(-1); + + done(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + hitCollider.move(new Vector3(-0.9, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + }); + }); + + function probeKinematicCallback(opts: { + aKine: boolean; + bKine: boolean; + timeoutMs?: number; + }): Promise<{ fired: boolean }> { + return new Promise((resolve) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = opts.aKine; + colB.isKinematic = opts.bKine; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve({ fired: true }); + } + } + ); + + // Step a few frames to let PhysX settle initial state. + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // Teleport B onto A → expect onCollisionEnter. + boxB.transform.setPosition(-3, 0, 0); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) resolve({ fired: false }); + }); + } + + it("kinematic-kinematic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: true, bKine: true }); + expect(r.fired).toBe(true); + }); + + it("kinematic-dynamic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: true, bKine: false }); + expect(r.fired).toBe(true); + }); + + it("dynamic-dynamic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: false, bKine: false }); + expect(r.fired).toBe(true); + }); + + it("kinematic-kinematic overlap via move fires onCollisionEnter", function () { + return new Promise((resolve, reject) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = true; + colB.isKinematic = true; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // Move B onto A via DynamicCollider.move() — this internally calls setKinematicTarget. + colB.move(new Vector3(-3, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) reject(new Error("kine-kine setKinematicTarget did NOT fire onCollisionEnter")); + }); + }); + + it("fully constrained dynamic overlap via transform.setPosition fires onCollisionEnter", function () { + return new Promise((resolve) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + const FREEZE_ALL = + DynamicColliderConstraints.FreezePositionX | + DynamicColliderConstraints.FreezePositionY | + DynamicColliderConstraints.FreezePositionZ | + DynamicColliderConstraints.FreezeRotationX | + DynamicColliderConstraints.FreezeRotationY | + DynamicColliderConstraints.FreezeRotationZ; + colA.constraints = FREEZE_ALL; + colB.constraints = FREEZE_ALL; + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = false; + colB.isKinematic = false; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + boxB.transform.setPosition(-3, 0, 0); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) { + expect.fail("expected onCollisionEnter to fire for dynamic-frozen pair after teleport"); + } + }); + }); }); diff --git a/tests/src/core/physics/DynamicCollider.test.ts b/tests/src/core/physics/DynamicCollider.test.ts index 0ed815b9ca..dde821480e 100644 --- a/tests/src/core/physics/DynamicCollider.test.ts +++ b/tests/src/core/physics/DynamicCollider.test.ts @@ -6,13 +6,14 @@ import { DynamicCollider, DynamicColliderConstraints, CollisionDetectionMode, + DynamicColliderKinematicTransformSyncMode, StaticCollider, PlaneColliderShape } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; import { PhysXPhysics, PhysXRuntimeMode } from "@galacean/engine-physics-physx"; import { Vector3 } from "@galacean/engine-math"; -import { vi, describe, beforeAll, beforeEach, expect, it } from "vitest"; +import { vi, describe, beforeAll, beforeEach, afterEach, expect, it } from "vitest"; const physXWasmModeUrl = new URL("../../../../packages/physics-physx/libs/physx.release.js", import.meta.url).href; const physXWasmSIMDModeUrl = new URL("../../../../packages/physics-physx/libs/physx.release.simd.js", import.meta.url) @@ -52,6 +53,16 @@ describe("DynamicCollider", function () { return boxEntity; } + function addOffsetBoxCollider(name: string) { + const entity = rootEntity.createChild(name); + const collider = entity.addComponent(DynamicCollider); + const shape = new BoxColliderShape(); + shape.size = new Vector3(2, 4, 6); + shape.position = new Vector3(1, 2, 3); + collider.addShape(shape); + return { entity, collider }; + } + function formatValue(value: number) { return Math.round(value * 100000) / 100000; } @@ -72,6 +83,26 @@ describe("DynamicCollider", function () { rootEntity.clearChildren(); }); + afterEach(function () { + vi.restoreAllMocks(); + }); + + it("tolerancesScale drives PhysX default contact offset and sleep threshold", function () { + const physics = new PhysXPhysics({ tolerancesScale: { length: 2, speed: 20 } }); + + expect(physics.getDefaultContactOffset()).to.closeTo(0.04, 1e-6); + expect(physics.getDefaultSleepThreshold()).to.closeTo(0.02, 1e-6); + }); + + it("rejects invalid tolerancesScale before initialization", function () { + expect(() => new PhysXPhysics({ tolerancesScale: { length: 0 } })).to.throw( + "tolerancesScale.length must be a positive finite number" + ); + expect(() => new PhysXPhysics({ tolerancesScale: { speed: Number.NaN } })).to.throw( + "tolerancesScale.speed must be a positive finite number" + ); + }); + it("addShape and removeShape", function () { const collider = rootEntity.createChild("entity").addComponent(DynamicCollider); const boxCollider = new BoxColliderShape(); @@ -288,6 +319,80 @@ describe("DynamicCollider", function () { expect(formatValue(boxCollider.inertiaTensor.y)).eq(1); }); + it("applyForce on sleeping actor must wake up and apply force", function () { + // Validates whether PhysX wasm `addForce(force, eFORCE, autowake=true)` actually wakes a + // sleeping actor on its own — or whether the engine's explicit wakeUp() call is required. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.linearDamping = 0; + + boxCollider.sleep(); + expect(boxCollider.isSleeping()).toBe(true); + + boxCollider.applyForce(new Vector3(1, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + expect(boxCollider.isSleeping()).toBe(false); + }); + + it("applyForce after kinematic→dynamic switch (mimic billiards game break flow)", function () { + // Game pattern: all balls set kinematic at init, switched back to dynamic on break, + // then applyForce. Verifies the original 'force lost' bug was actually from this path. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.linearDamping = 0; + + boxCollider.isKinematic = true; + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + boxCollider.isKinematic = false; + + boxCollider.applyForce(new Vector3(1, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + }); + + it("applyForce is consumed by the first fixed substep in a frame", function () { + const scene = engine.sceneManager.activeScene; + const originalFTS = scene.physics.fixedTimeStep; + let dv_1_60 = 0; + let dv_1_480 = 0; + + try { + const probe = (fts: number) => { + rootEntity.clearChildren(); + scene.physics.fixedTimeStep = fts; + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const c = box.getComponent(DynamicCollider); + c.mass = 1; + c.useGravity = false; + c.linearDamping = 0; + c.angularDamping = 0; + c.applyForce(new Vector3(100, 0, 0)); + // @ts-ignore + scene.physics._update(1 / 60); + return c.linearVelocity.x; + }; + + dv_1_60 = probe(1 / 60); + dv_1_480 = probe(1 / 480); + } finally { + scene.physics.fixedTimeStep = originalFTS; + } + + expect(dv_1_60).toBeCloseTo(100 / 60, 2); + expect(dv_1_480).toBeCloseTo(100 / 480, 2); + expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1); + }); + it("maxAngularVelocity", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); @@ -425,6 +530,65 @@ describe("DynamicCollider", function () { expect(Math.abs(formatValue(boxCollider2.linearVelocity.x))).lessThan(4); }); + it("clone keeps automatic mass properties without warnings", function () { + const { entity } = addOffsetBoxCollider("AutomaticCollider"); + const consoleWarnSpy = vi.spyOn(console, "warn"); + + const clone = entity.clone().getComponent(DynamicCollider); + + expect(clone.automaticCenterOfMass).toBe(true); + expect(clone.automaticInertiaTensor).toBe(true); + expect(clone.centerOfMass).to.deep.include({ x: 1, y: 2, z: 3 }); + expect(formatValue(clone.inertiaTensor.x)).eq(4.33333); + expect(formatValue(clone.inertiaTensor.y)).eq(3.33333); + expect(formatValue(clone.inertiaTensor.z)).eq(1.66667); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it("clone keeps manual mass properties", function () { + const { entity, collider } = addOffsetBoxCollider("ManualCollider"); + collider.automaticCenterOfMass = false; + collider.automaticInertiaTensor = false; + collider.centerOfMass = new Vector3(1, 2, 3); + collider.inertiaTensor = new Vector3(4, 5, 6); + + const clone = entity.clone().getComponent(DynamicCollider); + + expect(clone.automaticCenterOfMass).toBe(false); + expect(clone.automaticInertiaTensor).toBe(false); + expect(clone.centerOfMass).to.deep.include({ x: 1, y: 2, z: 3 }); + expect(clone.inertiaTensor).to.deep.include({ x: 4, y: 5, z: 6 }); + }); + + it("clone keeps mixed automatic mass properties", function () { + const { entity: manualCenterEntity, collider: manualCenterCollider } = addOffsetBoxCollider("ManualCenterCollider"); + manualCenterCollider.automaticCenterOfMass = false; + manualCenterCollider.centerOfMass = new Vector3(7, 8, 9); + + const manualCenterClone = manualCenterEntity.clone().getComponent(DynamicCollider); + + expect(manualCenterClone.automaticCenterOfMass).toBe(false); + expect(manualCenterClone.automaticInertiaTensor).toBe(true); + expect(manualCenterClone.centerOfMass).to.deep.include({ x: 7, y: 8, z: 9 }); + expect(formatValue(manualCenterClone.inertiaTensor.x)).eq(4.33333); + expect(formatValue(manualCenterClone.inertiaTensor.y)).eq(3.33333); + expect(formatValue(manualCenterClone.inertiaTensor.z)).eq(1.66667); + + const { entity: manualInertiaEntity, collider: manualInertiaCollider } = + addOffsetBoxCollider("ManualInertiaCollider"); + manualInertiaCollider.automaticInertiaTensor = false; + manualInertiaCollider.inertiaTensor = new Vector3(7, 8, 9); + + const manualInertiaClone = manualInertiaEntity.clone().getComponent(DynamicCollider); + + expect(manualInertiaClone.automaticCenterOfMass).toBe(true); + expect(manualInertiaClone.automaticInertiaTensor).toBe(false); + expect(manualInertiaClone.centerOfMass).to.deep.include({ x: 1, y: 2, z: 3 }); + expect(formatValue(manualInertiaClone.inertiaTensor.x)).eq(7); + expect(formatValue(manualInertiaClone.inertiaTensor.y)).eq(8); + expect(formatValue(manualInertiaClone.inertiaTensor.z)).eq(9); + }); + it("useGravity", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 10, 0)); const boxCollider = box.getComponent(DynamicCollider); @@ -459,6 +623,48 @@ describe("DynamicCollider", function () { expect(box.transform.position.y).below(1); }); + it("teleports kinematic target collider on re-enable instead of sweeping from stale native pose", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(-10, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.useGravity = false; + boxCollider.isKinematic = true; + boxCollider.kinematicTransformSyncMode = DynamicColliderKinematicTransformSyncMode.Target; + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + // @ts-ignore - intentionally observe the native boundary used by Collider sync. + const nativeCollider = boxCollider._nativeCollider; + const originalMove = nativeCollider.move.bind(nativeCollider); + const originalSetWorldTransform = nativeCollider.setWorldTransform.bind(nativeCollider); + let moveCalls = 0; + let setWorldTransformCalls = 0; + nativeCollider.move = (...args: Parameters) => { + moveCalls++; + return originalMove(...args); + }; + nativeCollider.setWorldTransform = (...args: Parameters) => { + setWorldTransformCalls++; + return originalSetWorldTransform(...args); + }; + + try { + box.isActive = false; + box.transform.setPosition(10, 0, 0); + box.isActive = true; + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(moveCalls).eq(0); + expect(setWorldTransformCalls).eq(1); + expect(formatValue(box.transform.position.x)).eq(10); + } finally { + nativeCollider.move = originalMove; + nativeCollider.setWorldTransform = originalSetWorldTransform; + } + }); + it("constraints", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); @@ -503,6 +709,46 @@ describe("DynamicCollider", function () { ).toBeTruthy(); }); + it("CCD mode survives kinematic toggle", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + // @ts-ignore + const physX = boxCollider._nativeCollider._physXPhysics._physX; + const ccdFlag = () => + // @ts-ignore + boxCollider._nativeCollider._pxActor.getRigidBodyFlags(physX.PxRigidBodyFlag.eENABLE_CCD); + + boxCollider.collisionDetectionMode = CollisionDetectionMode.Continuous; + expect(ccdFlag()).toBeTruthy(); + + boxCollider.isKinematic = true; + expect(ccdFlag()).toBeFalsy(); + + boxCollider.isKinematic = false; + expect(ccdFlag()).toBeTruthy(); + expect(boxCollider.collisionDetectionMode).toEqual(CollisionDetectionMode.Continuous); + }); + + it("setCollisionDetectionMode in kinematic state defers native CCD flag application", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + // @ts-ignore + const physX = boxCollider._nativeCollider._physXPhysics._physX; + const ccdFlag = () => + // @ts-ignore + boxCollider._nativeCollider._pxActor.getRigidBodyFlags(physX.PxRigidBodyFlag.eENABLE_CCD); + + boxCollider.isKinematic = true; + expect(ccdFlag()).toBeFalsy(); + + boxCollider.collisionDetectionMode = CollisionDetectionMode.Continuous; + expect(ccdFlag()).toBeFalsy(); + expect(boxCollider.collisionDetectionMode).toEqual(CollisionDetectionMode.Continuous); + + boxCollider.isKinematic = false; + expect(ccdFlag()).toBeTruthy(); + }); + it("sleep", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts index 9506569526..759ef22d8a 100644 --- a/tests/src/core/physics/MeshColliderShape.test.ts +++ b/tests/src/core/physics/MeshColliderShape.test.ts @@ -5,13 +5,14 @@ import { SphereColliderShape, BoxColliderShape, DynamicCollider, + Engine, StaticCollider, PhysicsMaterial, Script, ModelMesh } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { describe, beforeAll, beforeEach, expect, it, vi } from "vitest"; @@ -45,6 +46,10 @@ function createModelMesh(engine: WebGLEngine, positions: number[], indices?: num return mesh; } +function getNativeShapeCount(collider: DynamicCollider): number { + return (collider as any)._nativeCollider._shapes.length; +} + describe("MeshColliderShape PhysX", () => { let engine: WebGLEngine; let root: Entity; @@ -182,6 +187,46 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); material?.destroy(); }); + + it("cloned MeshColliderShape rebuilds its native PhysX shape", async () => { + const groundEntity = root.createChild("meshGroundForClone"); + groundEntity.transform.setPosition(0, 0, 0); + const groundCollider = groundEntity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const mesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = mesh; + groundCollider.addShape(meshShape); + + const clonedGround = groundEntity.clone(); + // Move the original aside so the cloned ground is the only surface below the sphere. + groundEntity.transform.setPosition(1000, 0, 0); + root.addChild(clonedGround); + clonedGround.transform.setPosition(0, 0, 0); + + const sphereEntity = root.createChild("sphereForClone"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + const sphereMaterial = sphereShape.material; + sphereShape.radius = 0.5; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + // Sphere lands on cloned ground (y > -1), not falls forever (y < -10). + const sphereY = sphereEntity.transform.position.y; + expect(sphereY).toBeGreaterThan(-1); + expect(sphereY).toBeLessThan(2); + + groundEntity.destroy(); + clonedGround.destroy(); + sphereEntity.destroy(); + meshMaterial?.destroy(); + sphereMaterial?.destroy(); + }); }); describe("Convex Mesh (Dynamic)", () => { @@ -205,6 +250,142 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); }); + it("does not retry non-convex mesh creation on non-kinematic dynamic colliders", () => { + const entity = root.createChild("unsupportedDynamicMesh"); + const dynamicCollider = entity.addComponent(DynamicCollider); + + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const convexMesh = createModelMesh( + engine, + [0, 1, 0, -1, 0, -1, 1, 0, -1, 0, 0, 1], + [0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2] + ); + meshShape.isConvex = true; + meshShape.mesh = convexMesh; + dynamicCollider.addShape(meshShape); + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + meshShape.isConvex = false; + consoleErrorSpy.mockClear(); + + const triangleMesh = createModelMesh(engine, [-1, 0, -1, 1, 0, -1, 0, 0, 1], [0, 1, 2]); + meshShape.mesh = triangleMesh; + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + consoleErrorSpy.mockClear(); + + for (let i = 0; i < 3; i++) { + physicsScene._update(1 / 60); + } + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + entity.destroy(); + meshMaterial?.destroy(); + } + }); + + it("should clone convex mesh with exactly one native shape", () => { + const entity = root.createChild("cloneConvexMesh"); + const dynamicCollider = entity.addComponent(DynamicCollider); + const meshShape = new MeshColliderShape(); + const defaultMaterial = meshShape.material; + meshShape.isConvex = true; + dynamicCollider.addShape(meshShape); + + const mesh = createModelMesh(engine, [-1, -1, -1, 1, -1, -1, 0, 1, -1, -1, -1, 1, 1, -1, 1, 0, 1, 1]); + meshShape.mesh = mesh; + + expect(dynamicCollider.shapes).toHaveLength(1); + expect(getNativeShapeCount(dynamicCollider)).toBe(1); + expect(mesh._getReferCount()).toBe(1); + + const clonedEntity = entity.clone(); + const clonedCollider = clonedEntity.getComponent(DynamicCollider); + const clonedShape = clonedCollider.shapes[0] as MeshColliderShape; + + expect(clonedCollider.shapes).toHaveLength(1); + expect(clonedShape.mesh).toBe(mesh); + expect((clonedShape as any)._nativeShape).toBeTruthy(); + expect((clonedShape as any)._isShapeAttached).toBe(true); + expect(getNativeShapeCount(clonedCollider)).toBe(1); + expect(mesh._getReferCount()).toBe(2); + + clonedEntity.destroy(); + expect(mesh._getReferCount()).toBe(1); + entity.destroy(); + expect(mesh._getReferCount()).toBe(0); + defaultMaterial?.destroy(); + }); + + it("should create a cloned native shape when mesh data becomes available later", () => { + const entity = root.createChild("cloneDelayedConvexMesh"); + const dynamicCollider = entity.addComponent(DynamicCollider); + const meshShape = new MeshColliderShape(); + const defaultMaterial = meshShape.material; + meshShape.isConvex = true; + dynamicCollider.addShape(meshShape); + + const clonedEntity = entity.clone(); + const clonedCollider = clonedEntity.getComponent(DynamicCollider); + const clonedShape = clonedCollider.shapes[0] as MeshColliderShape; + + expect(clonedCollider.shapes).toHaveLength(1); + expect((clonedShape as any)._nativeShape).toBeFalsy(); + expect((clonedShape as any)._isShapeAttached).toBe(false); + expect(getNativeShapeCount(clonedCollider)).toBe(0); + + const mesh = createModelMesh(engine, [-1, -1, -1, 1, -1, -1, 0, 1, -1, -1, -1, 1, 1, -1, 1, 0, 1, 1]); + clonedShape.mesh = mesh; + + expect((clonedShape as any)._nativeShape).toBeTruthy(); + expect((clonedShape as any)._isShapeAttached).toBe(true); + expect(getNativeShapeCount(clonedCollider)).toBe(1); + expect(mesh._getReferCount()).toBe(1); + + clonedEntity.destroy(); + expect(mesh._getReferCount()).toBe(0); + entity.destroy(); + defaultMaterial?.destroy(); + }); + + it("should keep an inaccessible mesh native-less when cloned", () => { + const warnSpy = vi.spyOn(console, "warn"); + const entity = root.createChild("cloneInaccessibleConvexMesh"); + const dynamicCollider = entity.addComponent(DynamicCollider); + const meshShape = new MeshColliderShape(); + const defaultMaterial = meshShape.material; + meshShape.isConvex = true; + dynamicCollider.addShape(meshShape); + + const mesh = new ModelMesh(engine); + mesh.setPositions([new Vector3(-1, 0, 0), new Vector3(1, 0, 0), new Vector3(0, 1, 0)]); + mesh.uploadData(true); + meshShape.mesh = mesh; + + expect((meshShape as any)._nativeShape).toBeFalsy(); + expect(getNativeShapeCount(dynamicCollider)).toBe(0); + expect(mesh._getReferCount()).toBe(1); + + const clonedEntity = entity.clone(); + const clonedCollider = clonedEntity.getComponent(DynamicCollider); + const clonedShape = clonedCollider.shapes[0] as MeshColliderShape; + + expect(clonedShape.mesh).toBe(mesh); + expect((clonedShape as any)._nativeShape).toBeFalsy(); + expect((clonedShape as any)._isShapeAttached).toBe(false); + expect(getNativeShapeCount(clonedCollider)).toBe(0); + expect(mesh._getReferCount()).toBe(2); + + clonedEntity.destroy(); + expect(mesh._getReferCount()).toBe(1); + entity.destroy(); + expect(mesh._getReferCount()).toBe(0); + defaultMaterial?.destroy(); + warnSpy.mockRestore(); + }); + it("should allow convex mesh on dynamic collider", async () => { // Create ground const groundEntity = root.createChild("ground2"); @@ -418,6 +599,82 @@ describe("MeshColliderShape PhysX", () => { entity.destroy(); defaultMaterial?.destroy(); }); + + it("keeps the existing native mesh when runtime mesh recooking fails", () => { + const groundEntity = root.createChild("transactionalMeshUpdateGround"); + const staticCollider = groundEntity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const groundMesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = groundMesh; + staticCollider.addShape(meshShape); + + const nativeShape = (meshShape as any)._nativeShape; + const cooking = nativeShape._physXPhysics._pxCooking; + const originalCreateTriMesh = cooking.createTriMesh; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + let sphereEntity: Entity | undefined; + let sphereMaterial: PhysicsMaterial | undefined; + + try { + cooking.createTriMesh = () => null; + const replacementMesh = createModelMesh(engine, [-2, 0, -2, 2, 0, -2, -2, 0, 2, 2, 0, 2], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = replacementMesh; + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to create triangle mesh")); + expect(meshShape.mesh).toBe(groundMesh); + + sphereEntity = root.createChild("transactionalMeshUpdateSphere"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + sphereMaterial = sphereShape.material; + sphereShape.radius = 0.5; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + expect(sphereEntity.transform.position.y).toBeGreaterThan(-1); + } finally { + cooking.createTriMesh = originalCreateTriMesh; + consoleErrorSpy.mockRestore(); + sphereEntity?.destroy(); + groundEntity.destroy(); + meshMaterial?.destroy(); + sphereMaterial?.destroy(); + } + }); + + it("does not retry terminal native shape creation failures every physics tick", () => { + const entity = root.createChild("terminalCookFailure"); + const staticCollider = entity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const mesh = createModelMesh(engine, [-1, 0, -1, 1, 0, -1, -1, 0, 1, 1, 0, 1], [0, 2, 1, 1, 2, 3]); + const nativePhysics = (Engine as any)._nativePhysics; + const originalCreateMeshColliderShape = nativePhysics.createMeshColliderShape; + const createMeshColliderShapeSpy = vi.fn(() => null); + + try { + nativePhysics.createMeshColliderShape = createMeshColliderShapeSpy; + staticCollider.addShape(meshShape); + meshShape.mesh = mesh; + + expect(createMeshColliderShapeSpy).toHaveBeenCalledTimes(1); + + for (let i = 0; i < 3; i++) { + physicsScene._update(1 / 60); + } + + expect(createMeshColliderShapeSpy).toHaveBeenCalledTimes(1); + } finally { + nativePhysics.createMeshColliderShape = originalCreateMeshColliderShape; + entity.destroy(); + meshMaterial?.destroy(); + } + }); }); describe("Triangle Mesh with DynamicCollider", () => { @@ -720,6 +977,38 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); }); + it("rolls back cached state when native recooking throws", () => { + const entity = root.createChild("recookingRollback"); + const staticCollider = entity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const defaultMaterial = meshShape.material; + const originalMesh = createModelMesh(engine, [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]); + const replacementMesh = createModelMesh(engine, [0, 0, 0, 2, 0, 0, 0, 2, 0], [0, 1, 2]); + meshShape.mesh = originalMesh; + staticCollider.addShape(meshShape); + + const originalFlags = meshShape.cookingFlags; + const nativeShape = (meshShape as any)._nativeShape; + vi.spyOn(nativeShape, "setMeshData").mockImplementation(() => { + throw new Error("recook failed"); + }); + + expect(() => { + meshShape.cookingFlags = MeshColliderShapeCookingFlag.Cleaning; + }).toThrow("recook failed"); + expect(meshShape.cookingFlags).toBe(originalFlags); + + expect(() => { + meshShape.mesh = replacementMesh; + }).toThrow("recook failed"); + expect(meshShape.mesh).toBe(originalMesh); + expect(originalMesh._getReferCount()).toBe(1); + expect(replacementMesh._getReferCount()).toBe(0); + + entity.destroy(); + defaultMaterial?.destroy(); + }); + it("should not update when no mesh is set", () => { const meshShape = new MeshColliderShape(); const defaultMaterial = meshShape.material; @@ -764,4 +1053,36 @@ describe("MeshColliderShape PhysX", () => { entity.destroy(); }); }); + + describe("mesh refCount (slot-ownership contract)", () => { + it("clone acquires via the setter, churn transfers, destroy releases", () => { + const meshA = createModelMesh(engine, [-1, 0, -1, 1, 0, -1, 0, 0, 1], [0, 1, 2]); + const meshB = createModelMesh(engine, [-2, 0, -2, 2, 0, -2, 0, 0, 2], [0, 1, 2]); + const entity = root.createChild("meshRefSlot"); + const collider = entity.addComponent(StaticCollider); + const shape = new MeshColliderShape(); + shape.mesh = meshA; + collider.addShape(shape); + expect(meshA.refCount).toBe(1); + + const clone = entity.clone(); + root.addChild(clone); + expect(meshA.refCount).toBe(2); + + const clonedShape = clone.getComponent(StaticCollider).shapes[0] as MeshColliderShape; + expect(clonedShape).not.toBe(shape); + // The rebuilt native shape must be attached exactly once (setter attaches; _syncNative skips). + expect((clone.getComponent(StaticCollider) as any)._nativeCollider._shapes.length).toBe(1); + clonedShape.mesh = meshB; + expect(meshA.refCount).toBe(1); + expect(meshB.refCount).toBe(1); + + clone.destroy(); + expect(meshB.refCount).toBe(0); + expect(meshA.refCount).toBe(1); + + entity.destroy(); + expect(meshA.refCount).toBe(0); + }); + }); }); diff --git a/tests/src/core/physics/PhysicsMaterial.test.ts b/tests/src/core/physics/PhysicsMaterial.test.ts index 676f917fe3..7bfd0e0e88 100644 --- a/tests/src/core/physics/PhysicsMaterial.test.ts +++ b/tests/src/core/physics/PhysicsMaterial.test.ts @@ -8,7 +8,7 @@ import { StaticCollider, PlaneColliderShape } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { Vector3 } from "@galacean/engine-math"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -79,6 +79,56 @@ describe("PhysicsMaterial", () => { expect(formatValue(boxEntity2.transform.position.y)).eq(0); }); + it("cloned collider shape material keeps native values", () => { + const scene = engine.sceneManager.activeScene; + const originalGravity = scene.physics.gravity.clone(); + const originalFixedTimeStep = scene.physics.fixedTimeStep; + scene.physics.gravity = new Vector3(0, 0, 0); + scene.physics.fixedTimeStep = 1 / 60; + + try { + const wallEntity = addBox(new Vector3(1, 8, 8), StaticCollider, new Vector3(0, 0, 0)); + const wallMaterial = wallEntity.getComponent(StaticCollider).shapes[0].material; + wallMaterial.bounciness = 1; + wallMaterial.dynamicFriction = 0; + wallMaterial.staticFriction = 0; + wallMaterial.bounceCombine = PhysicsMaterialCombineMode.Multiply; + wallMaterial.frictionCombine = PhysicsMaterialCombineMode.Multiply; + + const sourceEntity = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const sourceCollider = sourceEntity.getComponent(DynamicCollider); + sourceCollider.linearDamping = 0; + sourceCollider.angularDamping = 0; + sourceCollider.automaticCenterOfMass = false; + sourceCollider.automaticInertiaTensor = false; + + const sourceMaterial = sourceCollider.shapes[0].material; + sourceMaterial.bounciness = 1; + sourceMaterial.dynamicFriction = 0; + sourceMaterial.staticFriction = 0; + sourceMaterial.bounceCombine = PhysicsMaterialCombineMode.Multiply; + sourceMaterial.frictionCombine = PhysicsMaterialCombineMode.Multiply; + + const cloneEntity = sourceEntity.clone(); + sourceEntity.destroy(); + rootEntity.addChild(cloneEntity); + cloneEntity.transform.setPosition(-3, 0, 0); + + const cloneCollider = cloneEntity.getComponent(DynamicCollider); + cloneCollider.linearVelocity = new Vector3(10, 0, 0); + + for (let i = 0; i < 40; i++) { + // @ts-ignore + scene.physics._update(scene.physics.fixedTimeStep); + } + + expect(cloneCollider.linearVelocity.x).lessThan(-1); + } finally { + scene.physics.gravity = originalGravity; + scene.physics.fixedTimeStep = originalFixedTimeStep; + } + }); + it("bounceCombine Average", () => { const boxEntity = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(0, 5, 0)); const ground = addPlane(0, -0.5, 0); diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index fae4d817c2..72382e3ecd 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -17,7 +17,7 @@ import { } from "@galacean/engine-core"; import { Ray, Vector3, Quaternion } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { vi, describe, beforeAll, expect, it, afterEach } from "vitest"; class CollisionTestScript extends Script { @@ -62,12 +62,44 @@ class CollisionTestScript extends Script { } } +class CollisionDemandScript extends Script { + onCollisionEnter(): void {} +} + +class TriggerDemandScript extends Script { + onTriggerEnter(): void {} +} + function updatePhysics(physics) { for (let i = 0; i < 5; ++i) { physics._update(8); } } +function watchNativeContactEventDemand(physicsScene: PhysicsScene) { + const nativeScene = (physicsScene as any)._nativePhysicsScene; + const original = nativeScene.setContactEventEnabled; + const calls: boolean[] = []; + nativeScene.setContactEventEnabled = (enabled: boolean) => { + calls.push(enabled); + original?.call(nativeScene, enabled); + }; + return { + calls, + restore() { + if (original) { + nativeScene.setContactEventEnabled = original; + } else { + delete nativeScene.setContactEventEnabled; + } + } + }; +} + +function getLastContactEventDemandCall(calls: boolean[]): boolean | undefined { + return calls[calls.length - 1]; +} + function resetSpy() { // reset spy on collision test script. CollisionTestScript.prototype.onCollisionEnter = vi.fn(CollisionTestScript.prototype.onCollisionEnter); @@ -142,6 +174,113 @@ describe("Physics Test", () => { expect(enginePhysX.sceneManager.scenes[0].physics.fixedTimeStep).to.eq(fixedTimeStep); }); + it("maximumDeltaTime", () => { + const physics = enginePhysX.sceneManager.scenes[0].physics; + expect(physics.maximumDeltaTime).to.eq(Infinity); + + physics.fixedTimeStep = 1 / 60; + physics.maximumDeltaTime = 1 / 60; + (physics as any)._restTime = 0; + + const nativePhysicsScene = (physics as any)._nativePhysicsScene; + const update = vi.fn(); + (physics as any)._nativePhysicsScene = { + update, + updateEvents: () => ({ contactEvents: [], contactEventCount: 0, triggerEvents: [] }) + }; + + try { + physics._update(1); + expect(update).toHaveBeenCalledTimes(1); + } finally { + (physics as any)._nativePhysicsScene = nativePhysicsScene; + physics.maximumDeltaTime = Infinity; + } + }); + + it("auto-disables native contact events when no active collision callback exists", () => { + const scene = new Scene(enginePhysX); + enginePhysX.sceneManager.addScene(scene); + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-disabled"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + scene.destroy(); + } + }); + + it("auto-enables native contact events only while an active collision callback exists", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-enabled"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + const script = entity.addComponent(CollisionDemandScript); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(true); + + script.enabled = false; + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + + it("does not rescan contact event demand on every fixed substep", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const fixedTimeStep = physicsScene.fixedTimeStep; + const root = scene.createRootEntity("contact-demand-substeps"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + entity.addComponent(CollisionDemandScript); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene.fixedTimeStep = 1 / 480; + physicsScene._update(1 / 60); + expect(contactEventDemand.calls).to.deep.eq([true]); + } finally { + physicsScene.fixedTimeStep = fixedTimeStep; + contactEventDemand.restore(); + root.destroy(); + } + }); + + it("keeps native contact events disabled when only trigger callbacks exist", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-trigger-only"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + entity.addComponent(TriggerDemandScript); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + it("raycast", () => { const scene = enginePhysX.sceneManager.activeScene; const physicsScene = scene.physics; @@ -1488,15 +1627,16 @@ describe("Physics Test", () => { const entity2 = raycastTestRoot.createChild("entity2"); const collisionTestScript = entity1.addComponent(CollisionTestScript); - // Test that collision works correctly, A is dynamic and kinematic, B is static. + // SceneDesc.staticKineFilteringMode = eKEEP + Collider.move() routing kinematic + // to setKinematicTarget make static-kinematic pairs generate contact events. resetSpy(); setColliderProps(entity1, true, false, true); setColliderProps(entity2, false, false, false); updatePhysics(physicsMgr); - expect(collisionTestScript.onCollisionEnter).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionStay).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionExit).not.toHaveBeenCalled(); + expect(collisionTestScript.onCollisionEnter).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionStay).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionExit).toHaveBeenCalled(); expect(collisionTestScript.onTriggerEnter).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerStay).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerExit).not.toHaveBeenCalled(); @@ -1603,15 +1743,16 @@ describe("Physics Test", () => { const entity2 = raycastTestRoot.createChild("entity2"); const collisionTestScript = entity1.addComponent(CollisionTestScript); - // Test that collision works correctly, both A,B are dynamic, kinematic. + // SceneDesc.kineKineFilteringMode = eKEEP + Collider.move() routing kinematic + // to setKinematicTarget make kine-kine pairs generate contact events. resetSpy(); setColliderProps(entity1, true, false, true); setColliderProps(entity2, true, false, true); updatePhysics(physicsMgr); - expect(collisionTestScript.onCollisionEnter).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionStay).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionExit).not.toHaveBeenCalled(); + expect(collisionTestScript.onCollisionEnter).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionStay).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionExit).toHaveBeenCalled(); expect(collisionTestScript.onTriggerEnter).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerStay).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerExit).not.toHaveBeenCalled(); diff --git a/tests/src/core/postProcess/PostProcess.test.ts b/tests/src/core/postProcess/PostProcess.test.ts index 1d0178b549..cc6caba195 100644 --- a/tests/src/core/postProcess/PostProcess.test.ts +++ b/tests/src/core/postProcess/PostProcess.test.ts @@ -173,6 +173,57 @@ describe("PostProcess", () => { expect(pp._effects.length).to.equal(0); }); + it("Post Process clone", () => { + const pp = postEntity.addComponent(PostProcess); + const bloomEffect = pp.addEffect(BloomEffect); + const dirtTexture = new Texture2D(engine, 1, 1); + + bloomEffect.intensity.value = 1.5; + bloomEffect.threshold.value = 0.9; + bloomEffect.tint.value = new Color(0.5, 0.25, 0.1, 1); + bloomEffect.highQualityFiltering.value = true; + bloomEffect.dirtTexture.value = dirtTexture; + bloomEffect.enabled = false; + + const refCount = dirtTexture.refCount; + + const cloneEntity = postEntity.clone(); + const clonePP = cloneEntity.getComponent(PostProcess); + const cloneBloom = clonePP.getEffect(BloomEffect); + + // Effects, effect and parameters are all fresh instances. + expect(clonePP).to.not.equal(pp); + // @ts-ignore + expect(clonePP._effects).to.not.equal(pp._effects); + // @ts-ignore + expect(clonePP._effects.length).to.equal(1); + expect(cloneBloom).to.instanceOf(BloomEffect); + expect(cloneBloom).to.not.equal(bloomEffect); + expect(cloneBloom.intensity).to.not.equal(bloomEffect.intensity); + expect(cloneBloom.tint).to.not.equal(bloomEffect.tint); + + // Values are preserved. + expect(cloneBloom.intensity.value).to.equal(1.5); + expect(cloneBloom.threshold.value).to.equal(0.9); + expect(cloneBloom.highQualityFiltering.value).to.true; + expect(cloneBloom.enabled).to.false; + expect(cloneBloom.tint.value).to.include(new Color(0.5, 0.25, 0.1, 1)); + + // Values are independent: mutating the clone leaves the source untouched. + expect(cloneBloom.tint.value).to.not.equal(bloomEffect.tint.value); + cloneBloom.tint.value.r = 0.9; + expect(bloomEffect.tint.value.r).to.equal(0.5); + cloneBloom.intensity.value = 3; + expect(bloomEffect.intensity.value).to.equal(1.5); + + // Texture parameter shares the same texture reference without touching its refCount. + expect(cloneBloom.dirtTexture).to.not.equal(bloomEffect.dirtTexture); + expect(cloneBloom.dirtTexture.value).to.equal(dirtTexture); + expect(dirtTexture.refCount).to.equal(refCount); + + cloneEntity.destroy(); + }); + it("Post Process", () => { const ppManager = scene.postProcessManager; diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index b0fea63848..266aa3c850 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -28,6 +28,28 @@ describe("ResourceManager", () => { getResource = engine.resourceManager.getFromCache(wrongUrl); expect(getResource).equal(null); }); + + it("resolves virtual paths to physical cache keys", () => { + const resourceManager = engine.resourceManager; + const virtualPath = "Prefab/Character.prefab"; + const physicalPath = "/Prefab/Character.prefab"; + const remoteUrl = "https://cdn.ali.com/Prefab/Remote.prefab"; + const physicalResource = {}; + const remoteResource = {}; + + resourceManager.initVirtualResources([ + { virtualPath, path: physicalPath, type: AssetType.Prefab, params: { keep: true } } + ]); + // @ts-ignore + resourceManager._assetUrlPool[physicalPath] = physicalResource; + // @ts-ignore + resourceManager._assetUrlPool[remoteUrl] = remoteResource; + + expect(resourceManager.getFromCache(virtualPath)).equal(physicalResource); + expect(resourceManager.getFromCache(physicalPath)).equal(physicalResource); + expect(resourceManager.getFromCache(remoteUrl)).equal(remoteResource); + expect(resourceManager.getFromCache("Prefab/Missing.prefab")).equal(null); + }); }); describe("findResourcesByType", () => { diff --git a/tests/src/core/resource/SceneLoaderCache.test.ts b/tests/src/core/resource/SceneLoaderCache.test.ts new file mode 100644 index 0000000000..2a9ee568f1 --- /dev/null +++ b/tests/src/core/resource/SceneLoaderCache.test.ts @@ -0,0 +1,98 @@ +import { AssetPromise, AssetType, ResourceManager, Scene } from "@galacean/engine-core"; +import "@galacean/engine-loader"; +import { WebGLEngine } from "@galacean/engine"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +describe("SceneLoader cache policy", () => { + let engine: WebGLEngine; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + engine.destroy(); + }); + + it("SceneLoader should have useCache disabled", () => { + const sceneLoader = ResourceManager._loaders[AssetType.Scene]; + expect(sceneLoader).to.not.be.undefined; + expect(sceneLoader.useCache).to.eq(false); + }); + + it("PrimitiveMeshLoader should also have useCache disabled (existing convention)", () => { + const loader = ResourceManager._loaders[AssetType.PrimitiveMesh]; + expect(loader).to.not.be.undefined; + expect(loader.useCache).to.eq(false); + }); + + it("Texture loader should still have useCache enabled (immutable asset)", () => { + const loader = ResourceManager._loaders[AssetType.Texture]; + expect(loader).to.not.be.undefined; + expect(loader.useCache).to.eq(true); + }); + + describe("loadScene behavior", () => { + function mockSceneLoad() { + const loader = ResourceManager._loaders[AssetType.Scene]; + return vi.spyOn(loader, "load").mockImplementation( + () => + new AssetPromise((resolve) => { + resolve(new Scene(engine, "mock")); + }) + ); + } + + it("should activate a fresh Scene instance and destroy the old one on sequential loads of the same url", async () => { + mockSceneLoad(); + const sceneManager = engine.sceneManager; + + const first = await sceneManager.loadScene("/mock-sequential.scene"); + const second = await sceneManager.loadScene("/mock-sequential.scene"); + + expect(second).not.toBe(first); + expect(first.destroyed).toBe(true); + expect(second.destroyed).toBe(false); + expect(sceneManager.scenes[0]).toBe(second); + }); + + it("should destroy every old scene when multiple scenes are active", async () => { + mockSceneLoad(); + const sceneManager = engine.sceneManager; + sceneManager.addScene(new Scene(engine, "extra1")); + sceneManager.addScene(new Scene(engine, "extra2")); + const oldScenes = [...sceneManager.scenes]; + expect(oldScenes.length).toBeGreaterThanOrEqual(2); + + const scene = await sceneManager.loadScene("/mock-multi.scene"); + + for (const oldScene of oldScenes) { + expect(oldScene.destroyed).toBe(true); + } + expect(scene.destroyed).toBe(false); + expect(sceneManager.scenes.length).toBe(1); + expect(sceneManager.scenes[0]).toBe(scene); + }); + + it("should not destroy the activated scene when concurrent loads of the same url share one loading promise", async () => { + const loadSpy = mockSceneLoad(); + const sceneManager = engine.sceneManager; + + const [first, second] = await Promise.all([ + sceneManager.loadScene("/mock-concurrent.scene"), + sceneManager.loadScene("/mock-concurrent.scene") + ]); + + // The in-flight loading promise is shared regardless of useCache + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + expect(first.destroyed).toBe(false); + expect(sceneManager.scenes.length).toBe(1); + expect(sceneManager.scenes[0]).toBe(first); + }); + }); +}); diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 85cceaa5d7..4b9d50b646 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -602,6 +602,30 @@ afterAll(() => { }); describe("glTF Loader test", function () { + it("resolves scene sub-assets by the canonical glTF schema key", async () => { + const sceneRoot = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testRoot.gltf?q=scenes[0]" + }); + const glTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testRoot.gltf" + }); + + expect(sceneRoot).to.equal(glTFResource.scenes[0]); + }); + + it("rejects an out-of-range glTF scene index with a descriptive error", async () => { + const glTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testRoot.gltf" + }); + + expect(() => glTFResource.instantiateSceneRoot(glTFResource.scenes.length)).to.throw( + `scene index ${glTFResource.scenes.length} is out of range` + ); + }); + it("Pipeline Parser", async () => { const glTFResource: GLTFResource = await engine.resourceManager.load({ type: AssetType.GLTF, diff --git a/tests/src/loader/SceneFormatV2.test.ts b/tests/src/loader/SceneFormatV2.test.ts index 95cdc3a693..d03f397f64 100644 --- a/tests/src/loader/SceneFormatV2.test.ts +++ b/tests/src/loader/SceneFormatV2.test.ts @@ -23,6 +23,7 @@ import { } from "../../../packages/loader/src/resource-deserialize/resources/parser/ParserContext"; import { ReflectionParser } from "../../../packages/loader/src/resource-deserialize/resources/parser/ReflectionParser"; import { SceneParser } from "../../../packages/loader/src/resource-deserialize/resources/scene/SceneParser"; +import { GLTFResource } from "../../../packages/loader/src/gltf/GLTFResource"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -387,6 +388,31 @@ describe("SceneParser v2 entity tree", () => { }; } + it("loads the main glTF resource and instantiates the selected scene", async () => { + const data = createSceneData([{ instance: { asset: 0 } }], [], [0], [{ url: "model.glb", key: "scenes[1]" }]); + const scene = new Scene(engine); + const context = new ParserContext(engine, ParserType.Scene, scene); + const glTFResource = new GLTFResource(engine, "model.glb"); + const instanceRoot = new Entity(engine, "Scene 1"); + const instantiateSceneRoot = vi.spyOn(glTFResource, "instantiateSceneRoot").mockReturnValue(instanceRoot); + const getResourceByRef = vi + .spyOn(engine.resourceManager as any, "getResourceByRef") + .mockResolvedValue(glTFResource); + + try { + const parser = new SceneParser(data, context, scene); + parser.start(); + await parser.promise; + + expect(getResourceByRef).toHaveBeenCalledWith({ url: "model.glb" }); + expect(instantiateSceneRoot).toHaveBeenCalledWith(1); + expect(scene.rootEntities[0]).to.equal(instanceRoot); + } finally { + getResourceByRef.mockRestore(); + instantiateSceneRoot.mockRestore(); + } + }); + it("should reject scene data without the v2 version marker", () => { const data = { entities: [{ name: "Entity" }], diff --git a/tests/src/loader/ScenePhysics.test.ts b/tests/src/loader/ScenePhysics.test.ts new file mode 100644 index 0000000000..8b4e889df0 --- /dev/null +++ b/tests/src/loader/ScenePhysics.test.ts @@ -0,0 +1,53 @@ +import { AssetType, BackgroundMode, Scene } from "@galacean/engine"; +import "@galacean/engine-loader"; +import { PhysXPhysics } from "@galacean/engine-physics-physx"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +let engine: WebGLEngine; + +beforeAll(async () => { + engine = await WebGLEngine.create({ + canvas: document.createElement("canvas"), + physics: new PhysXPhysics() + }); +}); + +afterAll(() => { + engine?.destroy(); +}); + +describe("SceneLoader physics settings", () => { + it("applies serialized scene physics settings to PhysicsScene", async () => { + const sceneData = { + version: "2.0", + refs: [], + entities: [], + components: [], + scene: { + name: "physics-scene", + rootEntities: [], + background: { + mode: BackgroundMode.SolidColor, + color: [0, 0, 0, 1] + }, + physics: { + gravity: [0, -3200, 0], + fixedTimeStep: 1 / 120 + } + } + }; + const sceneUrl = + URL.createObjectURL(new Blob([JSON.stringify(sceneData)], { type: "application/json" })) + "#.scene"; + + const scene = await engine.resourceManager.load({ + url: sceneUrl, + type: AssetType.Scene + }); + + expect(scene.physics.gravity.x).toBe(0); + expect(scene.physics.gravity.y).toBe(-3200); + expect(scene.physics.gravity.z).toBe(0); + expect(scene.physics.fixedTimeStep).toBe(1 / 120); + }); +}); diff --git a/tests/src/math/Ray.test.ts b/tests/src/math/Ray.test.ts index 2a7eb2b5e4..21be16603a 100644 --- a/tests/src/math/Ray.test.ts +++ b/tests/src/math/Ray.test.ts @@ -31,4 +31,24 @@ describe("Ray test", () => { expect(Vector3.equals(out, new Vector3(0, 10, 0))).to.eq(true); }); + + it("ray-clone", () => { + const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 0, 1)); + const out = ray.clone(); + + expect(out).not.to.eq(ray); + expect(out.origin).not.to.eq(ray.origin); + expect(Vector3.equals(out.origin, ray.origin)).to.eq(true); + expect(Vector3.equals(out.direction, ray.direction)).to.eq(true); + }); + + it("ray-copyFrom", () => { + const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 0, 1)); + const out = new Ray(); + const result = out.copyFrom(ray); + + expect(result).to.eq(out); + expect(Vector3.equals(out.origin, ray.origin)).to.eq(true); + expect(Vector3.equals(out.direction, ray.direction)).to.eq(true); + }); }); diff --git a/tests/src/spine/LoaderUtils.test.ts b/tests/src/spine/LoaderUtils.test.ts new file mode 100644 index 0000000000..fa97f9afcf --- /dev/null +++ b/tests/src/spine/LoaderUtils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { LoaderUtils } from "../../../packages/spine/src/loader/LoaderUtils"; + +describe("LoaderUtils.getBaseUrl", () => { + it("strips the file name from a local path", () => { + expect(LoaderUtils.getBaseUrl("/assets/spine/data.json")).to.equal("/assets/spine/"); + }); + + it("returns empty string for a bare file name", () => { + expect(LoaderUtils.getBaseUrl("data.json")).to.equal(""); + }); + + it("strips the file name from an http url", () => { + expect(LoaderUtils.getBaseUrl("https://cdn.example.com/a/b/data.json")).to.equal("https://cdn.example.com/a/b/"); + }); +}); diff --git a/tests/src/spine/Pool.test.ts b/tests/src/spine/Pool.test.ts new file mode 100644 index 0000000000..0d835b408a --- /dev/null +++ b/tests/src/spine/Pool.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { ClearablePool } from "../../../packages/spine-core-4.2/src/util/ClearablePool"; +import { ReturnablePool } from "../../../packages/spine-core-4.2/src/util/ReturnablePool"; + +class Item { + value = 0; +} + +describe("ClearablePool", () => { + it("hands out distinct elements, then reuses them after clear", () => { + const pool = new ClearablePool(Item); + const a = pool.get(); + const b = pool.get(); + expect(a).to.not.equal(b); + + pool.clear(); + // clear() only resets the cursor; existing elements are reused in order. + expect(pool.get()).to.equal(a); + expect(pool.get()).to.equal(b); + }); +}); + +describe("ReturnablePool", () => { + it("pre-fills initializeCount and reuses returned elements", () => { + const pool = new ReturnablePool(Item, 2); + const a = pool.get(); + const b = pool.get(); + expect(a).to.not.equal(b); + + pool.return(a); + expect(pool.get()).to.equal(a); + }); + + it("allocates a fresh element when the pool is exhausted", () => { + const pool = new ReturnablePool(Item, 1); + const a = pool.get(); + const fresh = pool.get(); + expect(fresh).to.be.instanceOf(Item); + expect(fresh).to.not.equal(a); + }); +}); diff --git a/tests/src/spine/SpineAnimationRenderer.test.ts b/tests/src/spine/SpineAnimationRenderer.test.ts new file mode 100644 index 0000000000..48de10c2e3 --- /dev/null +++ b/tests/src/spine/SpineAnimationRenderer.test.ts @@ -0,0 +1,72 @@ +import { describe, beforeAll, expect, it } from "vitest"; +import { Entity, Material, Shader, Texture2D, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "../../../packages/spine/src/renderer/SpineAnimationRenderer"; +import { SpineMaterial } from "../../../packages/spine/src/renderer/SpineMaterial"; +import { SpineBlendMode } from "../../../packages/spine/src/enums/SpineBlendMode"; + +describe("SpineAnimationRenderer", () => { + let engine: WebGLEngine; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + }); + + it("initializes with default config", () => { + const renderer = new Entity(engine).addComponent(SpineAnimationRenderer); + expect(renderer.defaultConfig.animationName).to.be.null; + expect(renderer.defaultConfig.skinName).to.equal("default"); + expect(renderer.premultipliedAlpha).to.be.false; + expect(renderer.tintBlack).to.be.false; + }); + + it("tintBlack setter flags a buffer resize", () => { + const renderer = new Entity(engine).addComponent(SpineAnimationRenderer); + renderer.tintBlack = true; + expect(renderer.tintBlack).to.be.true; + expect((renderer as any)._needResizeBuffer).to.be.true; + }); + + it("_getMaterial caches by texture + blendMode", () => { + const renderer = new Entity(engine).addComponent(SpineAnimationRenderer); + const texture = new Texture2D(engine, 4, 4); + const normal = renderer._getMaterial(texture, SpineBlendMode.Normal); + // Same texture + blendMode must hit the cache (regression guard for the Map[key] bug). + expect(renderer._getMaterial(texture, SpineBlendMode.Normal)).to.equal(normal); + // Different blendMode must produce a different material. + expect(renderer._getMaterial(texture, SpineBlendMode.Additive)).to.not.equal(normal); + }); + + it("_getMaterial does not share a material across tintBlack variants", () => { + const renderer = new Entity(engine).addComponent(SpineAnimationRenderer); + const texture = new Texture2D(engine, 4, 4); + const plain = renderer._getMaterial(texture, SpineBlendMode.Normal); + renderer.tintBlack = true; + const tinted = renderer._getMaterial(texture, SpineBlendMode.Normal); + // Sharing one material would let renderers that differ only in tintBlack clobber + // each other's RENDERER_TINT_BLACK macro every frame. + expect(tinted).to.not.equal(plain); + expect(renderer._getMaterial(texture, SpineBlendMode.Normal)).to.equal(tinted); + renderer.tintBlack = false; + expect(renderer._getMaterial(texture, SpineBlendMode.Normal)).to.equal(plain); + }); + + it("destroy tolerates user-set materials and removes cached entries by key", () => { + const entity = new Entity(engine); + const renderer = entity.addComponent(SpineAnimationRenderer); + const texture = new Texture2D(engine, 4, 4); + const cached = renderer._getMaterial(texture, SpineBlendMode.Normal) as SpineMaterial; + renderer.setMaterial(0, cached); + // setMaterial is public API — destroy must not assume every entry is a SpineMaterial. + renderer.setMaterial(1, new Material(engine, Shader.find("2D/Spine"))); + const cacheKey = cached._cacheKey; + expect(SpineAnimationRenderer._materialCacheMap.has(cacheKey)).to.be.true; + expect(() => entity.destroy()).to.not.throw(); + expect(SpineAnimationRenderer._materialCacheMap.has(cacheKey)).to.be.false; + }); + + it("cloning an entity whose renderer has no skeleton does not throw", () => { + const entity = new Entity(engine); + entity.addComponent(SpineAnimationRenderer); + expect(() => entity.clone()).to.not.throw(); + }); +}); diff --git a/tests/src/spine/SpineConstant.test.ts b/tests/src/spine/SpineConstant.test.ts new file mode 100644 index 0000000000..38113402c6 --- /dev/null +++ b/tests/src/spine/SpineConstant.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { SpineVertexStride } from "../../../packages/spine/src/SpineConstant"; +import { SpineBlendMode } from "../../../packages/spine/src/enums/SpineBlendMode"; + +describe("SpineVertexStride", () => { + it("is 9 floats without tint and 12 with tint black", () => { + // [x,y,z,u,v,r,g,b,a] vs [...,dr,dg,db] — must match the renderer's vertex layout. + expect(SpineVertexStride.withoutTint).to.equal(9); + expect(SpineVertexStride.withTint).to.equal(12); + }); +}); + +describe("SpineBlendMode", () => { + it("ordinals match the spine-core BlendMode order", () => { + expect(SpineBlendMode.Normal).to.equal(0); + expect(SpineBlendMode.Additive).to.equal(1); + expect(SpineBlendMode.Multiply).to.equal(2); + expect(SpineBlendMode.Screen).to.equal(3); + }); +}); diff --git a/tests/src/spine/SpineLoader.test.ts b/tests/src/spine/SpineLoader.test.ts new file mode 100644 index 0000000000..3e2903f986 --- /dev/null +++ b/tests/src/spine/SpineLoader.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { SpineLoader } from "../../../packages/spine/src/loader/SpineLoader"; + +describe("SpineLoader._getUrlExtension", () => { + it("extracts the extension for spine asset types", () => { + expect(SpineLoader._getUrlExtension("path/to/spine.json")).to.equal("json"); + expect(SpineLoader._getUrlExtension("path/to/spine.skel")).to.equal("skel"); + expect(SpineLoader._getUrlExtension("path/to/spine.atlas")).to.equal("atlas"); + }); + + it("ignores query strings", () => { + expect(SpineLoader._getUrlExtension("spine.json?v=2")).to.equal("json"); + }); + + it("returns null when there is no extension", () => { + expect(SpineLoader._getUrlExtension("spine")).to.be.null; + }); +}); diff --git a/tests/src/spine/SpineMaterial.test.ts b/tests/src/spine/SpineMaterial.test.ts new file mode 100644 index 0000000000..2bb4e42bcd --- /dev/null +++ b/tests/src/spine/SpineMaterial.test.ts @@ -0,0 +1,36 @@ +import { describe, beforeAll, expect, it } from "vitest"; +import { BlendFactor, Shader, ShaderProperty, WebGLEngine } from "@galacean/engine"; +import { SpineMaterial } from "../../../packages/spine/src/renderer/SpineMaterial"; +import { SpineBlendMode } from "../../../packages/spine/src/enums/SpineBlendMode"; + +describe("SpineMaterial", () => { + let engine: WebGLEngine; + const srcColor = ShaderProperty.getByName("sourceColorBlendFactor"); + const dstColor = ShaderProperty.getByName("destinationColorBlendFactor"); + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + }); + + it("uses the built-in 2D/Spine shader and defaults to Normal blend", () => { + const material = new SpineMaterial(engine); + expect(material.shader).to.equal(Shader.find("2D/Spine")); + expect(material._getBlendMode()).to.equal(SpineBlendMode.Normal); + }); + + it("additive blend sets source=SourceAlpha, destination=One", () => { + const material = new SpineMaterial(engine); + material._setBlendMode(SpineBlendMode.Additive, false); + expect(material._getBlendMode()).to.equal(SpineBlendMode.Additive); + expect(material.shaderData.getInt(srcColor)).to.equal(BlendFactor.SourceAlpha); + expect(material.shaderData.getInt(dstColor)).to.equal(BlendFactor.One); + }); + + it("premultipliedAlpha switches the source color factor to One", () => { + const material = new SpineMaterial(engine); + material._setBlendMode(SpineBlendMode.Normal, true); + expect(material.shaderData.getInt(srcColor)).to.equal(BlendFactor.One); + material._setBlendMode(SpineBlendMode.Normal, false); + expect(material.shaderData.getInt(srcColor)).to.equal(BlendFactor.SourceAlpha); + }); +}); diff --git a/tests/src/spine/SpineResource.test.ts b/tests/src/spine/SpineResource.test.ts new file mode 100644 index 0000000000..6f281f81ba --- /dev/null +++ b/tests/src/spine/SpineResource.test.ts @@ -0,0 +1,33 @@ +import { Texture2D, WebGLEngine } from "@galacean/engine"; +import { beforeAll, describe, expect, it } from "vitest"; +import { SpineResource } from "../../../packages/spine/src/loader/SpineResource"; +import { SpineTexture } from "../../../packages/spine-core-4.2/src/SpineTexture"; + +describe("SpineResource._associationTextureInSkeletonData", () => { + let engine: WebGLEngine; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + }); + + it("associates the attachment's Texture2D and protects it from GC while the resource is alive", () => { + const texture2D = new Texture2D(engine, 4, 4); + const attachment = { region: { texture: new SpineTexture(texture2D) } }; + const skeletonData = { + skins: [{ getAttachment: () => attachment }], + slots: [{ index: 0, name: "slot0" }] + }; + // Stands in for the SpineResource instance: only the fields `_associationTextureInSkeletonData` + // reads/writes (`_texturesInSpineAtlas`) and the super-resource GC check reads (`refCount`). + const fakeResource = { _texturesInSpineAtlas: [] as Texture2D[], refCount: 1 }; + + // @ts-ignore + SpineResource.prototype._associationTextureInSkeletonData.call(fakeResource, skeletonData); + + expect(fakeResource._texturesInSpineAtlas).to.deep.equal([texture2D]); + // Regression guard: region.texture is already the SpineTexture, so reading `.texture` off it again + // (instead of `.getImage()`) resolved to `undefined` on the 4.2 backend, and the association above + // silently never happened - leaving the texture unprotected from a GC pass while still in use. + expect(texture2D.destroy(false, true)).to.equal(false); + }); +}); diff --git a/tests/src/spine/SpineRuntimeRegistry.test.ts b/tests/src/spine/SpineRuntimeRegistry.test.ts new file mode 100644 index 0000000000..d9234a9b8f --- /dev/null +++ b/tests/src/spine/SpineRuntimeRegistry.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { registerSpineRuntime, getSpineRuntime } from "../../../packages/spine/src/runtime/SpineRuntimeRegistry"; +import type { ISpineRuntime } from "../../../packages/spine/src/runtime/ISpineRuntime"; + +describe("SpineRuntimeRegistry", () => { + // Must run first: the module singleton starts null and there is no reset. + it("throws when no runtime is registered", () => { + expect(() => getSpineRuntime()).to.throw(/no spine runtime registered/); + }); + + it("returns the registered runtime", () => { + const runtime = {} as ISpineRuntime; + registerSpineRuntime(runtime); + expect(getSpineRuntime()).to.equal(runtime); + }); + + it("last registration wins", () => { + const a = {} as ISpineRuntime; + const b = {} as ISpineRuntime; + registerSpineRuntime(a); + registerSpineRuntime(b); + expect(getSpineRuntime()).to.equal(b); + }); +}); diff --git a/tests/src/spine/SpineTexture.test.ts b/tests/src/spine/SpineTexture.test.ts new file mode 100644 index 0000000000..ac6b76e5f6 --- /dev/null +++ b/tests/src/spine/SpineTexture.test.ts @@ -0,0 +1,44 @@ +import { describe, beforeAll, expect, it } from "vitest"; +import { Texture2D, TextureFilterMode, WebGLEngine } from "@galacean/engine"; +import { TextureFilter as TextureFilter42 } from "@esotericsoftware/spine-core"; +import { SpineTexture as SpineTexture42 } from "../../../packages/spine-core-4.2/src/SpineTexture"; +import { SpineTexture as SpineTexture38 } from "../../../packages/spine-core-3.8/src/SpineTexture"; +import { TextureFilter as TextureFilter38 } from "../../../packages/spine-core-3.8/src/spine-core/Texture"; + +describe("SpineTexture.setFilters", () => { + let engine: WebGLEngine; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + }); + + it("maps atlas min filters to engine filter modes (4.2)", () => { + const texture = new Texture2D(engine, 4, 4); + const spineTexture = new SpineTexture42(texture); + + spineTexture.setFilters(TextureFilter42.Nearest, TextureFilter42.Nearest); + expect(texture.filterMode).to.equal(TextureFilterMode.Point); + + spineTexture.setFilters(TextureFilter42.Linear, TextureFilter42.Linear); + expect(texture.filterMode).to.equal(TextureFilterMode.Bilinear); + + // Atlases pair a MipMap* min filter with a plain Linear mag filter — the min filter + // alone decides whether mipmap sampling (Trilinear) is requested. + spineTexture.setFilters(TextureFilter42.MipMapLinearLinear, TextureFilter42.Linear); + expect(texture.filterMode).to.equal(TextureFilterMode.Trilinear); + }); + + it("maps atlas min filters to engine filter modes (3.8)", () => { + const texture = new Texture2D(engine, 4, 4); + const spineTexture = new SpineTexture38(texture); + + spineTexture.setFilters(TextureFilter38.Nearest, TextureFilter38.Nearest); + expect(texture.filterMode).to.equal(TextureFilterMode.Point); + + spineTexture.setFilters(TextureFilter38.Linear, TextureFilter38.Linear); + expect(texture.filterMode).to.equal(TextureFilterMode.Bilinear); + + spineTexture.setFilters(TextureFilter38.MipMapLinearLinear, TextureFilter38.Linear); + expect(texture.filterMode).to.equal(TextureFilterMode.Trilinear); + }); +}); diff --git a/tests/src/ui/Image.test.ts b/tests/src/ui/Image.test.ts index eaa6bd026a..1f409fe447 100644 --- a/tests/src/ui/Image.test.ts +++ b/tests/src/ui/Image.test.ts @@ -68,4 +68,27 @@ describe("Image", async () => { expect(cloneImage.raycastPadding.z).to.eq(1); expect(cloneImage.raycastPadding.w).to.eq(1); }); + + it("sprite refCount: clone acquires, setter churn transfers, destroy releases", () => { + const entity = canvasEntity.createChild("imageRefSlot"); + const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1)); + const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1)); + entity.addComponent(Image).sprite = spriteA; + expect(spriteA.refCount).to.eq(1); + + const clone = entity.clone(); + canvasEntity.addChild(clone); + expect(spriteA.refCount).to.eq(2); + + clone.getComponent(Image).sprite = spriteB; + expect(spriteA.refCount).to.eq(1); + expect(spriteB.refCount).to.eq(1); + + clone.destroy(); + expect(spriteB.refCount).to.eq(0); + expect(spriteA.refCount).to.eq(1); + + entity.destroy(); + expect(spriteA.refCount).to.eq(0); + }); }); diff --git a/tests/src/ui/Mask.test.ts b/tests/src/ui/Mask.test.ts new file mode 100644 index 0000000000..747a9309d8 --- /dev/null +++ b/tests/src/ui/Mask.test.ts @@ -0,0 +1,75 @@ +import { Sprite, SpriteMaskInteraction, SpriteMaskLayer, Texture2D } from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { CanvasRenderMode, Image, Mask, UICanvas, UITransform } from "@galacean/engine-ui"; +import { describe, expect, it } from "vitest"; + +describe("Mask", async () => { + const canvas = document.createElement("canvas"); + const engine = await WebGLEngine.create({ canvas }); + const webCanvas = engine.canvas; + webCanvas.setResolution(300, 300); + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 50; + rootCanvas.referenceResolution.set(300, 300); + + const imageEntity = canvasEntity.createChild("image"); + const image = imageEntity.addComponent(Image); + (imageEntity.transform).size.set(300, 300); + + const maskEntity = canvasEntity.createChild("mask"); + const mask = maskEntity.addComponent(Mask); + (maskEntity.transform).size.set(100, 100); + mask.sprite = createSolidSprite(engine); + + it("Set and Get sprite", () => { + const texture = new Texture2D(engine, 1, 1); + const sprite = new Sprite(engine, texture); + mask.sprite = sprite; + expect(mask.sprite).to.eq(sprite); + + mask.sprite = null; + expect(mask.sprite).to.eq(null); + + mask.sprite = createSolidSprite(engine); + expect(mask.sprite).not.to.eq(null); + }); + + it("Set and Get alphaCutoff", () => { + expect(mask.alphaCutoff).to.eq(0.5); + mask.alphaCutoff = 0.2; + expect(mask.alphaCutoff).to.eq(0.2); + }); + + it("Set and Get influenceLayers", () => { + expect(mask.influenceLayers).to.eq(SpriteMaskLayer.Everything); + mask.influenceLayers = SpriteMaskLayer.Layer1; + expect(mask.influenceLayers).to.eq(SpriteMaskLayer.Layer1); + mask.influenceLayers = SpriteMaskLayer.Everything; + }); + + it("Set and Get flipX/flipY", () => { + mask.flipX = true; + expect(mask.flipX).to.eq(true); + mask.flipY = true; + expect(mask.flipY).to.eq(true); + mask.flipX = false; + mask.flipY = false; + }); + + it("UI image maskInteraction default is None", () => { + expect(image.maskInteraction).to.eq(SpriteMaskInteraction.None); + image.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + expect(image.maskInteraction).to.eq(SpriteMaskInteraction.VisibleInsideMask); + }); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/tests/src/ui/RectMask2D.test.ts b/tests/src/ui/RectMask2D.test.ts new file mode 100644 index 0000000000..872d4b56b3 --- /dev/null +++ b/tests/src/ui/RectMask2D.test.ts @@ -0,0 +1,78 @@ +import { Vector2, Vector3, Vector4 } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { CanvasRenderMode, RectMask2D, UICanvas, UITransform } from "@galacean/engine-ui"; +import { describe, expect, it } from "vitest"; + +describe("RectMask2D", async () => { + const canvas = document.createElement("canvas"); + const engine = await WebGLEngine.create({ canvas }); + const webCanvas = engine.canvas; + webCanvas.setResolution(300, 300); + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 1; + rootCanvas.referenceResolution.set(300, 300); + + it("should return false when mask size is zero", () => { + const maskEntity = canvasEntity.createChild("mask-zero"); + const rectMask = maskEntity.addComponent(RectMask2D); + const transform = maskEntity.transform as UITransform; + transform.size.set(0, 100); + const worldRect = new Vector4(); + + expect(rectMask._getWorldRect(worldRect)).to.eq(false); + }); + + it("should clamp negative softness values", () => { + const rectMask = canvasEntity.createChild("mask-softness").addComponent(RectMask2D); + + rectMask.softness.set(-4, 6); + expect(rectMask.softness.x).to.eq(0); + expect(rectMask.softness.y).to.eq(6); + + rectMask.softness = new Vector2(5, -3); + expect(rectMask.softness.x).to.eq(5); + expect(rectMask.softness.y).to.eq(0); + }); + + it("should toggle alphaClip", () => { + const rectMask = canvasEntity.createChild("mask-alphaclip").addComponent(RectMask2D); + expect(rectMask.alphaClip).to.eq(false); + rectMask.alphaClip = true; + expect(rectMask.alphaClip).to.eq(true); + }); + + it("should compute world rect when size and pivot set", () => { + const maskEntity = canvasEntity.createChild("mask-rect"); + const transform = maskEntity.transform as UITransform; + transform.pivot.set(0.5, 0.5); + transform.size.set(100, 80); + transform.setPosition(0, 0, 0); + const rectMask = maskEntity.addComponent(RectMask2D); + const worldRect = new Vector4(); + expect(rectMask._getWorldRect(worldRect)).to.eq(true); + // The canvas applies adaptation; just verify width/height match + expect(worldRect.z - worldRect.x).to.be.closeTo(100, 1); + expect(worldRect.w - worldRect.y).to.be.closeTo(80, 1); + }); + + it("should test contains world point", () => { + const maskEntity = canvasEntity.createChild("mask-contains"); + const transform = maskEntity.transform as UITransform; + transform.pivot.set(0.5, 0.5); + transform.size.set(100, 100); + transform.setPosition(0, 0, 0); + const rectMask = maskEntity.addComponent(RectMask2D); + + const rect = new Vector4(); + rectMask._getWorldRect(rect); + const cx = (rect.x + rect.z) * 0.5; + const cy = (rect.y + rect.w) * 0.5; + expect(rectMask._containsWorldPoint(new Vector3(cx, cy, 0))).to.eq(true); + expect(rectMask._containsWorldPoint(new Vector3(rect.x - 5, rect.y - 5, 0))).to.eq(false); + }); +}); diff --git a/tests/src/ui/UICanvas.test.ts b/tests/src/ui/UICanvas.test.ts index 5e534763c8..9889792a9f 100644 --- a/tests/src/ui/UICanvas.test.ts +++ b/tests/src/ui/UICanvas.test.ts @@ -1,7 +1,7 @@ import { Camera } from "@galacean/engine-core"; import { Vector2 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { CanvasRenderMode, ResolutionAdaptationMode, UICanvas, UITransform } from "@galacean/engine-ui"; +import { CanvasRenderMode, Image, ResolutionAdaptationMode, UICanvas, UITransform } from "@galacean/engine-ui"; import { describe, expect, it } from "vitest"; describe("UICanvas", async () => { @@ -98,6 +98,21 @@ describe("UICanvas", async () => { expect(rootCanvas._isRootCanvas).to.eq(true); }); + it("clearChildren invalidates the root canvas renderer cache", () => { + const container = canvasEntity.createChild("clear-container"); + const image = container.createChild("image").addComponent(Image); + + // Populate the ordered renderer cache before removing the subtree. + // @ts-ignore + expect(rootCanvas._getRenderers()).toContain(image); + + container.clearChildren(); + + // @ts-ignore + expect(rootCanvas._getRenderers()).not.toContain(image); + container.destroy(); + }); + // Pose it("Pose Fit", () => { const canvasTransform = canvasEntity.transform; diff --git a/tests/src/ui/UIEvent.test.ts b/tests/src/ui/UIEvent.test.ts index cbc87ba359..81056a705a 100644 --- a/tests/src/ui/UIEvent.test.ts +++ b/tests/src/ui/UIEvent.test.ts @@ -1,4 +1,4 @@ -import { PointerEventData, Script } from "@galacean/engine-core"; +import { Entity, PointerEventData, Script } from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; import { CanvasRenderMode, Image, UICanvas, UITransform } from "@galacean/engine-ui"; import { describe, expect, it } from "vitest"; @@ -34,6 +34,8 @@ describe("UIEvent", async () => { endDragCount = 0; upCount = 0; dropCount = 0; + downTarget: Entity = null; + downCurrentTarget: Entity = null; onPointerEnter(eventData: PointerEventData): void { ++this.enterCount; } @@ -44,6 +46,8 @@ describe("UIEvent", async () => { onPointerDown(eventData: PointerEventData): void { ++this.downCount; + this.downTarget = eventData.target; + this.downCurrentTarget = eventData.currentTarget; } onPointerClick(eventData: PointerEventData): void { @@ -73,17 +77,20 @@ describe("UIEvent", async () => { // Add Image const imageEntity1 = canvasEntity.createChild("Image1"); - imageEntity1.addComponent(Image); + const image1 = imageEntity1.addComponent(Image); + image1.raycastEnabled = true; (imageEntity1.transform).size.set(300, 300); const script1 = imageEntity1.addComponent(TestScript); const imageEntity2 = imageEntity1.createChild("Image2"); - imageEntity2.addComponent(Image); + const image2 = imageEntity2.addComponent(Image); + image2.raycastEnabled = true; (imageEntity2.transform).size.set(200, 200); const script2 = imageEntity2.addComponent(TestScript); const imageEntity3 = imageEntity2.createChild("Image3"); const image3 = imageEntity3.addComponent(Image); + image3.raycastEnabled = true; (imageEntity3.transform).size.set(100, 100); const script3 = imageEntity3.addComponent(TestScript); @@ -289,6 +296,42 @@ describe("UIEvent", async () => { expect(script3.upCount).toBe(0); expect(script3.dropCount).toBe(0); }); + + it("ui event target and currentTarget", () => { + // @ts-ignore + const { _pointerManager: pointerManager } = inputManager; + const { _target: target } = pointerManager; + const { left, top } = target.getBoundingClientRect(); + + // Re-enable the deepest image (disabled by the previous test) and release the leftover pointer. + image3.enabled = true; + target.dispatchEvent(generatePointerEvent("pointerleave", 2, left + 8, top + 8, -1, 0)); + engine.update(); + + // Reset the records touched by the previous test. + script1.downCount = script2.downCount = script3.downCount = 0; + script1.downTarget = script2.downTarget = script3.downTarget = null; + script1.downCurrentTarget = script2.downCurrentTarget = script3.downCurrentTarget = null; + + // Press at the center so the deepest image (Image3) is hit; the event bubbles Image3 -> Image2 -> Image1. + target.dispatchEvent(generatePointerEvent("pointerdown", 3, left + 8, top + 8)); + engine.update(); + + // Down bubbles through all three nested images. + expect(script1.downCount).toBe(1); + expect(script2.downCount).toBe(1); + expect(script3.downCount).toBe(1); + + // `target` stays the deepest hit entity (Image3) for every handler on the bubble path. + expect(script1.downTarget).toBe(imageEntity3); + expect(script2.downTarget).toBe(imageEntity3); + expect(script3.downTarget).toBe(imageEntity3); + + // `currentTarget` is the entity actually handling the event at each bubble step. + expect(script1.downCurrentTarget).toBe(imageEntity1); + expect(script2.downCurrentTarget).toBe(imageEntity2); + expect(script3.downCurrentTarget).toBe(imageEntity3); + }); }); function generatePointerEvent( diff --git a/tests/src/ui/UIInteractive.test.ts b/tests/src/ui/UIInteractive.test.ts index b44f7a50ec..d1cfd264e9 100644 --- a/tests/src/ui/UIInteractive.test.ts +++ b/tests/src/ui/UIInteractive.test.ts @@ -1,4 +1,4 @@ -import { Camera, PointerEventData, Script, SpriteDrawMode } from "@galacean/engine-core"; +import { Camera, PointerEventData, Script, Sprite, SpriteDrawMode, Texture2D } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { @@ -6,6 +6,7 @@ import { ColorTransition, Image, ScaleTransition, + SpriteTransition, Text, UICanvas, UIGroup, @@ -22,6 +23,7 @@ class ClickHandler extends Script { } handleClickWithPrefix(event: PointerEventData, prefix: string) { + void event; this.callCount++; this.lastPrefix = prefix; } @@ -250,4 +252,98 @@ describe("Button", async () => { testEntity.destroy(); }); + + it("cloned interactive gets independent deep-cloned transitions with remapped targets", () => { + const testEntity = canvasEntity.createChild("transitionClone"); + const testImage = testEntity.addComponent(Image); + (testEntity.transform).size.set(100, 40); + const testButton = testEntity.addComponent(Button); + + const transition = new ColorTransition(); + transition.target = testImage; + transition.normal = new Color(1, 0, 0, 1); + transition.hover = new Color(0, 1, 0, 1); + testButton.addTransition(transition); + + const cloneEntity = testEntity.clone(); + canvasEntity.addChild(cloneEntity); + const cloneButton = cloneEntity.getComponent(Button); + const cloneImage = cloneEntity.getComponent(Image); + + expect(cloneButton.transitions.length).to.eq(1); + const cloneTransition = cloneButton.transitions[0] as ColorTransition; + // Independent instance, wired to the clone's own components. + expect(cloneTransition).not.to.eq(transition); + expect(cloneTransition.target).to.eq(cloneImage); + // @ts-ignore + expect(cloneTransition._interactive).to.eq(cloneButton); + // State values deep cloned. + expect(cloneTransition.normal).not.to.eq(transition.normal); + expect(cloneTransition.normal.r).to.eq(1); + expect(cloneTransition.hover.g).to.eq(1); + + // Destroying the clone must not strip the source button's transitions. + cloneEntity.destroy(); + expect(testButton.transitions.length).to.eq(1); + expect(transition.target).to.eq(testImage); + + testEntity.destroy(); + }); + + it("cloned sprite transition keeps shared sprites' refCount balanced", () => { + const testEntity = canvasEntity.createChild("spriteTransitionClone"); + const testImage = testEntity.addComponent(Image); + const testButton = testEntity.addComponent(Button); + + const sprite = new Sprite(engine, new Texture2D(engine, 1, 1)); + const transition = new SpriteTransition(); + transition.target = testImage; + transition.normal = sprite; + testButton.addTransition(transition); + const baseline = sprite.refCount; + + const cloneEntity = testEntity.clone(); + canvasEntity.addChild(cloneEntity); + // +1 by the cloned transition (acquired in _cloneTo), +1 by the cloned Image + // (transition._applyValue assigns the sprite through the Image.sprite setter on activation). + expect(sprite.refCount).to.eq(baseline + 2); + + cloneEntity.destroy(); + expect(sprite.refCount).to.eq(baseline); + + testEntity.destroy(); + expect(sprite.refCount).to.eq(baseline - 2); + }); + + it("cloned sprite transition setter churn transfers sprite counts", () => { + const testEntity = canvasEntity.createChild("spriteTransitionChurn"); + const testImage = testEntity.addComponent(Image); + const testButton = testEntity.addComponent(Button); + + const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1)); + const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1)); + const transition = new SpriteTransition(); + transition.target = testImage; + transition.normal = spriteA; + testButton.addTransition(transition); + const baselineA = spriteA.refCount; + + const cloneEntity = testEntity.clone(); + canvasEntity.addChild(cloneEntity); + // +1 cloned transition (_cloneTo) + 1 cloned Image (applied through its sprite setter). + expect(spriteA.refCount).to.eq(baselineA + 2); + + const cloneTransition = cloneEntity.getComponent(Button).transitions[0] as SpriteTransition; + cloneTransition.normal = spriteB; + // Churn releases A from the cloned transition AND from the cloned Image (re-applied), B gains both. + expect(spriteA.refCount).to.eq(baselineA); + expect(spriteB.refCount).to.eq(2); + + cloneEntity.destroy(); + expect(spriteB.refCount).to.eq(0); + expect(spriteA.refCount).to.eq(baselineA); + + testEntity.destroy(); + expect(spriteA.refCount).to.eq(baselineA - 2); + }); }); diff --git a/tests/src/ui/UITransform.test.ts b/tests/src/ui/UITransform.test.ts index 534f0f3927..84ab5487d1 100644 --- a/tests/src/ui/UITransform.test.ts +++ b/tests/src/ui/UITransform.test.ts @@ -1,5 +1,5 @@ -import { WebGLEngine } from "@galacean/engine"; -import { HorizontalAlignmentMode, UICanvas, UITransform, VerticalAlignmentMode } from "@galacean/engine-ui"; +import { Entity, MeshRenderer, WebGLEngine } from "@galacean/engine"; +import { HorizontalAlignmentMode, Image, UICanvas, UITransform, VerticalAlignmentMode } from "@galacean/engine-ui"; import { describe, expect, it } from "vitest"; describe("UITransform", async () => { @@ -404,6 +404,23 @@ describe("UITransform", async () => { }); describe("clone", () => { + it("keeps component mapping after a renderer replaces Transform with UITransform", () => { + const original = new Entity(engine, "clone-transform-replacement"); + original.addComponent(MeshRenderer); + original.addComponent(Image); + + expect(original.transform).to.be.instanceOf(UITransform); + expect(original._components[0]).to.equal(original.transform); + + const cloned = original.clone(); + expect(cloned.transform).to.be.instanceOf(UITransform); + expect(cloned.getComponent(MeshRenderer)).not.to.equal(null); + expect(cloned.getComponent(Image)).not.to.equal(null); + expect(cloned._components.map((component) => component.constructor)).to.deep.equal( + original._components.map((component) => component.constructor) + ); + }); + it("clones basic properties correctly", () => { const parent = root.createChild("clone-parent"); parent.addComponent(UICanvas);