@roku-sdk/compat 0.0.0-stage → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/eslint.config.mjs +3 -0
- package/package.json +54 -3
- package/src/__tests__/refuse-gated-tags.test.ts +31 -0
- package/src/__tests__/tween-shim-source.test.ts +276 -0
- package/src/__tests__/tween.test.ts +34 -0
- package/src/index.ts +4 -0
- package/src/refuse-gated-tags.ts +18 -0
- package/src/tween/index.ts +9 -0
- package/src/tween/rsg-tween-shim.brs +618 -0
- package/src/tween/rsg-tween-shim.xml +71 -0
- package/src/tween/rsg-tween-shim.xml.d.ts +79 -0
- package/tsconfig.json +11 -0
- package/xml.d.ts +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roku, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @roku-sdk/compat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shims for SceneGraph nodes that the SDK renders but firmware does not report as supported.
|
|
4
|
+
|
|
5
|
+
The package is a side-effect import. `rk build` and `rk dev` inject it ahead of the app entry, so
|
|
6
|
+
apps do not import it themselves. Set `app.compat: false` in `roku-config.ts` to leave it out.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
Each shim registers a tag alias with `registerTagAlias` from `@roku-sdk/solid`. When the tag is
|
|
11
|
+
created, the alias asks `hasFeature("tween")` from `@roku-sdk/runtime/has-feature` whether the firmware
|
|
12
|
+
has the real node. If it does, the real node is created. If not, the shim is created instead.
|
|
13
|
+
|
|
14
|
+
| Tag | Native node | Firmware feature | Shim |
|
|
15
|
+
| --------- | ----------- | -------------------------- | -------------- |
|
|
16
|
+
| `<tween>` | `Tween` | `roku.hasFeature("tween")` | `RsgTweenShim` |
|
|
17
|
+
|
|
18
|
+
`hasFeature` returns false when `roku` or `roku.hasFeature` is absent, including Jest.
|
|
19
|
+
The firmware feature set starts empty;
|
|
20
|
+
until firmware adds `"tween"`, devices use the shim regardless of their OS version.
|
|
21
|
+
|
|
22
|
+
When `app.compat: false`, the Roku build injects only the native-or-refusal registration, not
|
|
23
|
+
the shim XML or BrightScript. Importing `@roku-sdk/rsg-ts` alone performs no registration; only
|
|
24
|
+
the Roku build injects these entries.
|
|
25
|
+
|
|
26
|
+
In a Roku build without shims, creating a gated tag when its feature is not reported throws an
|
|
27
|
+
error naming the missing firmware feature and this package. That is deliberate: a SceneGraph node type the
|
|
28
|
+
firmware does not have fails silently and leaves an empty screen.
|
|
29
|
+
|
|
30
|
+
## Retiring a shim
|
|
31
|
+
|
|
32
|
+
Removing a shim drops support for every firmware that does not report its native feature, so it is a breaking
|
|
33
|
+
change:
|
|
34
|
+
|
|
35
|
+
- Remove shims only in a major SDK release, and state which firmware features that release requires.
|
|
36
|
+
- Batch removals into one release rather than shipping one breaking release per shim.
|
|
37
|
+
- Remove the shim's directory here and its entry in `refuse-gated-tags.ts` once all supported
|
|
38
|
+
firmware reports the feature. `@roku-sdk/solid` does not change.
|
|
39
|
+
|
|
40
|
+
An app that must keep running on older firmware can stay on the last `@roku-sdk/compat` release
|
|
41
|
+
that has the shim it needs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roku-sdk/compat",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "2.18.0",
|
|
4
|
+
"description": "Shims that stand in for SceneGraph nodes older Roku OS firmware lacks",
|
|
5
|
+
"sideEffects": true,
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./refuse-gated-tags": "./src/refuse-gated-tags.ts"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"src/",
|
|
14
|
+
"eslint.config.mjs",
|
|
15
|
+
"tsconfig.json",
|
|
16
|
+
"xml.d.ts",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"npm": ">=8.5.0",
|
|
21
|
+
"node": ">=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@roku-sdk/runtime": "^2.18.0",
|
|
25
|
+
"@roku-sdk/solid": "^2.18.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@jest/globals": "^30.4.1",
|
|
29
|
+
"@types/jest": "^30.0.0",
|
|
30
|
+
"jest": "^30.4.1",
|
|
31
|
+
"jest-transform-stub": "^2.0.0",
|
|
32
|
+
"@roku-sdk/dev-tools": "2.18.0",
|
|
33
|
+
"@roku-sdk/solid": "2.18.0",
|
|
34
|
+
"@roku-sdk/interfaces": "2.18.0",
|
|
35
|
+
"@roku-sdk/runtime": "2.18.0"
|
|
36
|
+
},
|
|
37
|
+
"author": "Roku Inc",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://gitlab.com/rokudev/rsg-sdk.git",
|
|
45
|
+
"directory": "packages/compat"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://gitlab.com/rokudev/rsg-sdk/-/tree/main/packages/compat#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://gitlab.com/rokudev/rsg-sdk/-/issues"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "jest --passWithNoTests",
|
|
53
|
+
"coverage": "jest --coverage --runInBand --passWithNoTests --testTimeout=60000 --forceExit",
|
|
54
|
+
"clean": "rm -rf dist",
|
|
55
|
+
"lint": "eslint --no-error-on-unmatched-pattern \"src/**/*.{ts,tsx,mjs,cjs,js}\""
|
|
56
|
+
}
|
|
6
57
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { beforeAll, describe, expect, jest, test } from "@jest/globals";
|
|
5
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
6
|
+
import { hasTagAlias, registerTagAlias } from "@roku-sdk/solid";
|
|
7
|
+
|
|
8
|
+
jest.mock("@roku-sdk/solid", () => ({ hasTagAlias: jest.fn(), registerTagAlias: jest.fn() }));
|
|
9
|
+
jest.mock("@roku-sdk/runtime/has-feature", () => ({ hasFeature: jest.fn() }));
|
|
10
|
+
|
|
11
|
+
let resolveTween: (() => string) | undefined;
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await import("../refuse-gated-tags");
|
|
14
|
+
resolveTween = jest.mocked(registerTagAlias).mock.calls.find(([tag]) => tag === "tween")?.[1];
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("tween refusal alias", () => {
|
|
18
|
+
test("checks for an existing alias before registering", () => {
|
|
19
|
+
expect(hasTagAlias).toHaveBeenCalledWith("tween");
|
|
20
|
+
expect(resolveTween).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
test("keeps the native node only when firmware reports tween", () => {
|
|
23
|
+
jest.mocked(hasFeature).mockReturnValue(true);
|
|
24
|
+
expect(resolveTween!()).toBe("tween");
|
|
25
|
+
expect(hasFeature).toHaveBeenCalledWith("tween");
|
|
26
|
+
});
|
|
27
|
+
test("refuses with the missing feature and compat named", () => {
|
|
28
|
+
jest.mocked(hasFeature).mockReturnValue(false);
|
|
29
|
+
expect(resolveTween!).toThrow('<tween> requires the "tween" firmware feature or @roku-sdk/compat');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// Source-contract guards, not a BrightScript execution harness. Device parity still needs
|
|
5
|
+
// the tween-parity fixture on both firmware versions; Jest cannot run SceneGraph Animation.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "@jest/globals";
|
|
10
|
+
|
|
11
|
+
const shim = readFileSync(path.join(__dirname, "../tween/rsg-tween-shim.brs"), "utf8");
|
|
12
|
+
const body = (name: string): string => {
|
|
13
|
+
const match = shim.match(new RegExp(`(?:sub|function) ${name}\\([^]*?end (?:sub|function)`, "i"));
|
|
14
|
+
expect(match).not.toBeNull();
|
|
15
|
+
return match![0];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("Tween BrightScript native-contract source guards", () => {
|
|
19
|
+
test("binds anonymous target references without requiring a caller-assigned id", () => {
|
|
20
|
+
const bind = body("bindField");
|
|
21
|
+
expect(bind).toContain('if m.top.targetNode.id = "" then');
|
|
22
|
+
expect(bind).toContain('m.top.targetNode.id = "rsg-tween-" + createObject("roDeviceInfo").GetRandomUUID()');
|
|
23
|
+
expect(bind.indexOf("GetRandomUUID()")).toBeLessThan(bind.indexOf("desiredField ="));
|
|
24
|
+
expect(body("onTargetNodeChanged")).not.toContain('if target.id = "" then return');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("uses the last-written scalar or vector trigger", () => {
|
|
28
|
+
expect(body("init")).toContain("m.useScalarTarget = true");
|
|
29
|
+
expect(body("onTargetValueChanged")).toContain("m.useScalarTarget = true");
|
|
30
|
+
expect(body("onTargetVectorChanged")).toContain("m.useScalarTarget = false");
|
|
31
|
+
expect(body("buildVectorTarget")).toContain(
|
|
32
|
+
"if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 2 then"
|
|
33
|
+
);
|
|
34
|
+
for (const name of ["fireAnimation", "snapWithoutAnim"]) {
|
|
35
|
+
expect(body(name)).toContain(
|
|
36
|
+
"if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then"
|
|
37
|
+
);
|
|
38
|
+
expect(body(name)).toContain("toVal = m.top.targetVector[0]");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reports an integer millisecond remainder without 32-bit epoch arithmetic", () => {
|
|
43
|
+
const report = body("fireAnimationStop");
|
|
44
|
+
expect(report).toContain("totalMs# = currentTime.AsSeconds() * 1000#");
|
|
45
|
+
expect(report).toContain("Int(delaySeconds * 1000#)");
|
|
46
|
+
expect(report).toContain("seconds# = Int(totalMs# / 1000#)");
|
|
47
|
+
expect(report).toContain("milliseconds = Int(totalMs# - seconds# * 1000#)");
|
|
48
|
+
expect(report).toContain("[seconds#, milliseconds]");
|
|
49
|
+
expect(
|
|
50
|
+
body("onProposedStopsChanged").match(/fireAnimationStop\(index, closestStop, newDuration\)/g)
|
|
51
|
+
).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects underfoot candidates in both moving searches and stops zero-distance settles without restarting", () => {
|
|
55
|
+
const selection = body("pickStop1D");
|
|
56
|
+
expect(selection).toContain("stopPosition > fromPos");
|
|
57
|
+
expect(selection).toContain("stopPosition < fromPos");
|
|
58
|
+
expect(selection).not.toMatch(/stopPosition [<>]= fromPos/);
|
|
59
|
+
expect(selection).toContain("closestStop = defaultTo");
|
|
60
|
+
expect(selection).toContain("index = -1");
|
|
61
|
+
// The vector search uses the same strict comparison, per component.
|
|
62
|
+
const vector = body("pickStopVector");
|
|
63
|
+
for (const c of [0, 1]) {
|
|
64
|
+
expect(vector).toContain(`not (candidate[${c}] > fromVec[${c}])`);
|
|
65
|
+
expect(vector).toContain(`not (candidate[${c}] < fromVec[${c}])`);
|
|
66
|
+
}
|
|
67
|
+
expect(vector).not.toMatch(/candidate\[\d\] [<>]= fromVec/);
|
|
68
|
+
const settle = body("onProposedStopsChanged");
|
|
69
|
+
const fastPaths = [...settle.matchAll(/if newDistance = 0 then([^]*?)end if/g)].map(match => match[1]);
|
|
70
|
+
expect(fastPaths).toHaveLength(2);
|
|
71
|
+
for (const fastPath of fastPaths) {
|
|
72
|
+
expect(fastPath).toContain("stopAtValue(");
|
|
73
|
+
expect(fastPath).toContain("fireAnimationStop(index, closestStop, 0)");
|
|
74
|
+
expect(fastPath).toContain("return");
|
|
75
|
+
expect(fastPath).not.toContain('"start"');
|
|
76
|
+
}
|
|
77
|
+
expect(fastPaths[0]).toContain("stopAtValue(fromPos)");
|
|
78
|
+
expect(fastPaths[1]).toContain("stopAtValue(fromVec)");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("pins the stopped value before stop for scalar and vector snaps and settles", () => {
|
|
82
|
+
const stop = body("stopAtValue");
|
|
83
|
+
expect(stop).toMatch(/m\.interp\.keyValue = \[value, value\][^]*m\.anim\.control = "stop"/);
|
|
84
|
+
const snap = body("fireAnimation");
|
|
85
|
+
expect(snap).toContain("stopAtValue(toVal)");
|
|
86
|
+
expect(snap).toContain("stopAtValue(toVec)");
|
|
87
|
+
const settle = body("onProposedStopsChanged");
|
|
88
|
+
expect(settle).toContain("stopAtValue(fromPos)");
|
|
89
|
+
expect(settle).toContain("stopAtValue(fromVec)");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("reads the current easing at the shared start point, including settle re-arms", () => {
|
|
93
|
+
const arm = body("armSpan");
|
|
94
|
+
expect(arm).toMatch(/m\.anim\.easeFunction = m\.top\.easeFunction[^]*m\.anim\.control {4}= "start"/);
|
|
95
|
+
expect(shim.match(/m\.anim\.easeFunction = m\.top\.easeFunction/g)).toHaveLength(1);
|
|
96
|
+
expect(body("onProposedStopsChanged").match(/armSpan\(/g)).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("starts every span through armSpan, which records the armed destination", () => {
|
|
100
|
+
// The retry timer's start is the only other.
|
|
101
|
+
expect(shim.match(/control\s*= "start"/g)).toHaveLength(2);
|
|
102
|
+
const arm = body("armSpan");
|
|
103
|
+
expect(arm).toMatch(/m\.interp\.keyValue = \[fromValue, toValue\][^]*m\.anim\.control {4}= "start"/);
|
|
104
|
+
// Stop resets the target to keyValue[0]; writing the keys after it would flash the
|
|
105
|
+
// previous span's start for a frame on every animated retarget or settle.
|
|
106
|
+
expect(arm).toMatch(/m\.interp\.keyValue = \[fromValue, toValue\][^]*m\.anim\.control {4}= "stop"/);
|
|
107
|
+
expect(arm.indexOf("m.interp.keyValue")).toBeLessThan(arm.indexOf('m.anim.control = "stop"'));
|
|
108
|
+
expect(arm).toContain("m.armed = { node: m.top.targetNode, to: toValue }");
|
|
109
|
+
expect(body("fireAnimation").match(/armSpan\(/g)).toHaveLength(2);
|
|
110
|
+
expect(body("onProposedStopsChanged")).toContain("armSpan(fromPos, closestStop, newDuration)");
|
|
111
|
+
expect(body("onProposedStopsChanged")).toContain("armSpan([fromVec[0], fromVec[1]], toVec, newDuration)");
|
|
112
|
+
// Snaps and pins land on a destination too, so they record it as well.
|
|
113
|
+
expect(body("stopAtValue")).toContain("m.armed = { node: m.top.targetNode, to: value }");
|
|
114
|
+
expect(body("snapWithoutAnim").match(/m\.armed = \{ node: m\.top\.targetNode, to: /g)).toHaveLength(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a stopped transition with no parent releases the Animation", () => {
|
|
118
|
+
expect(body("onAnimStateChanged")).toMatch(
|
|
119
|
+
/m\.anim\.state = "stopped" and m\.top\.getParent\(\) = invalid then releaseDetached\(\)/
|
|
120
|
+
);
|
|
121
|
+
// Deferred a frame on the retry timer, not run inside the Animation's own state observer.
|
|
122
|
+
const detached = body("releaseDetached");
|
|
123
|
+
expect(detached).toContain("m.releasePending = true");
|
|
124
|
+
expect(detached).toContain("startRetryTimer()");
|
|
125
|
+
expect(detached).not.toContain("releaseAnim()");
|
|
126
|
+
expect(body("onRetryTimerFire")).toMatch(/m\.top\.getParent\(\) = invalid then releaseAnim\(\)/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a stopped transition with a parent does not release", () => {
|
|
130
|
+
const state = body("onAnimStateChanged");
|
|
131
|
+
expect(state).not.toMatch(/getParent\(\) <> invalid/);
|
|
132
|
+
expect(state).not.toContain("releaseAnim()");
|
|
133
|
+
// The condition requires a missing parent, so an attached node just goes idle.
|
|
134
|
+
expect(state.match(/releaseDetached\(\)/g)).toHaveLength(1);
|
|
135
|
+
expect(state).toMatch(/getParent\(\) = invalid then releaseDetached/);
|
|
136
|
+
expect(state).toContain("m.top.animationState = [data.getData(), curVal]");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("has no owner guard Timer", () => {
|
|
140
|
+
expect(shim).not.toMatch(/ownerGuard|OwnerGuard/);
|
|
141
|
+
// The retry timer is the only Timer the shim creates.
|
|
142
|
+
expect(shim.match(/createObject\("roSGNode", "Timer"\)/g)).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("skips a write of the armed destination, never across a target change or a stop", () => {
|
|
146
|
+
const guard = body("isArmedFor");
|
|
147
|
+
expect(guard).toContain("if m.armed = invalid then return false");
|
|
148
|
+
// Keyframes armed on a previous target (FlexGrid's X tween follows the focused row)
|
|
149
|
+
// must not carry over to the new one.
|
|
150
|
+
expect(guard).toContain("if not m.armed.node.isSameNode(m.top.targetNode) then return false");
|
|
151
|
+
expect(guard).toContain("if ownX and abs(armedTo[0] - target[0]) >= 0.01 then return false");
|
|
152
|
+
expect(guard).toContain("if ownY and abs(armedTo[1] - target[1]) >= 0.01 then return false");
|
|
153
|
+
expect(body("onTargetNodeChanged")).toMatch(/m\.armed = invalid[^]*fireAnimation\(\)/);
|
|
154
|
+
expect(body("onControlChanged")).toContain('if ctrl = "stop" then m.armed = invalid');
|
|
155
|
+
expect(body("releaseAnim")).toContain("m.armed = invalid");
|
|
156
|
+
// Checked after the snap path, so a duration-0 write always lands, and before the restart.
|
|
157
|
+
const fire = body("fireAnimation");
|
|
158
|
+
const floatGuard = fire.indexOf("if isArmedFor(toVal, true, true) then return");
|
|
159
|
+
const vectorGuard = fire.indexOf('if isArmedFor(toVec, axis <> "y", axis <> "x") then return');
|
|
160
|
+
expect(floatGuard).toBeGreaterThan(fire.indexOf("stopAtValue(toVal)"));
|
|
161
|
+
expect(floatGuard).toBeLessThan(fire.indexOf("armSpan(fromVal, toVal, m.top.duration)"));
|
|
162
|
+
expect(vectorGuard).toBeGreaterThan(fire.indexOf("stopAtValue(toVec)"));
|
|
163
|
+
expect(vectorGuard).toBeLessThan(fire.indexOf("armSpan(fromVec, toVec, m.top.duration)"));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('resolves the axis to "x", "y" or "all" so there is one vector axis path', () => {
|
|
167
|
+
const resolve = body("resolvedAxis");
|
|
168
|
+
expect(resolve).toContain('if axis = "x" or axis = "y" then return axis');
|
|
169
|
+
expect(resolve).toContain('return "all"');
|
|
170
|
+
expect(shim).not.toContain('"both"');
|
|
171
|
+
expect(shim).not.toContain("AxisBoth");
|
|
172
|
+
|
|
173
|
+
// Nothing outside resolvedAxis() may branch on the raw axis field.
|
|
174
|
+
for (const name of ["buildVectorTarget", "fireAnimation", "onProposedStopsChanged", "normalizeVectorStops"]) {
|
|
175
|
+
expect(body(name)).not.toContain("m.top.axis");
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("settles one whole [x, y] candidate on every owned component instead of a dominant axis", () => {
|
|
180
|
+
const settle = body("onProposedStopsChanged");
|
|
181
|
+
// The dominant-axis hack and the per-component projection are gone.
|
|
182
|
+
expect(settle).not.toContain("abs(kvTo[0] - kvFrom[0]) >= abs(kvTo[1] - kvFrom[1])");
|
|
183
|
+
expect(settle).not.toContain("isAxisPair");
|
|
184
|
+
expect(settle).not.toContain("for axisIdx");
|
|
185
|
+
expect(shim).not.toMatch(/function pickStop2D/);
|
|
186
|
+
// The axis decides which components are owned; one point is chosen for all of them.
|
|
187
|
+
expect(settle).toContain('ownX = (axis <> "y")');
|
|
188
|
+
expect(settle).toContain('ownY = (axis <> "x")');
|
|
189
|
+
expect(settle).toContain("picked = pickStopVector(stops, fromVec, ownX, ownY, dirX, dirY, unfiltered)");
|
|
190
|
+
expect(settle).toContain("if ownX then toVec[0] = candidate[0]");
|
|
191
|
+
expect(settle).toContain("if ownY then toVec[1] = candidate[1]");
|
|
192
|
+
// Speed is held along the path, so both distances are Euclidean over owned components.
|
|
193
|
+
expect(settle).toContain("newDistance = vecDistance(fromVec, toVec, ownX, ownY)");
|
|
194
|
+
expect(settle).toContain("originalDistance = vecDistance(kvFrom, kvTo, ownX, ownY)");
|
|
195
|
+
// A single candidate or a span that moves no owned component drops the direction filter.
|
|
196
|
+
expect(settle).toContain("unfiltered = (stops.count() = 1) or (dirX = 0 and dirY = 0)");
|
|
197
|
+
// Nothing ahead keeps the armed destination, like the 1D path.
|
|
198
|
+
expect(settle).toContain("candidate = [kvTo[0], kvTo[1]]");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("picks the nearest accepted candidate by Euclidean distance", () => {
|
|
202
|
+
expect(body("vecDistance")).toContain("return sqr(dx * dx + dy * dy)");
|
|
203
|
+
const vector = body("pickStopVector");
|
|
204
|
+
expect(vector).toContain("distance = vecDistance(fromVec, candidate, ownX, ownY)");
|
|
205
|
+
expect(vector).toContain("best = -1");
|
|
206
|
+
// A component that is not moving (dir 0) adds no constraint: only signed dirs filter.
|
|
207
|
+
expect(vector).toContain("if dirX > 0 and");
|
|
208
|
+
expect(vector).toContain("if dirY < 0 and");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("normalizes the vector stop shapes to [x, y] tuples", () => {
|
|
212
|
+
const normalize = body("normalizeVectorStops");
|
|
213
|
+
// A flat pair is two candidates, never one destination point.
|
|
214
|
+
expect(normalize).not.toContain("return [[stops[0], stops[1]]]");
|
|
215
|
+
expect(body("onProposedStopsChanged")).toContain("normalized = normalizeVectorStops(stops, axis)");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('accepts only [x, y] pairs as stops on axis "all", skipping scalars instead of lifting them', () => {
|
|
219
|
+
const normalize = body("normalizeVectorStops");
|
|
220
|
+
// Under "all" a stop must have the vector's arity; a scalar is skipped, not lifted to
|
|
221
|
+
// [s, s] (the lift settled a legacy flat [X, Y] focus stop onto the row below).
|
|
222
|
+
const allBranch = normalize.slice(normalize.indexOf('if axis = "all" then'), normalize.indexOf("else if"));
|
|
223
|
+
expect(allBranch).toContain('if type(candidate) = "roArray" and candidate.count() = 2 then');
|
|
224
|
+
expect(allBranch).toContain("skipped = skipped + 1");
|
|
225
|
+
expect(allBranch).not.toContain("[candidate, candidate]");
|
|
226
|
+
// Skips are logged once per settle, after the loop, not per entry.
|
|
227
|
+
expect(normalize.indexOf("print")).toBeGreaterThan(normalize.indexOf("end for"));
|
|
228
|
+
// "x" / "y" still read a scalar as the owned coordinate.
|
|
229
|
+
expect(normalize.slice(normalize.indexOf("else if"))).toContain("candidates.push([candidate, candidate])");
|
|
230
|
+
// No usable candidate means no settle; the reported index maps back into proposedStops.
|
|
231
|
+
const settle = body("onProposedStopsChanged");
|
|
232
|
+
expect(settle).toContain("if stops.count() = 0 then return");
|
|
233
|
+
expect(settle).toContain("index = normalized.indices[index]");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('accepts only scalars as stops on axis "x" / "y", skipping arrays instead of reading one component', () => {
|
|
237
|
+
const normalize = body("normalizeVectorStops");
|
|
238
|
+
// Under "x" / "y" a stop is the one owned coordinate; an array of any length, [x, y]
|
|
239
|
+
// included, is skipped and counted with the "all" skips, never read on one component.
|
|
240
|
+
const axisBranch = normalize.slice(normalize.indexOf("else if"), normalize.indexOf("end for"));
|
|
241
|
+
expect(axisBranch).toContain('else if type(candidate) = "roArray" then\n skipped = skipped + 1');
|
|
242
|
+
expect(axisBranch).not.toContain("candidate.count() >= 2");
|
|
243
|
+
expect(normalize).not.toMatch(/count\(\) >= 2/);
|
|
244
|
+
// Scalars still compete, reported at their original index.
|
|
245
|
+
expect(axisBranch).toContain("candidates.push([candidate, candidate])");
|
|
246
|
+
expect(axisBranch).toContain("indices.push(i)");
|
|
247
|
+
// One log per settle names the expected shape for the axis in use.
|
|
248
|
+
const log = normalize.slice(normalize.indexOf("end for"));
|
|
249
|
+
expect(log).toContain("not [x, y] pairs on axis");
|
|
250
|
+
expect(log).toContain("not scalars on axis");
|
|
251
|
+
expect(log.match(/print /g)).toHaveLength(2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("only replays written targets and compares node identity rather than id strings", () => {
|
|
255
|
+
expect(body("init")).toContain("m.targetWritten = false");
|
|
256
|
+
for (const name of ["onTargetValueChanged", "onTargetVectorChanged"]) {
|
|
257
|
+
expect(body(name)).toContain("m.targetWritten = true");
|
|
258
|
+
}
|
|
259
|
+
const rebind = body("onTargetNodeChanged");
|
|
260
|
+
expect(rebind).toContain("target.isSameNode(m.lastTarget)");
|
|
261
|
+
expect(rebind).toContain("if m.targetWritten then fireAnimation()");
|
|
262
|
+
expect(rebind).not.toContain("m.top.axis");
|
|
263
|
+
expect(rebind).not.toContain("m.lastTargetId");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("filters array stops before scalar arithmetic and preserves source indices", () => {
|
|
268
|
+
const settle = body("onProposedStopsChanged");
|
|
269
|
+
expect(settle).toMatch(/normalizeScalarStops\(stops\)[^]*if stops.count\(\) = 0 then return[^]*pickStop1D\(stops/);
|
|
270
|
+
expect(settle).toMatch(
|
|
271
|
+
/index = picked\[0\][^]*if index < 0 then[^]*index = 0[^]*else[^]*index = normalized.indices\[index\]/
|
|
272
|
+
);
|
|
273
|
+
expect(body("pickStop1D")).toContain("index = -1");
|
|
274
|
+
expect(body("normalizeScalarStops")).toContain('if type(candidate) = "roArray" then');
|
|
275
|
+
expect(body("normalizeScalarStops")).toContain("if skipped > 0 then print");
|
|
276
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { beforeAll, describe, expect, jest, test } from "@jest/globals";
|
|
5
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
6
|
+
import { registerTagAlias } from "@roku-sdk/solid";
|
|
7
|
+
|
|
8
|
+
jest.mock("@roku-sdk/solid", () => ({ registerTagAlias: jest.fn() }));
|
|
9
|
+
jest.mock("@roku-sdk/runtime/has-feature", () => ({ hasFeature: jest.fn() }));
|
|
10
|
+
|
|
11
|
+
let resolveTween: (() => string) | undefined;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await import("../index");
|
|
15
|
+
resolveTween = jest.mocked(registerTagAlias).mock.calls.find(([tag]) => tag === "tween")?.[1];
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("tween shim alias", () => {
|
|
19
|
+
test("is registered for the tween tag", () => {
|
|
20
|
+
expect(resolveTween).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("keeps the native node when the firmware has it", () => {
|
|
24
|
+
jest.mocked(hasFeature).mockReturnValue(true);
|
|
25
|
+
expect(resolveTween!()).toBe("tween");
|
|
26
|
+
expect(hasFeature).toHaveBeenCalledWith("tween");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("creates the shim when the firmware does not", () => {
|
|
30
|
+
jest.mocked(hasFeature).mockReturnValue(false);
|
|
31
|
+
expect(resolveTween!()).toBe("RsgTweenShim");
|
|
32
|
+
expect(hasFeature).toHaveBeenCalledWith("tween");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// Injected only by Roku builds without compat; importing rsg-ts alone stays tree-shakeable.
|
|
5
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
6
|
+
import { hasTagAlias, registerTagAlias } from "@roku-sdk/solid";
|
|
7
|
+
|
|
8
|
+
const GATED_TAGS: Record<string, string> = { tween: "tween" };
|
|
9
|
+
|
|
10
|
+
for (const [tag, feature] of Object.entries(GATED_TAGS)) {
|
|
11
|
+
if (hasTagAlias(tag)) continue;
|
|
12
|
+
registerTagAlias(tag, () => {
|
|
13
|
+
if (hasFeature(feature)) return tag;
|
|
14
|
+
throw new Error(
|
|
15
|
+
`<${tag}> requires the "${feature}" firmware feature or @roku-sdk/compat: this firmware does not report that feature.`
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import "./rsg-tween-shim.xml";
|
|
5
|
+
|
|
6
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
7
|
+
import { registerTagAlias } from "@roku-sdk/solid";
|
|
8
|
+
|
|
9
|
+
registerTagAlias("tween", () => (hasFeature("tween") ? "tween" : "RsgTweenShim"));
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
' Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
' SPDX-License-Identifier: MIT
|
|
3
|
+
'
|
|
4
|
+
' Stand-in for the firmware Tween node when firmware does not report the "tween" feature.
|
|
5
|
+
'
|
|
6
|
+
' Drives an Animation + FloatFieldInterpolator | Vector2dFieldInterpolator pair against the
|
|
7
|
+
' Tween field contract. The pair is built here and inserted next to this node, in its parent,
|
|
8
|
+
' so `fieldToInterp` resolves the target's id in the scope the target lives in; it is removed
|
|
9
|
+
' again when targetNode is cleared, or when a span ends after this node was detached from its
|
|
10
|
+
' parent (see onAnimStateChanged and releaseDetached). Value type (float vs vector2d) is detected from the target
|
|
11
|
+
' field and the trigger written; a scalar written to a vector field is auto-expanded to [v, v].
|
|
12
|
+
'
|
|
13
|
+
' Axis modes are "x", "y" and "all", resolved by resolvedAxis() before anything reasons about
|
|
14
|
+
' them, so there is one vector code path.
|
|
15
|
+
'
|
|
16
|
+
' Settle (onProposedStopsChanged): candidates are accepted strictly ahead in the direction of
|
|
17
|
+
' travel (> / <), a stationary span takes the absolute-nearest (sticky step), and the settle
|
|
18
|
+
' duration scales to hold velocity (floored at 0.02s, 0.05s zero-span fallback). On a vector
|
|
19
|
+
' field a candidate is a whole [x, y] point: it must be strictly ahead on every owned component
|
|
20
|
+
' that moves, the nearest such point by Euclidean distance wins, and it is applied to every
|
|
21
|
+
' owned component at once. There is no dominant axis.
|
|
22
|
+
'
|
|
23
|
+
' A candidate has the same arity as the value it settles. On a float field, or a vector field
|
|
24
|
+
' under axis "x" / "y", that is a scalar: the coordinate on the owned component; an array
|
|
25
|
+
' there is skipped. Under axis "all" it is an [x, y] pair; a scalar there is skipped, never
|
|
26
|
+
' lifted to [s, s]. See normalizeVectorStops().
|
|
27
|
+
|
|
28
|
+
sub init()
|
|
29
|
+
m.anim = invalid
|
|
30
|
+
m.interp = invalid
|
|
31
|
+
m.armed = invalid ' { node, to }: the last destination armed or pinned, and on which node
|
|
32
|
+
m.retryTimer = invalid
|
|
33
|
+
m.releasePending = false ' a detached node's Animation is to be released on the next frame
|
|
34
|
+
m.pendingFire = false
|
|
35
|
+
m.lastTarget = invalid
|
|
36
|
+
m.targetWritten = false
|
|
37
|
+
m.useScalarTarget = true
|
|
38
|
+
m.valueType = invalid ' "float" | "vector2d", detected lazily
|
|
39
|
+
end sub
|
|
40
|
+
|
|
41
|
+
' Vector fields animate as vector2d; everything else as float. A targetVector write also
|
|
42
|
+
' forces vector2d (handled at its trigger).
|
|
43
|
+
function detectValueType() as string
|
|
44
|
+
field = m.top.field
|
|
45
|
+
if field = "translation" or field = "scale" or field = "scaleRotateCenter" then
|
|
46
|
+
return "vector2d"
|
|
47
|
+
end if
|
|
48
|
+
return "float"
|
|
49
|
+
end function
|
|
50
|
+
|
|
51
|
+
' Lazily build m.anim / m.interp in this node's parent. Returns false while there is no target
|
|
52
|
+
' or no parent to put them in.
|
|
53
|
+
function ensureAnimReady() as boolean
|
|
54
|
+
if m.top.targetNode = invalid then return false
|
|
55
|
+
parent = m.top.getParent()
|
|
56
|
+
if parent = invalid then return false
|
|
57
|
+
if m.valueType = invalid then m.valueType = detectValueType()
|
|
58
|
+
|
|
59
|
+
if m.anim <> invalid then
|
|
60
|
+
animParent = m.anim.getParent()
|
|
61
|
+
if animParent = invalid or not animParent.isSameNode(parent) then
|
|
62
|
+
if animParent <> invalid then animParent.removeChild(m.anim)
|
|
63
|
+
parent.appendChild(m.anim)
|
|
64
|
+
end if
|
|
65
|
+
else
|
|
66
|
+
m.anim = createObject("roSGNode", "Animation")
|
|
67
|
+
m.anim.repeat = false
|
|
68
|
+
m.anim.observeFieldScopedEx("state", "onAnimStateChanged")
|
|
69
|
+
parent.appendChild(m.anim)
|
|
70
|
+
end if
|
|
71
|
+
|
|
72
|
+
wantType = "FloatFieldInterpolator"
|
|
73
|
+
if m.valueType = "vector2d" then wantType = "Vector2DFieldInterpolator"
|
|
74
|
+
if m.interp <> invalid and LCase(m.interp.subtype()) <> LCase(wantType) then
|
|
75
|
+
m.anim.removeChild(m.interp)
|
|
76
|
+
m.interp = invalid
|
|
77
|
+
end if
|
|
78
|
+
if m.interp = invalid then
|
|
79
|
+
m.interp = createObject("roSGNode", wantType)
|
|
80
|
+
m.interp.key = [0.0, 1.0]
|
|
81
|
+
m.anim.appendChild(m.interp)
|
|
82
|
+
end if
|
|
83
|
+
return true
|
|
84
|
+
end function
|
|
85
|
+
|
|
86
|
+
' Take the animation out of the parent, so a disposed tween leaves nothing behind in a tree
|
|
87
|
+
' it no longer belongs to and holds no node.
|
|
88
|
+
sub releaseAnim()
|
|
89
|
+
m.pendingFire = false
|
|
90
|
+
m.releasePending = false
|
|
91
|
+
m.armed = invalid
|
|
92
|
+
if m.retryTimer <> invalid then m.retryTimer.control = "stop"
|
|
93
|
+
if m.anim = invalid then return
|
|
94
|
+
m.anim.unobserveFieldScoped("state")
|
|
95
|
+
m.anim.control = "stop"
|
|
96
|
+
animParent = m.anim.getParent()
|
|
97
|
+
if animParent <> invalid then animParent.removeChild(m.anim)
|
|
98
|
+
m.anim = invalid
|
|
99
|
+
m.interp = invalid
|
|
100
|
+
end sub
|
|
101
|
+
|
|
102
|
+
' The Animation lives beside this node, not under it, so removing this node from the tree does
|
|
103
|
+
' not remove it. The usual release is immediate: the Tween wrapper clears targetNode when it is
|
|
104
|
+
' disposed, and onTargetNodeChanged calls releaseAnim. For a raw <tween> removed mid-flight
|
|
105
|
+
' without clearing targetNode there is no timer and no polling: a span is finite and every span
|
|
106
|
+
' that starts ends in "stopped", so that transition is the moment to check ownership (see
|
|
107
|
+
' onAnimStateChanged). The Animation lingers in the parent until its span ends. A raw <tween>
|
|
108
|
+
' removed while idle or paused is not caught: a paused span never reaches "stopped", and a node
|
|
109
|
+
' has no destructor and no observable parent to hook instead. Clear targetNode first.
|
|
110
|
+
'
|
|
111
|
+
' The release is deferred by one frame rather than run inside the Animation's own state
|
|
112
|
+
' observer, because unobserving a field and removing the node from inside that field's callback
|
|
113
|
+
' is not something SceneGraph documents as safe. The deferral reuses the retry Timer.
|
|
114
|
+
sub releaseDetached()
|
|
115
|
+
m.releasePending = true
|
|
116
|
+
startRetryTimer()
|
|
117
|
+
end sub
|
|
118
|
+
|
|
119
|
+
' A trigger can arrive before this node is inserted into its parent; retry on the next frames
|
|
120
|
+
' until it is.
|
|
121
|
+
sub scheduleRetry()
|
|
122
|
+
m.pendingFire = true
|
|
123
|
+
startRetryTimer()
|
|
124
|
+
end sub
|
|
125
|
+
|
|
126
|
+
sub startRetryTimer()
|
|
127
|
+
if m.retryTimer = invalid then
|
|
128
|
+
m.retryTimer = createObject("roSGNode", "Timer")
|
|
129
|
+
m.retryTimer.duration = 0.001
|
|
130
|
+
m.retryTimer.repeat = false
|
|
131
|
+
m.retryTimer.observeFieldScoped("fire", "onRetryTimerFire")
|
|
132
|
+
m.top.appendChild(m.retryTimer)
|
|
133
|
+
end if
|
|
134
|
+
m.retryTimer.control = "start"
|
|
135
|
+
end sub
|
|
136
|
+
|
|
137
|
+
sub onRetryTimerFire()
|
|
138
|
+
if m.releasePending then
|
|
139
|
+
m.releasePending = false
|
|
140
|
+
' Re-check: a new span may have started, or the node been re-attached, since the request.
|
|
141
|
+
if m.anim <> invalid then
|
|
142
|
+
if m.anim.state = "stopped" and m.top.getParent() = invalid then releaseAnim()
|
|
143
|
+
end if
|
|
144
|
+
return
|
|
145
|
+
end if
|
|
146
|
+
if not m.pendingFire then return
|
|
147
|
+
if m.top.targetNode = invalid then return
|
|
148
|
+
fireAnimation()
|
|
149
|
+
end sub
|
|
150
|
+
|
|
151
|
+
' Bind the interpolator to the CURRENT target's field. Without a rebind on target change a
|
|
152
|
+
' pooled node recycled onto another cell keeps animating its previous owner.
|
|
153
|
+
sub bindField()
|
|
154
|
+
' Interpolators address a node by id, but the public API only requires a reference.
|
|
155
|
+
' Give anonymous targets a stable unique id without changing a caller-assigned id.
|
|
156
|
+
if m.top.targetNode.id = "" then
|
|
157
|
+
m.top.targetNode.id = "rsg-tween-" + createObject("roDeviceInfo").GetRandomUUID()
|
|
158
|
+
end if
|
|
159
|
+
desiredField = m.top.targetNode.id + "." + m.top.field
|
|
160
|
+
if m.interp.fieldToInterp <> desiredField then
|
|
161
|
+
m.interp.fieldToInterp = desiredField
|
|
162
|
+
end if
|
|
163
|
+
end sub
|
|
164
|
+
|
|
165
|
+
' Target-node changes animate the standing target, but only after a target write.
|
|
166
|
+
sub onTargetNodeChanged()
|
|
167
|
+
target = m.top.targetNode
|
|
168
|
+
if target = invalid then
|
|
169
|
+
m.lastTarget = invalid
|
|
170
|
+
releaseAnim()
|
|
171
|
+
return
|
|
172
|
+
end if
|
|
173
|
+
if m.lastTarget <> invalid then
|
|
174
|
+
if target.isSameNode(m.lastTarget) then return
|
|
175
|
+
end if
|
|
176
|
+
m.lastTarget = target
|
|
177
|
+
' Whatever was armed belongs to the previous node; the new one starts from its own value.
|
|
178
|
+
m.armed = invalid
|
|
179
|
+
|
|
180
|
+
if m.targetWritten then fireAnimation()
|
|
181
|
+
end sub
|
|
182
|
+
|
|
183
|
+
sub onTargetValueChanged()
|
|
184
|
+
m.useScalarTarget = true
|
|
185
|
+
m.targetWritten = true
|
|
186
|
+
m.valueType = detectValueType()
|
|
187
|
+
fireAnimation()
|
|
188
|
+
end sub
|
|
189
|
+
|
|
190
|
+
sub onTargetVectorChanged()
|
|
191
|
+
m.useScalarTarget = false
|
|
192
|
+
m.targetWritten = true
|
|
193
|
+
' An explicit vector target forces vector2d regardless of field name.
|
|
194
|
+
m.valueType = "vector2d"
|
|
195
|
+
fireAnimation()
|
|
196
|
+
end sub
|
|
197
|
+
|
|
198
|
+
sub onControlChanged()
|
|
199
|
+
if m.anim = invalid then return
|
|
200
|
+
ctrl = m.top.control
|
|
201
|
+
if ctrl = "" then return
|
|
202
|
+
' A stop puts the target back at the span's start, so the armed destination no longer holds.
|
|
203
|
+
if ctrl = "stop" then m.armed = invalid
|
|
204
|
+
m.anim.control = ctrl
|
|
205
|
+
end sub
|
|
206
|
+
|
|
207
|
+
' The axis mode. Every read of the axis goes through here so there is exactly one set of
|
|
208
|
+
' vector semantics: "x" and "y" move one component and hold the other at preserveOther,
|
|
209
|
+
' "all" moves the whole vector.
|
|
210
|
+
function resolvedAxis() as string
|
|
211
|
+
axis = m.top.axis
|
|
212
|
+
if axis = "x" or axis = "y" then return axis
|
|
213
|
+
return "all"
|
|
214
|
+
end function
|
|
215
|
+
|
|
216
|
+
' Build the vector2d target from the trigger inputs (scalar auto-expansion + axis rules).
|
|
217
|
+
function buildVectorTarget() as object
|
|
218
|
+
if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 2 then
|
|
219
|
+
return [m.top.targetVector[0], m.top.targetVector[1]]
|
|
220
|
+
end if
|
|
221
|
+
v = m.top.targetValue
|
|
222
|
+
axis = resolvedAxis()
|
|
223
|
+
preserveOther = m.top.preserveOther
|
|
224
|
+
if axis = "y" then return [preserveOther, v]
|
|
225
|
+
if axis = "x" then return [v, preserveOther]
|
|
226
|
+
' "all": the scalar goes on both slots (uniform scale), as the native node does.
|
|
227
|
+
return [v, v]
|
|
228
|
+
end function
|
|
229
|
+
|
|
230
|
+
sub fireAnimation()
|
|
231
|
+
' A trigger can be written before targetNode; the native node keeps it, so hold it until
|
|
232
|
+
' onTargetNodeChanged delivers a target.
|
|
233
|
+
if m.top.targetNode = invalid then
|
|
234
|
+
m.pendingFire = true
|
|
235
|
+
return
|
|
236
|
+
end if
|
|
237
|
+
if not ensureAnimReady() then
|
|
238
|
+
if m.top.duration = 0 then
|
|
239
|
+
snapWithoutAnim()
|
|
240
|
+
else
|
|
241
|
+
scheduleRetry()
|
|
242
|
+
end if
|
|
243
|
+
return
|
|
244
|
+
end if
|
|
245
|
+
m.pendingFire = false
|
|
246
|
+
bindField()
|
|
247
|
+
|
|
248
|
+
targetNode = m.top.targetNode
|
|
249
|
+
|
|
250
|
+
if m.valueType = "float" then
|
|
251
|
+
toVal = m.top.targetValue
|
|
252
|
+
if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then
|
|
253
|
+
toVal = m.top.targetVector[0]
|
|
254
|
+
end if
|
|
255
|
+
if m.top.duration = 0 then
|
|
256
|
+
stopAtValue(toVal)
|
|
257
|
+
targetNode.setField(m.top.field, toVal)
|
|
258
|
+
return
|
|
259
|
+
end if
|
|
260
|
+
if isArmedFor(toVal, true, true) then return
|
|
261
|
+
fromVal = targetNode.getField(m.top.field)
|
|
262
|
+
armSpan(fromVal, toVal, m.top.duration)
|
|
263
|
+
else
|
|
264
|
+
toVec = buildVectorTarget()
|
|
265
|
+
if m.top.duration = 0 then
|
|
266
|
+
stopAtValue(toVec)
|
|
267
|
+
targetNode.setField(m.top.field, toVec)
|
|
268
|
+
return
|
|
269
|
+
end if
|
|
270
|
+
axis = resolvedAxis()
|
|
271
|
+
if isArmedFor(toVec, axis <> "y", axis <> "x") then return
|
|
272
|
+
liveVec = targetNode.getField(m.top.field)
|
|
273
|
+
if liveVec = invalid then liveVec = [0, 0]
|
|
274
|
+
preserveOther = m.top.preserveOther
|
|
275
|
+
if axis = "y" then
|
|
276
|
+
fromVec = [preserveOther, liveVec[1]]
|
|
277
|
+
else if axis = "x" then
|
|
278
|
+
fromVec = [liveVec[0], preserveOther]
|
|
279
|
+
else
|
|
280
|
+
fromVec = liveVec
|
|
281
|
+
end if
|
|
282
|
+
armSpan(fromVec, toVec, m.top.duration)
|
|
283
|
+
end if
|
|
284
|
+
|
|
285
|
+
date = createObject("roDateTime")
|
|
286
|
+
m.top.animationStart = [date.AsSeconds(), date.GetMilliseconds()]
|
|
287
|
+
end sub
|
|
288
|
+
|
|
289
|
+
' The one place a span starts. Records the destination so a later write of the same one is a
|
|
290
|
+
' no-op (see isArmedFor). Every start reads the current ease, including a settle re-arm.
|
|
291
|
+
' Stop resets the target to keyValue[0], so the new keys go in first: the reset then lands on
|
|
292
|
+
' fromValue (the live position) rather than on the previous span's start.
|
|
293
|
+
sub armSpan(fromValue as dynamic, toValue as dynamic, duration as float)
|
|
294
|
+
m.anim.easeFunction = m.top.easeFunction
|
|
295
|
+
m.anim.duration = duration
|
|
296
|
+
m.interp.keyValue = [fromValue, toValue]
|
|
297
|
+
m.anim.control = "stop"
|
|
298
|
+
m.anim.control = "start"
|
|
299
|
+
m.armed = { node: m.top.targetNode, to: toValue }
|
|
300
|
+
end sub
|
|
301
|
+
|
|
302
|
+
' True when `target` is the destination last armed or pinned on the current target node, on
|
|
303
|
+
' every owned component. Writing it again must not restart: FlexGrid re-writes the settled
|
|
304
|
+
' position as a settle lands, and a restart re-arms the last few pixels over the full duration,
|
|
305
|
+
' so the end of the settle crawls. The other renderer implementations follow the same rule. A
|
|
306
|
+
' target-node change and a "stop" control clear the record, so neither is ever skipped.
|
|
307
|
+
function isArmedFor(target as dynamic, ownX as boolean, ownY as boolean) as boolean
|
|
308
|
+
if m.armed = invalid then return false
|
|
309
|
+
if not m.armed.node.isSameNode(m.top.targetNode) then return false
|
|
310
|
+
armedTo = m.armed.to
|
|
311
|
+
if type(target) <> "roArray" then
|
|
312
|
+
if type(armedTo) = "roArray" then return false
|
|
313
|
+
return abs(armedTo - target) < 0.01
|
|
314
|
+
end if
|
|
315
|
+
if type(armedTo) <> "roArray" then return false
|
|
316
|
+
if ownX and abs(armedTo[0] - target[0]) >= 0.01 then return false
|
|
317
|
+
if ownY and abs(armedTo[1] - target[1]) >= 0.01 then return false
|
|
318
|
+
return true
|
|
319
|
+
end function
|
|
320
|
+
|
|
321
|
+
' Stop resets the target to keyValue[0], so pin both keys before issuing the control.
|
|
322
|
+
sub stopAtValue(value as dynamic)
|
|
323
|
+
m.interp.keyValue = [value, value]
|
|
324
|
+
m.anim.control = "stop"
|
|
325
|
+
m.armed = { node: m.top.targetNode, to: value }
|
|
326
|
+
end sub
|
|
327
|
+
|
|
328
|
+
' A snap needs no animation, so it lands even before this node has a parent.
|
|
329
|
+
sub snapWithoutAnim()
|
|
330
|
+
m.pendingFire = false
|
|
331
|
+
if m.valueType = invalid then m.valueType = detectValueType()
|
|
332
|
+
if m.valueType = "float" then
|
|
333
|
+
toVal = m.top.targetValue
|
|
334
|
+
if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then
|
|
335
|
+
toVal = m.top.targetVector[0]
|
|
336
|
+
end if
|
|
337
|
+
m.top.targetNode.setField(m.top.field, toVal)
|
|
338
|
+
m.armed = { node: m.top.targetNode, to: toVal }
|
|
339
|
+
else
|
|
340
|
+
toVec = buildVectorTarget()
|
|
341
|
+
m.top.targetNode.setField(m.top.field, toVec)
|
|
342
|
+
m.armed = { node: m.top.targetNode, to: toVec }
|
|
343
|
+
end if
|
|
344
|
+
end sub
|
|
345
|
+
|
|
346
|
+
sub onProposedStopsChanged(data as dynamic)
|
|
347
|
+
if m.anim = invalid or m.interp = invalid then return
|
|
348
|
+
if not ensureAnimReady() then return
|
|
349
|
+
stops = data.getData()
|
|
350
|
+
if stops = invalid or stops.count() = 0 then return
|
|
351
|
+
if m.interp.keyValue = invalid or m.interp.keyValue.count() < 2 then return
|
|
352
|
+
bindField()
|
|
353
|
+
|
|
354
|
+
targetNode = m.top.targetNode
|
|
355
|
+
kvFrom = m.interp.keyValue[0]
|
|
356
|
+
kvTo = m.interp.keyValue[1]
|
|
357
|
+
|
|
358
|
+
if m.valueType = "float" then
|
|
359
|
+
normalized = normalizeScalarStops(stops)
|
|
360
|
+
stops = normalized.candidates
|
|
361
|
+
if stops.count() = 0 then return
|
|
362
|
+
fromPos = targetNode.getField(m.top.field)
|
|
363
|
+
isIncreasing = (kvTo > kvFrom)
|
|
364
|
+
isStationary = (kvTo = kvFrom)
|
|
365
|
+
picked = pickStop1D(stops, fromPos, isIncreasing, isStationary, kvTo)
|
|
366
|
+
index = picked[0]
|
|
367
|
+
if index < 0 then
|
|
368
|
+
index = 0
|
|
369
|
+
else
|
|
370
|
+
index = normalized.indices[index]
|
|
371
|
+
end if
|
|
372
|
+
closestStop = picked[1]
|
|
373
|
+
|
|
374
|
+
originalDistance = abs(kvTo - kvFrom)
|
|
375
|
+
newDistance = abs(closestStop - fromPos)
|
|
376
|
+
if newDistance = 0 then
|
|
377
|
+
stopAtValue(fromPos)
|
|
378
|
+
fireAnimationStop(index, closestStop, 0)
|
|
379
|
+
return
|
|
380
|
+
end if
|
|
381
|
+
newDuration = scaleSettle(m.anim.duration, originalDistance, newDistance)
|
|
382
|
+
|
|
383
|
+
armSpan(fromPos, closestStop, newDuration)
|
|
384
|
+
|
|
385
|
+
fireAnimationStop(index, closestStop, newDuration)
|
|
386
|
+
return
|
|
387
|
+
end if
|
|
388
|
+
|
|
389
|
+
' vector2d. One code path for every axis mode. A candidate is a whole [x, y] point, chosen
|
|
390
|
+
' once and applied to every component the axis owns: "x" and "y" own one component and hold
|
|
391
|
+
' the other where it is, "all" owns both. There is no dominant-axis selection, so a diagonal glide settles X and Y together onto one candidate.
|
|
392
|
+
axis = resolvedAxis()
|
|
393
|
+
normalized = normalizeVectorStops(stops, axis)
|
|
394
|
+
stops = normalized.candidates
|
|
395
|
+
' No candidate of the right arity: no settle. The armed span keeps running to its own
|
|
396
|
+
' target and onAnimationStop does not fire, as for an empty proposedStops.
|
|
397
|
+
if stops.count() = 0 then return
|
|
398
|
+
|
|
399
|
+
fromVec = targetNode.getField(m.top.field)
|
|
400
|
+
if fromVec = invalid then fromVec = [0, 0]
|
|
401
|
+
|
|
402
|
+
ownX = (axis <> "y")
|
|
403
|
+
ownY = (axis <> "x")
|
|
404
|
+
dirX = 0
|
|
405
|
+
dirY = 0
|
|
406
|
+
if ownX then dirX = sgn(kvTo[0] - kvFrom[0])
|
|
407
|
+
if ownY then dirY = sgn(kvTo[1] - kvFrom[1])
|
|
408
|
+
|
|
409
|
+
' One candidate is a destination, not a choice, so the direction-of-travel filter is
|
|
410
|
+
' dropped for it. A span that moves no owned component (FlexGrid's sticky-step arming)
|
|
411
|
+
' also drops the filter and takes the absolute-nearest candidate.
|
|
412
|
+
unfiltered = (stops.count() = 1) or (dirX = 0 and dirY = 0)
|
|
413
|
+
|
|
414
|
+
picked = pickStopVector(stops, fromVec, ownX, ownY, dirX, dirY, unfiltered)
|
|
415
|
+
index = picked[0]
|
|
416
|
+
if index < 0 then
|
|
417
|
+
' Nothing ahead: keep the armed span running to its own target, as the 1D path does
|
|
418
|
+
' when no candidate is accepted.
|
|
419
|
+
candidate = [kvTo[0], kvTo[1]]
|
|
420
|
+
index = 0
|
|
421
|
+
else
|
|
422
|
+
candidate = stops[index]
|
|
423
|
+
' Report the index into proposedStops as written, not into the filtered list.
|
|
424
|
+
index = normalized.indices[index]
|
|
425
|
+
end if
|
|
426
|
+
|
|
427
|
+
toVec = [fromVec[0], fromVec[1]]
|
|
428
|
+
if ownX then toVec[0] = candidate[0]
|
|
429
|
+
if ownY then toVec[1] = candidate[1]
|
|
430
|
+
|
|
431
|
+
' The reported position is a scalar: the owned component for "x"/"y", and for "all" the
|
|
432
|
+
' component that travels further, which is also the one that dominated the pre-unification
|
|
433
|
+
' report.
|
|
434
|
+
reportIdx = 0
|
|
435
|
+
if axis = "y" then reportIdx = 1
|
|
436
|
+
if axis = "all" and abs(toVec[1] - fromVec[1]) > abs(toVec[0] - fromVec[0]) then reportIdx = 1
|
|
437
|
+
closestStop = toVec[reportIdx]
|
|
438
|
+
|
|
439
|
+
newDistance = vecDistance(fromVec, toVec, ownX, ownY)
|
|
440
|
+
originalDistance = vecDistance(kvFrom, kvTo, ownX, ownY)
|
|
441
|
+
if newDistance = 0 then
|
|
442
|
+
stopAtValue(fromVec)
|
|
443
|
+
fireAnimationStop(index, closestStop, 0)
|
|
444
|
+
return
|
|
445
|
+
end if
|
|
446
|
+
newDuration = scaleSettle(m.anim.duration, originalDistance, newDistance)
|
|
447
|
+
|
|
448
|
+
armSpan([fromVec[0], fromVec[1]], toVec, newDuration)
|
|
449
|
+
|
|
450
|
+
fireAnimationStop(index, closestStop, newDuration)
|
|
451
|
+
end sub
|
|
452
|
+
|
|
453
|
+
' Euclidean length of b - a over the owned components; an unowned component contributes 0.
|
|
454
|
+
function vecDistance(a as object, b as object, ownX as boolean, ownY as boolean) as float
|
|
455
|
+
dx = 0
|
|
456
|
+
dy = 0
|
|
457
|
+
if ownX then dx = b[0] - a[0]
|
|
458
|
+
if ownY then dy = b[1] - a[1]
|
|
459
|
+
return sqr(dx * dx + dy * dy)
|
|
460
|
+
end function
|
|
461
|
+
|
|
462
|
+
' Bring `proposedStops` on a vector field to one shape, a list of [x, y] candidates, keeping
|
|
463
|
+
' only entries whose arity matches the value being settled. Each entry is judged on its own,
|
|
464
|
+
' so in a mixed list the valid entries still compete. Returns { candidates, indices }, where
|
|
465
|
+
' indices[k] is the proposedStops index candidates[k] came from.
|
|
466
|
+
'
|
|
467
|
+
' - axis "x" / "y": the settled value is the one owned coordinate, so a candidate must be a
|
|
468
|
+
' scalar s (only the owned component is read; [s, s] is just a carrier). An array of any
|
|
469
|
+
' length, [x, y] included, is skipped and logged once per settle.
|
|
470
|
+
' - axis "all": the settled value is the whole vector, so a candidate must be an [x, y]
|
|
471
|
+
' pair. Scalars, and arrays of any other length, are skipped and logged once per settle.
|
|
472
|
+
'
|
|
473
|
+
' Scalars are deliberately not lifted to [s, s] under "all". That lift turned a legacy flat
|
|
474
|
+
' [X, Y] focus-highlight stop into two diagonal candidates [X, X] and [Y, Y]: holding left
|
|
475
|
+
' and releasing settled the highlight onto the row below. The same rule extends to wider
|
|
476
|
+
' values: a Rect2D field would take [x, y, w, h] candidates and skip anything else.
|
|
477
|
+
'
|
|
478
|
+
' Pairs are likewise not read on one component under "x" / "y": a pair there is the wrong
|
|
479
|
+
' arity for the one coordinate being settled, and reading half of it hides a caller that
|
|
480
|
+
' meant "all".
|
|
481
|
+
function normalizeVectorStops(stops as object, axis as string) as object
|
|
482
|
+
candidates = []
|
|
483
|
+
indices = []
|
|
484
|
+
skipped = 0
|
|
485
|
+
for i = 0 to stops.count() - 1
|
|
486
|
+
candidate = stops[i]
|
|
487
|
+
if axis = "all" then
|
|
488
|
+
if type(candidate) = "roArray" and candidate.count() = 2 then
|
|
489
|
+
candidates.push(candidate)
|
|
490
|
+
indices.push(i)
|
|
491
|
+
else
|
|
492
|
+
skipped = skipped + 1
|
|
493
|
+
end if
|
|
494
|
+
else if type(candidate) = "roArray" then
|
|
495
|
+
skipped = skipped + 1
|
|
496
|
+
else
|
|
497
|
+
candidates.push([candidate, candidate])
|
|
498
|
+
indices.push(i)
|
|
499
|
+
end if
|
|
500
|
+
end for
|
|
501
|
+
if skipped > 0 then
|
|
502
|
+
if axis = "all" then
|
|
503
|
+
print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not [x, y] pairs on axis "; chr(34); "all"; chr(34)
|
|
504
|
+
else
|
|
505
|
+
print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not scalars on axis "; chr(34); axis; chr(34)
|
|
506
|
+
end if
|
|
507
|
+
end if
|
|
508
|
+
return { candidates: candidates, indices: indices }
|
|
509
|
+
end function
|
|
510
|
+
|
|
511
|
+
' Vector candidate selection. A candidate is accepted when it lies strictly ahead on every
|
|
512
|
+
' owned component that is moving (a component the span does not move imposes no constraint);
|
|
513
|
+
' among accepted candidates the nearest by Euclidean distance over the owned components wins.
|
|
514
|
+
' Unfiltered (single destination or stationary span) accepts everything. Returns [index] with
|
|
515
|
+
' -1 when nothing was accepted.
|
|
516
|
+
function pickStopVector(stops as object, fromVec as object, ownX as boolean, ownY as boolean, dirX as integer, dirY as integer, unfiltered as boolean) as object
|
|
517
|
+
best = -1
|
|
518
|
+
bestDistance = invalid
|
|
519
|
+
for i = 0 to stops.count() - 1
|
|
520
|
+
candidate = stops[i]
|
|
521
|
+
accept = true
|
|
522
|
+
if not unfiltered then
|
|
523
|
+
if dirX > 0 and not (candidate[0] > fromVec[0]) then accept = false
|
|
524
|
+
if dirX < 0 and not (candidate[0] < fromVec[0]) then accept = false
|
|
525
|
+
if dirY > 0 and not (candidate[1] > fromVec[1]) then accept = false
|
|
526
|
+
if dirY < 0 and not (candidate[1] < fromVec[1]) then accept = false
|
|
527
|
+
end if
|
|
528
|
+
if accept then
|
|
529
|
+
distance = vecDistance(fromVec, candidate, ownX, ownY)
|
|
530
|
+
if bestDistance = invalid or distance < bestDistance then
|
|
531
|
+
bestDistance = distance
|
|
532
|
+
best = i
|
|
533
|
+
end if
|
|
534
|
+
end if
|
|
535
|
+
end for
|
|
536
|
+
return [best]
|
|
537
|
+
end function
|
|
538
|
+
|
|
539
|
+
' Unified 1D candidate selection: accept strictly ahead in the direction of travel; a
|
|
540
|
+
' stationary span takes the absolute-nearest. Returns [index, stopValue].
|
|
541
|
+
' Float fields take scalar stops only, retaining the caller's original indices.
|
|
542
|
+
function normalizeScalarStops(stops as object) as object
|
|
543
|
+
candidates = []
|
|
544
|
+
indices = []
|
|
545
|
+
skipped = 0
|
|
546
|
+
for i = 0 to stops.count() - 1
|
|
547
|
+
candidate = stops[i]
|
|
548
|
+
if type(candidate) = "roArray" then
|
|
549
|
+
skipped = skipped + 1
|
|
550
|
+
else
|
|
551
|
+
candidates.push(candidate)
|
|
552
|
+
indices.push(i)
|
|
553
|
+
end if
|
|
554
|
+
end for
|
|
555
|
+
if skipped > 0 then print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not scalars on a float field"
|
|
556
|
+
return { candidates: candidates, indices: indices }
|
|
557
|
+
end function
|
|
558
|
+
|
|
559
|
+
function pickStop1D(stops as object, fromPos as float, isIncreasing as boolean, isStationary as boolean, defaultTo as float) as object
|
|
560
|
+
closestStop = defaultTo
|
|
561
|
+
minDistance = invalid
|
|
562
|
+
index = -1
|
|
563
|
+
for i = 0 to stops.count() - 1
|
|
564
|
+
stopPosition = stops[i]
|
|
565
|
+
distance = abs(stopPosition - fromPos)
|
|
566
|
+
accept = false
|
|
567
|
+
if isStationary then
|
|
568
|
+
accept = true
|
|
569
|
+
else if isIncreasing then
|
|
570
|
+
if stopPosition > fromPos then accept = true
|
|
571
|
+
else
|
|
572
|
+
if stopPosition < fromPos then accept = true
|
|
573
|
+
end if
|
|
574
|
+
if accept then
|
|
575
|
+
if minDistance = invalid or distance < minDistance then
|
|
576
|
+
minDistance = distance
|
|
577
|
+
closestStop = stopPosition
|
|
578
|
+
index = i
|
|
579
|
+
end if
|
|
580
|
+
end if
|
|
581
|
+
end for
|
|
582
|
+
return [index, closestStop]
|
|
583
|
+
end function
|
|
584
|
+
|
|
585
|
+
' Velocity-preserving settle duration: hold the original span's units-per-second across
|
|
586
|
+
' the shortened distance. Zero-length span → 0.05s; floored at 0.02s.
|
|
587
|
+
function scaleSettle(originalDuration as float, originalDistance as float, newDistance as float) as float
|
|
588
|
+
if originalDistance > 0 then
|
|
589
|
+
newDuration = originalDuration * newDistance / originalDistance
|
|
590
|
+
else
|
|
591
|
+
newDuration = 0.05
|
|
592
|
+
end if
|
|
593
|
+
if newDuration < 0.02 then newDuration = 0.02
|
|
594
|
+
return newDuration
|
|
595
|
+
end function
|
|
596
|
+
|
|
597
|
+
sub onAnimStateChanged(data as dynamic)
|
|
598
|
+
' A span has ended. If this node was detached meanwhile, nobody owns the Animation any more.
|
|
599
|
+
' Judge the live state, not the notification: a restart delivers "stopped" after armSpan has
|
|
600
|
+
' already started the next span.
|
|
601
|
+
if m.anim <> invalid then
|
|
602
|
+
if m.anim.state = "stopped" and m.top.getParent() = invalid then releaseDetached()
|
|
603
|
+
end if
|
|
604
|
+
curVal = invalid
|
|
605
|
+
if m.top.targetNode <> invalid and m.top.field <> "" then
|
|
606
|
+
curVal = m.top.targetNode.getField(m.top.field)
|
|
607
|
+
end if
|
|
608
|
+
m.top.animationState = [data.getData(), curVal]
|
|
609
|
+
end sub
|
|
610
|
+
|
|
611
|
+
' Keep epoch milliseconds in double precision: a 32-bit BRS integer overflows here.
|
|
612
|
+
sub fireAnimationStop(index as integer, stopPos as float, delaySeconds as float)
|
|
613
|
+
currentTime = createObject("roDateTime")
|
|
614
|
+
totalMs# = currentTime.AsSeconds() * 1000# + currentTime.GetMilliseconds() + Int(delaySeconds * 1000#)
|
|
615
|
+
seconds# = Int(totalMs# / 1000#)
|
|
616
|
+
milliseconds = Int(totalMs# - seconds# * 1000#)
|
|
617
|
+
m.top.animationStop = [index, stopPos, [seconds#, milliseconds]]
|
|
618
|
+
end sub
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- Copyright (c) 2026 Roku, Inc. -->
|
|
3
|
+
<!-- SPDX-License-Identifier: MIT -->
|
|
4
|
+
<!--
|
|
5
|
+
Stand-in for the firmware `Tween` node when firmware does not report the "tween" feature.
|
|
6
|
+
|
|
7
|
+
`@roku-sdk/compat` aliases the `<tween>` tag to this component when the firmware has no
|
|
8
|
+
native `Tween`, so app source is unchanged either way. It presents the same field contract
|
|
9
|
+
as the native node, defaults included, and nothing more: a field the native node lacks would
|
|
10
|
+
work with the shim and break with the native node. It drives an `Animation` + `*FieldInterpolator` pair it
|
|
11
|
+
builds itself.
|
|
12
|
+
|
|
13
|
+
That pair is inserted next to this node, in its parent, rather than under it: an
|
|
14
|
+
interpolator resolves the `id` in `fieldToInterp` within the component scope that holds its
|
|
15
|
+
`Animation`, and this component's own scope cannot see the target. Because the pair is not
|
|
16
|
+
under this node, removing this node does not remove it. The `Tween` wrapper clears `targetNode`
|
|
17
|
+
when it is disposed, which releases the pair at once. A raw `<tween>` removed mid-flight
|
|
18
|
+
without clearing `targetNode` is released when its span ends, which is the next "stopped"
|
|
19
|
+
state; nothing polls. A raw `<tween>` removed while idle or paused never reaches that state,
|
|
20
|
+
so its Animation stays in the parent; clear `targetNode` first.
|
|
21
|
+
|
|
22
|
+
Field-order discipline (BrightScript fires onChange in declaration order): all config
|
|
23
|
+
fields (targetNode, field, duration, easeFunction, axis, preserveOther) are declared
|
|
24
|
+
BEFORE the trigger fields (targetValue, targetVector) so their writes land before the
|
|
25
|
+
trigger's onChange fires.
|
|
26
|
+
-->
|
|
27
|
+
<component name="RsgTweenShim" extends="Group">
|
|
28
|
+
<interface>
|
|
29
|
+
<!-- ── Config (must precede the trigger fields) ──────────────────────────── -->
|
|
30
|
+
<field id="targetNode" type="node" onChange="onTargetNodeChanged" />
|
|
31
|
+
<!-- Field on targetNode to animate ("translation", "scale", "opacity", ...). -->
|
|
32
|
+
<field id="field" type="string" value="translation" />
|
|
33
|
+
<field id="duration" type="float" value="0.333" />
|
|
34
|
+
<field id="easeFunction" type="string" value="linear" />
|
|
35
|
+
<!-- axis: "x" | "y" | "all". Vector fields only. Anything other than "x" or "y" is
|
|
36
|
+
"all". -->
|
|
37
|
+
<!-- @ts-field axis { type: "'x' | 'y' | 'all'" } -->
|
|
38
|
+
<field id="axis" type="string" value="all" />
|
|
39
|
+
<!-- Value held on the non-animated axis for single-axis vector animation. -->
|
|
40
|
+
<field id="preserveOther" type="float" value="0" />
|
|
41
|
+
|
|
42
|
+
<!-- ── Triggers (writing one starts the animation) ───────────────────────── -->
|
|
43
|
+
<!-- Float target. For a vector field a scalar is auto-expanded to [v, v]. -->
|
|
44
|
+
<field id="targetValue" type="float" value="0" onChange="onTargetValueChanged" />
|
|
45
|
+
<!-- Vector2d target [x, y]. -->
|
|
46
|
+
<!-- @ts-field targetVector { type: "[number, number]" } -->
|
|
47
|
+
<field id="targetVector" type="array" value="[]" onChange="onTargetVectorChanged" />
|
|
48
|
+
|
|
49
|
+
<!-- Settle candidates. A candidate has the arity of the settled value: float fields
|
|
50
|
+
and axis x/y on a vector field: number[]; axis all (the default) on a vector field:
|
|
51
|
+
[number, number][]; axis is ignored on float fields. Entries of the wrong arity are
|
|
52
|
+
skipped (a scalar under "all" is never expanded to [s, s], an array under "x" / "y"
|
|
53
|
+
is never read on one component). On a vector field one whole point is chosen
|
|
54
|
+
(strictly ahead on every moving owned component, then nearest) and applied to every
|
|
55
|
+
component the axis owns. -->
|
|
56
|
+
<!-- @ts-field proposedStops { type: "number[] | [number, number][]" } -->
|
|
57
|
+
<field id="proposedStops" type="array" value="[]" onChange="onProposedStopsChanged" />
|
|
58
|
+
|
|
59
|
+
<!-- Explicit playback control: "start" | "stop" | "pause" | "resume" | "finish". -->
|
|
60
|
+
<field id="control" type="string" value="" onChange="onControlChanged" />
|
|
61
|
+
|
|
62
|
+
<!-- ── Outputs (render thread → TypeScript) ──────────────────────────────── -->
|
|
63
|
+
<!-- @ts-field animationStart { propName: "onAnimationStart", type: "(t: [number, number]) => void" } -->
|
|
64
|
+
<field id="animationStart" type="array" value="[0,0]" />
|
|
65
|
+
<!-- @ts-field animationStop { propName: "onAnimationStop", type: "(t: [number, number, [number, number]]) => void" } -->
|
|
66
|
+
<field id="animationStop" type="array" value="[0,0]" />
|
|
67
|
+
<!-- @ts-field animationState { propName: "onAnimationState", type: "(t: [import(\"@roku-sdk/components\").AnimationState, number | import(\"@roku-sdk/components\").Vector2D]) => void" } -->
|
|
68
|
+
<field id="animationState" type="array" value="[]" />
|
|
69
|
+
</interface>
|
|
70
|
+
<script type="text/brightscript" uri="rsg-tween-shim.brs" />
|
|
71
|
+
</component>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Auto-generated TypeScript declarations for XML component import.
|
|
2
|
+
// DO NOT EDIT — this file is regenerated during build.
|
|
3
|
+
//
|
|
4
|
+
// Source component: RsgTweenShim
|
|
5
|
+
// Extends: Group
|
|
6
|
+
|
|
7
|
+
import type { IGroupProps } from "@roku-sdk/components";
|
|
8
|
+
import type { TypedXmlComponent } from "@roku-sdk/interfaces";
|
|
9
|
+
|
|
10
|
+
interface RsgTweenShimFields {
|
|
11
|
+
targetNode: import("@roku-sdk/interfaces").DomNode;
|
|
12
|
+
/** @default "translation" */
|
|
13
|
+
field: string;
|
|
14
|
+
/** @default "0.333" */
|
|
15
|
+
duration: number;
|
|
16
|
+
/** @default "linear" */
|
|
17
|
+
easeFunction: string;
|
|
18
|
+
/** @default "all" */
|
|
19
|
+
axis: string;
|
|
20
|
+
/** @default "0" */
|
|
21
|
+
preserveOther: number;
|
|
22
|
+
/** @default "0" */
|
|
23
|
+
targetValue: number;
|
|
24
|
+
/** @default "[]" */
|
|
25
|
+
targetVector: unknown[];
|
|
26
|
+
/** @default "[]" */
|
|
27
|
+
proposedStops: unknown[];
|
|
28
|
+
/** @default "" */
|
|
29
|
+
control: string;
|
|
30
|
+
/** @default "[0,0]" */
|
|
31
|
+
animationStart: unknown[];
|
|
32
|
+
/** @default "[0,0]" */
|
|
33
|
+
animationStop: unknown[];
|
|
34
|
+
/** @default "[]" */
|
|
35
|
+
animationState: unknown[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IRsgTweenShimProps extends IGroupProps {
|
|
39
|
+
targetNode?: import("@roku-sdk/interfaces").DomNode;
|
|
40
|
+
field?: string;
|
|
41
|
+
duration?: number;
|
|
42
|
+
easeFunction?: string;
|
|
43
|
+
axis?: 'x' | 'y' | 'all';
|
|
44
|
+
preserveOther?: number;
|
|
45
|
+
targetValue?: number;
|
|
46
|
+
targetVector?: [number, number];
|
|
47
|
+
proposedStops?: number[] | [number, number][];
|
|
48
|
+
control?: string;
|
|
49
|
+
onAnimationStart?: (t: [number, number]) => void;
|
|
50
|
+
onAnimationStop?: (t: [number, number, [number, number]]) => void;
|
|
51
|
+
onAnimationState?: (t: [import("@roku-sdk/components").AnimationState, number | import("@roku-sdk/components").Vector2D]) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
declare const _default: TypedXmlComponent<"RsgTweenShim", RsgTweenShimFields>;
|
|
55
|
+
export default _default;
|
|
56
|
+
|
|
57
|
+
declare module "@roku-sdk/solid" {
|
|
58
|
+
namespace JSX {
|
|
59
|
+
interface IntrinsicElements {
|
|
60
|
+
rsgTweenShim: IRsgTweenShimProps;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
declare module "@roku-sdk/solid/jsx-runtime" {
|
|
66
|
+
namespace JSX {
|
|
67
|
+
interface IntrinsicElements {
|
|
68
|
+
rsgTweenShim: IRsgTweenShimProps;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare module "solid-js" {
|
|
74
|
+
namespace JSX {
|
|
75
|
+
interface IntrinsicElements {
|
|
76
|
+
rsgTweenShim: IRsgTweenShimProps;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@roku-sdk/dev-tools/typescript-config/tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"types": ["@roku-sdk/interfaces/global"]
|
|
7
|
+
},
|
|
8
|
+
"include": ["src/**/*"],
|
|
9
|
+
"exclude": ["dist", "node_modules"],
|
|
10
|
+
"files": ["xml.d.ts"]
|
|
11
|
+
}
|
package/xml.d.ts
ADDED