diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aff82a1..0277eb8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,5 +2,6 @@ version: 2 updates: - package-ecosystem: "npm" directory: "/" + target-branch: "dev" schedule: interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3b042..92e7b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [14.1.0] - 2024-08-14 + +### Highlights +- Added a `find` method to FXR objects, which finds and returns a value at a given path in the FXR. For example, a path like `root/nodes/0` would be the first child node of the root node. +- Added a `getActiveEffect` method to nodes that contain effects, which returns the effect that would be active when a given state index is active. +- Added a `clamp` method to properties. This slices off peaks and troughs outside of a given range to make sure the property value stays within the range. +- Added a `minify` method to modifiers. This does nothing for most of the modifier types, but for the external value modifiers this will minify the `factor` property. +- The `minify` method on properties now also minify the modifiers. +- The `minify` method on Stepped and Linear properties now removes keyframes that aren't doing anything. For example, if you have a linear scalar property with these keyframe values: `0, 1, 1, 1, 0`, then the middle keyframe is not doing anything, because it has two equivalent ones surrounding it, so when minified, this property would end up having these keyframe values: `0, 1, 1, 0`. + ## [14.0.1] - 2024-08-11 ### Highlights @@ -141,6 +151,7 @@ - External values 2000 and 70200 for AC6 have been documented thanks to lugia19. - Fixed action 301 (EqualDistanceEmitter) missing a type for one of its fields, potentially causing issues when writing to DS3's structure. +[14.1.0]: https://github.com/EvenTorset/fxr/compare/v14.0.1...v14.1.0 [14.0.1]: https://github.com/EvenTorset/fxr/compare/v14.0.0...v14.0.1 [14.0.0]: https://github.com/EvenTorset/fxr/compare/v13.0.0...v14.0.0 [13.0.0]: https://github.com/EvenTorset/fxr/compare/v12.2.0...v13.0.0 diff --git a/package-lock.json b/package-lock.json index ff99aa2..0038379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cccode/fxr", - "version": "14.0.1", + "version": "14.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cccode/fxr", - "version": "14.0.1", + "version": "14.1.0", "license": "Unlicense", "devDependencies": { "@types/node": "^22.0.0", @@ -24,9 +24,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", - "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", + "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==", "dev": true, "dependencies": { "undici-types": "~6.13.0" diff --git a/package.json b/package.json index 784868c..cd6c52d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cccode/fxr", - "version": "14.0.1", + "version": "14.1.0", "description": "JavaScript library for creating and editing FXR files for Dark Souls 3, Sekiro, Elden Ring, and Armored Core 6.", "author": "CCCode", "type": "module", diff --git a/src/fxr.ts b/src/fxr.ts index 09e6d5f..84a0f39 100644 --- a/src/fxr.ts +++ b/src/fxr.ts @@ -1739,6 +1739,7 @@ export interface IModifier { toJSON(): any clone(): IModifier separateComponents(): IModifier[] + minify(): IModifier } export interface ActionMeta { @@ -6816,8 +6817,8 @@ function uniqueArray(a: T[]) { return Array.from(new Set(a)) } -function lerp(a: number, b: number, c: number) { - return a + (b - a) * c +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t } function interpolateSegments(arr: number[], targetS: number, maxSegments: number): number[] { @@ -7800,6 +7801,146 @@ function scalePropMods(prop: Property, } } +function clampPropValue(v: T, min: T, max: T): T { + if (Array.isArray(v) && Array.isArray(min) && Array.isArray(max)) { + return v.map((e, i) => Math.max(min[i], Math.min(max[i], e))) as T + } else if (!Array.isArray(v) && !Array.isArray(min) && !Array.isArray(max)) { + return Math.max(min, Math.min(max, v)) as T + } else { + throw new Error('Invalid property type inputs.') + } +} + +function lerpPropValue(v1: T, v2: T, t: number): T { + if (isVector(v1) && isVector(v2)) { + return v1.map((e, i) => lerp(e, v2[i], t)) as T + } else if (typeof v1 === 'number' && typeof v2 === 'number') { + return lerp(v1, v2, t) as T + } else { + throw new Error('') + } +} + +function findIntersections( + keyframe1: Keyframe, + keyframe2: Keyframe, + minValue: TypeMap.PropertyValue[T], + maxValue: TypeMap.PropertyValue[T] +): Keyframe[] { + const { position: x1, value: y1 } = keyframe1 + const { position: x2, value: y2 } = keyframe2 + + if (y1 === y2) { + return [] + } + + function interpolate(y1: number, y2: number, minValue: number, maxValue: number) { + const slope = (y2 - y1) / (x2 - x1) + const positions: number[] = [] + + const tMin = (minValue - y1) / slope + if (tMin > 0 && tMin < (x2 - x1)) { + const xMin = x1 + tMin + if (xMin >= x1 && xMin <= x2) { + positions.push(xMin) + } + } + + const tMax = (maxValue - y1) / slope + if (tMax > 0 && tMax < (x2 - x1)) { + const xMax = x1 + tMax + if (xMax >= x1 && xMax <= x2) { + positions.push(xMax) + } + } + + return positions + } + + const results: Keyframe[] = [] + if (typeof y1 === 'number' && typeof y2 === 'number') { + const positions = interpolate(y1, y2, minValue as number, maxValue as number) + results.push(...positions.map(x => new Keyframe(x, clampPropValue(lerpPropValue(y1, y2, (x - x1) / (x2 - x1)), minValue, maxValue)))) + } else if (Array.isArray(y1) && Array.isArray(y2)) { + const comps = y1.length + for (let c = 0; c < comps; c++) { + const positions = interpolate(y1[c], y2[c], minValue[c], maxValue[c]) + results.push(...positions.map(x => new Keyframe(x, clampPropValue(lerpPropValue(y1, y2, (x - x1) / (x2 - x1)), minValue, maxValue)))) + } + } + return results +} + +function clampKeyframes( + keyframes: Keyframe[], + min: TypeMap.PropertyValue[T], + max: TypeMap.PropertyValue[T] +): Keyframe[] { + const clampedKeyframes: Keyframe[] = [] + for (let i = 0; i < keyframes.length - 1; i++) { + const kf1 = keyframes[i] + clampedKeyframes.push( + new Keyframe(kf1.position, clampPropValue(kf1.value, min, max)), + ...findIntersections(kf1, keyframes[i + 1], min, max) + ) + } + const lastKeyframe = keyframes[keyframes.length - 1] + clampedKeyframes.push(new Keyframe(lastKeyframe.position, clampPropValue(lastKeyframe.value, min, max))) + return clampedKeyframes +} + +function clampProp( + prop: Property, + min: TypeMap.PropertyValue[T], + max: TypeMap.PropertyValue[T] +): Property { + let clone = prop.clone().minify() + if (clone instanceof ValueProperty) { + clone.value = clampPropValue(clone.value, min, max) + } else if (clone instanceof SequenceProperty || clone instanceof ComponentSequenceProperty) { + if (clone instanceof ComponentSequenceProperty) { + clone = clone.combineComponents() + } + let seq = clone as SequenceProperty + if (seq.function !== PropertyFunction.Stepped && seq.function !== PropertyFunction.Linear) { + const posSet = new Set() + for (const keyframe of seq.keyframes) { + posSet.add(keyframe.position) + } + const positions = filterMillisecondDiffs(posSet).sort((a, b) => a - b) + seq = new LinearProperty( + seq.loop, + filterMillisecondDiffs(interpolateSegments(positions, 0.1, 40)) + .map(e => new Keyframe(e, seq.valueAt(e))) + ) + } + if (seq.function === PropertyFunction.Stepped) { + for (const kf of seq.keyframes) { + kf.value = clampPropValue(kf.value, min, max) + } + } else { + seq.keyframes = clampKeyframes(seq.keyframes, min, max) + } + seq.sortKeyframes() + clone = seq + } + return clone.minify() +} + +const FLOAT32_EPSILON = 2 ** -23 +function f32Equal(a: number, b: number) { + return Math.abs(a - b) <= FLOAT32_EPSILON +} + +function propValueEqual(a: PropertyValue, b: PropertyValue) { + if (isVector(a) && isVector(b)) { + return a.every((e, i) => f32Equal(e, b[i])) + } else if (!isVector(a) && !isVector(b)) { + return f32Equal(a, b) + } + return false +} + const ActionDataConversion = { [ActionType.StaticNodeTransform]: { read(props: StaticNodeTransformParams, game: Game) { @@ -9120,6 +9261,36 @@ class FXR { return `f${this.id.toString().padStart(9, '0')}.fxr` } + /** + * Finds and returns a value at a given path. If the path does not match + * anything, this returns `null`. + * + * For example, to get the appearance action in the second effect in the + * first child node of the root node, you would use this path: + * ```js + * fxr.find(['root', 'nodes', 0, 'effects', 1, 'appearance']) + * // Or in string form: + * fxr.find('root/nodes/0/effects/1/appearance') + * // Both are equivalent to this: + * fxr.root.nodes[0].effects[1].appearance + * ``` + * @param path The path to the value to look for. + */ + find(path: (string | number)[] | string): any { + if (typeof path === 'string') { + path = path.replace(/^[\/\s]+|[\/\s]+$/g, '').split('/') + } + let current: any = this + for (const key of path) { + if (current && current[key] !== undefined) { + current = current[key] + } else { + return null + } + } + return current + } + } //#region State @@ -10244,6 +10415,16 @@ abstract class NodeWithEffects extends Node { return this } + /** + * Returns the effect that is active when a given {@link State state} index + * is active. If no effects are active for the state, this returns `null` + * instead. + * @param stateIndex The index of a {@link FXR.states state in the FXR}. + */ + getActiveEffect(stateIndex: number): IEffect | null { + return this.effects[this.stateEffectMap[stateIndex] ?? this.stateEffectMap[0]] ?? null + } + } /** @@ -38883,17 +39064,9 @@ class Keyframe implements IBasicKeyframe { } static equal | IBezierKeyframe>(kf1: K, kf2: K) { - return ( - Array.isArray(kf1.value) && kf1.value.every((e, i) => e === kf2.value[i]) || - kf1.value === kf2.value - ) && ( - !('p1' in kf1 && 'p1' in kf2) || ( - Array.isArray(kf1.p1) && kf1.p1.every((e, i) => e === 0 && kf2.p1[i] === 0) || - kf1.p1 === 0 && kf2.p1 === 0 - ) && ( - Array.isArray(kf1.p2) && kf1.p2.every((e, i) => e === 0 && kf2.p2[i] === 0) || - kf1.p2 === 0 && kf2.p2 === 0 - ) + return propValueEqual(kf1.value, kf2.value) && ( + !('p1' in kf1 && 'p1' in kf2) || + propValueEqual(kf1.p1, kf2.p1) && propValueEqual(kf1.p2, kf2.p2) ) } @@ -39005,6 +39178,10 @@ abstract class Property impleme return this } + clamp(min: TypeMap.PropertyValue[T], max: TypeMap.PropertyValue[T]): Property { + return clampProp(this, min, max) + } + abstract fieldCount: number abstract fields: NumericalField[] abstract toJSON(): any @@ -39159,7 +39336,7 @@ class ValueProperty minify(): ValueProperty { const clone = this.clone() - clone.modifiers = clone.modifiers.filter(Modifier.isEffective) + clone.modifiers = clone.modifiers.map(mod => mod.minify()).filter(Modifier.isEffective) return clone } @@ -39482,19 +39659,26 @@ class SequenceProperty } minify(): Property { + const mods = this.modifiers.map(mod => mod.minify()).filter(Modifier.isEffective) if (this.keyframes.length === 1 || this.keyframes.slice(1).every(kf => Keyframe.equal(this.keyframes[0], kf))) { if (this.valueType === ValueType.Scalar) { - return new ConstantProperty(this.keyframes[0].value as number).withModifiers( - ...this.modifiers.filter(Modifier.isEffective) - ) + return new ConstantProperty(this.keyframes[0].value as number).withModifiers(...mods) } else { - return new ConstantProperty(...(this.keyframes[0].value as Vector)).withModifiers( - ...this.modifiers.filter(Modifier.isEffective) - ) + return new ConstantProperty(...(this.keyframes[0].value as Vector)).withModifiers(...mods) } } const clone = this.clone() - clone.modifiers = clone.modifiers.filter(Modifier.isEffective) + clone.modifiers = mods + if (clone.function === PropertyFunction.Stepped) { + clone.keyframes = clone.keyframes.filter((e, i, a) => i === 0 || i === a.length - 1 || !propValueEqual(a[i-1].value, e.value)) + } else if (clone.function === PropertyFunction.Linear) { + clone.keyframes = clone.keyframes.filter((e, i, a) => + i === 0 || + i === a.length - 1 || + !propValueEqual(a[i-1].value, e.value) || + !propValueEqual(e.value, a[i+1].value) + ) + } return clone } @@ -39739,12 +39923,12 @@ class ComponentSequenceProperty return new ConstantProperty( ...this.components.map(c => c.keyframes[0].value) ).withModifiers( - ...this.modifiers.filter(Modifier.isEffective) + ...this.modifiers.map(mod => mod.minify()).filter(Modifier.isEffective) ) } if (this.canBeSimplified()) return this.combineComponents().minify() const clone = this.clone() - clone.modifiers = clone.modifiers.filter(Modifier.isEffective) + clone.modifiers = clone.modifiers.map(mod => mod.minify()).filter(Modifier.isEffective) return clone } @@ -40252,6 +40436,10 @@ class GenericModifier implements IModifier { throw new Error('Generic modifiers cannot be split into component modifiers.') } + minify(): GenericModifier { + return this + } + } /** @@ -40329,6 +40517,10 @@ class RandomDeltaModifier implements IModifier { } } + minify(): RandomDeltaModifier { + return this + } + } /** @@ -40408,6 +40600,10 @@ class RandomRangeModifier implements IModifier { } } + minify(): RandomRangeModifier { + return this + } + } /** @@ -40484,6 +40680,10 @@ class RandomFractionModifier implements IModifier { return (this.max as Vector).map((e, i) => new RandomFractionModifier(e, (this.seed as Vector)[i])) } + minify(): RandomFractionModifier { + return this + } + } /** @@ -40544,6 +40744,12 @@ class ExternalValue1Modifier implements IModifier { return this.factor.separateComponents().map(e => new ExternalValue1Modifier(this.externalValue, e)) } + minify(): ExternalValue1Modifier { + const clone = this.clone() + clone.factor = clone.factor.minify() as TypeMap.Property[T] + return clone + } + } class ExternalValue2Modifier implements IModifier { @@ -40600,6 +40806,12 @@ class ExternalValue2Modifier implements IModifier { return this.factor.separateComponents().map(e => new ExternalValue2Modifier(this.externalValue, e)) } + minify(): ExternalValue2Modifier { + const clone = this.clone() + clone.factor = clone.factor.minify() as TypeMap.Property[T] + return clone + } + } /** @@ -41652,10 +41864,19 @@ namespace FXRUtility { return rad * 180 / Math.PI } - export function transform(center: Vector3, axis: Vector3, roll: number, nodes: Node[]) { + /** + * Wraps the given {@link nodes} in a node or set of nodes that have a + * transform applied to them. This can be useful to make the nodes point in a + * specific direction. + * @param offset Translation offset. + * @param axis The direction to point towards. + * @param roll The roll angle in degrees. + * @param nodes The nodes to transform. + */ + export function transform(offset: Vector3, axis: Vector3, roll: number, nodes: Node[]) { if (axis[0] === 0 && axis[1] === 0 && axis[2] === 0 && roll === 0) { return new BasicNode([ - NodeTransform({ offset: center }) + NodeTransform({ offset }) ], nodes) } axis = normalizeVector3(axis) @@ -41663,7 +41884,7 @@ namespace FXRUtility { const pitch = -Math.asin(axis[0]) * 180 / Math.PI return new BasicNode([ NodeTransform({ - offset: center, + offset, rotation: [yaw, 0, 0] }) ], [