@quenty/streamingutils 10.26.0 → 10.27.1
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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [10.27.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.27.0...@quenty/streamingutils@10.27.1) (2026-07-25)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @quenty/streamingutils
|
|
9
|
+
|
|
10
|
+
# [10.27.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.26.0...@quenty/streamingutils@10.27.0) (2026-07-25)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- Streaming cinematic allows latest focus to work (for now) ([528cb41](https://github.com/Quenty/NevermoreEngine/commit/528cb41a8c4f233d0903faf66c67dd6e71450cea))
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- Use ReplicationFocus instead of overwriting the focus ([daaf82f](https://github.com/Quenty/NevermoreEngine/commit/daaf82f22862570e1eaafd48188c8ed067005ef9))
|
|
19
|
+
|
|
6
20
|
# [10.26.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.25.0...@quenty/streamingutils@10.26.0) (2026-07-24)
|
|
7
21
|
|
|
8
22
|
**Note:** Version bump only for package @quenty/streamingutils
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/streamingutils",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.27.1",
|
|
4
4
|
"description": "Provides utilities for working with Roblox's streaming system",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -29,19 +29,20 @@
|
|
|
29
29
|
"Quenty"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@quenty/instanceutils": "13.
|
|
32
|
+
"@quenty/instanceutils": "13.35.1",
|
|
33
33
|
"@quenty/loader": "10.11.0",
|
|
34
34
|
"@quenty/maid": "3.11.0",
|
|
35
35
|
"@quenty/nevermore-test-runner": "1.5.0",
|
|
36
|
-
"@quenty/playermock": "1.
|
|
37
|
-
"@quenty/promise": "10.
|
|
38
|
-
"@quenty/remoting": "12.
|
|
39
|
-
"@quenty/rx": "13.
|
|
36
|
+
"@quenty/playermock": "1.5.1",
|
|
37
|
+
"@quenty/promise": "10.23.0",
|
|
38
|
+
"@quenty/remoting": "12.40.1",
|
|
39
|
+
"@quenty/rx": "13.33.0",
|
|
40
40
|
"@quenty/servicebag": "11.20.0",
|
|
41
|
+
"@quenty/statestack": "14.37.1",
|
|
41
42
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
42
43
|
},
|
|
43
44
|
"publishConfig": {
|
|
44
45
|
"access": "public"
|
|
45
46
|
},
|
|
46
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "f276b791b8eb4e88b952b5babaf0f4189c1a11d0"
|
|
47
48
|
}
|
|
@@ -20,6 +20,7 @@ local Remoting = require("Remoting")
|
|
|
20
20
|
local Rx = require("Rx")
|
|
21
21
|
local RxInstanceUtils = require("RxInstanceUtils")
|
|
22
22
|
local ServiceBag = require("ServiceBag")
|
|
23
|
+
local StateStack = require("StateStack")
|
|
23
24
|
|
|
24
25
|
-- A handful of updates per second is plenty for streaming; the pan is slow and the streaming radius
|
|
25
26
|
-- dwarfs the per-update movement. leading+trailing so the first and final resting positions both send.
|
|
@@ -33,6 +34,7 @@ export type StreamingCinematicsServiceClient = typeof(setmetatable(
|
|
|
33
34
|
_serviceBag: ServiceBag.ServiceBag,
|
|
34
35
|
_maid: Maid.Maid,
|
|
35
36
|
_remoting: any,
|
|
37
|
+
_observableStack: StateStack.StateStack<Observable.Observable<Vector3?>?>,
|
|
36
38
|
},
|
|
37
39
|
{} :: typeof({ __index = StreamingCinematicsServiceClient })
|
|
38
40
|
))
|
|
@@ -46,15 +48,28 @@ function StreamingCinematicsServiceClient.Init(
|
|
|
46
48
|
self._maid = Maid.new()
|
|
47
49
|
|
|
48
50
|
self._remoting = self._maid:Add(Remoting.Client.new(ReplicatedStorage, "StreamingCinematics"))
|
|
49
|
-
end
|
|
50
51
|
|
|
51
|
-
--
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
-- The server tracks one focus, but anything cinematic can want to drive it, so pushes stack: the
|
|
53
|
+
-- newest wins, and popping hands streaming back to whatever was underneath instead of clearing.
|
|
54
|
+
self._observableStack = self._maid:Add(StateStack.new(nil :: Observable.Observable<Vector3?>?))
|
|
55
|
+
|
|
56
|
+
-- Sending is the service's job rather than each caller's. A caller that outlives its own cleanup
|
|
57
|
+
-- can no longer fire at a torn-down remoting, and the throttle bounds the whole client instead of
|
|
58
|
+
-- being reapplied per push.
|
|
59
|
+
self._maid:GiveTask(self._observableStack
|
|
60
|
+
:Observe()
|
|
61
|
+
:Pipe({
|
|
62
|
+
Rx.switchMap(function(observePosition: Observable.Observable<Vector3?>?): any
|
|
63
|
+
return observePosition or Rx.of(nil)
|
|
64
|
+
end) :: any,
|
|
65
|
+
Rx.throttleTime(SEND_RATE_SECONDS, {
|
|
66
|
+
leading = true,
|
|
67
|
+
trailing = true,
|
|
68
|
+
}) :: any,
|
|
69
|
+
})
|
|
70
|
+
:Subscribe(function(position: Vector3?)
|
|
71
|
+
self._remoting.SetFocus:FireServer(position)
|
|
72
|
+
end))
|
|
58
73
|
end
|
|
59
74
|
|
|
60
75
|
--[=[
|
|
@@ -66,53 +81,32 @@ function StreamingCinematicsServiceClient.ObserveCurrentCameraPosition(
|
|
|
66
81
|
): Observable.Observable<Vector3?>
|
|
67
82
|
local observeCamera: any = RxInstanceUtils.observeProperty(Workspace, "CurrentCamera")
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
return observeCamera:Pipe({
|
|
85
|
+
Rx.switchMap(function(camera): any
|
|
86
|
+
if not camera then
|
|
87
|
+
return Rx.of(nil)
|
|
88
|
+
end
|
|
89
|
+
return RxInstanceUtils.observeProperty(camera, "CFrame")
|
|
90
|
+
end),
|
|
91
|
+
Rx.map(function(cframe): Vector3?
|
|
92
|
+
return if cframe then cframe.Position else nil
|
|
93
|
+
end),
|
|
94
|
+
} :: { any })
|
|
80
95
|
end
|
|
81
96
|
|
|
82
97
|
--[=[
|
|
83
98
|
Streams world content around a cinematic camera until the returned cleanup runs. Defaults to
|
|
84
99
|
following the current camera; pass an observable of `Vector3?` to drive the focus explicitly.
|
|
85
|
-
Sends are throttled to [SEND_RATE_SECONDS].
|
|
100
|
+
Sends are throttled to [SEND_RATE_SECONDS] across every push.
|
|
86
101
|
|
|
87
102
|
@param observePosition Observable<Vector3?>? -- Optional override
|
|
88
|
-
@return function -- Cleanup;
|
|
103
|
+
@return function -- Cleanup; hands the focus back to the push underneath, or clears it
|
|
89
104
|
]=]
|
|
90
105
|
function StreamingCinematicsServiceClient.PushCameraFocus(
|
|
91
106
|
self: StreamingCinematicsServiceClient,
|
|
92
107
|
observePosition: Observable.Observable<Vector3?>?
|
|
93
108
|
): () -> ()
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
local maid = Maid.new()
|
|
97
|
-
|
|
98
|
-
maid:GiveTask(source
|
|
99
|
-
:Pipe({
|
|
100
|
-
Rx.throttleTime(SEND_RATE_SECONDS, {
|
|
101
|
-
leading = true,
|
|
102
|
-
trailing = true,
|
|
103
|
-
}),
|
|
104
|
-
})
|
|
105
|
-
:Subscribe(function(position: Vector3?)
|
|
106
|
-
self:SetFocus(position)
|
|
107
|
-
end))
|
|
108
|
-
|
|
109
|
-
maid:GiveTask(function()
|
|
110
|
-
self:SetFocus(nil)
|
|
111
|
-
end)
|
|
112
|
-
|
|
113
|
-
return function()
|
|
114
|
-
maid:DoCleaning()
|
|
115
|
-
end
|
|
109
|
+
return self._observableStack:PushState(observePosition or self:ObserveCurrentCameraPosition())
|
|
116
110
|
end
|
|
117
111
|
|
|
118
112
|
function StreamingCinematicsServiceClient.Destroy(self: StreamingCinematicsServiceClient): ()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
--!strict
|
|
2
2
|
--[=[
|
|
3
3
|
Server half of the streaming-cinematics system. Receives a focus position from a client
|
|
4
|
-
(who has no character, or whose character is far from a cinematic camera) and
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
(who has no character, or whose character is far from a cinematic camera) and adds a
|
|
5
|
+
replication focus there via a [ReplicationFocusTracker], so world content streams in around
|
|
6
|
+
the cinematic camera. Sending nil clears it.
|
|
7
7
|
|
|
8
8
|
@server
|
|
9
9
|
@class StreamingCinematicsService
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
--!strict
|
|
2
2
|
--[=[
|
|
3
|
-
Keeps a single hidden part positioned at a point and
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Keeps a single hidden part positioned at a point and added as a [Player] replication focus, so
|
|
4
|
+
Roblox streams world content around that point. Reuses one part across position updates and
|
|
5
|
+
removes the focus (and destroys the part) on cleanup.
|
|
6
6
|
|
|
7
|
-
The subject is duck-typed at runtime -- anything with
|
|
8
|
-
pass a plain table; the production caller always
|
|
7
|
+
The subject is duck-typed at runtime -- anything with `AddReplicationFocus` /
|
|
8
|
+
`RemoveReplicationFocus` methods -- so tests pass a plain table; the production caller always
|
|
9
|
+
passes a [Player].
|
|
9
10
|
|
|
10
11
|
@class ReplicationFocusTracker
|
|
11
12
|
]=]
|
|
@@ -68,7 +69,7 @@ function ReplicationFocusTracker.SetPosition(self: ReplicationFocusTracker, posi
|
|
|
68
69
|
|
|
69
70
|
self._part = part
|
|
70
71
|
self._maid:GiveTask(part)
|
|
71
|
-
self:
|
|
72
|
+
self:_addReplicationFocus(part)
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
--[=[
|
|
@@ -80,17 +81,29 @@ function ReplicationFocusTracker.IsActive(self: ReplicationFocusTracker): boolea
|
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
function ReplicationFocusTracker.Destroy(self: ReplicationFocusTracker): ()
|
|
83
|
-
self
|
|
84
|
+
local part = self._part
|
|
85
|
+
if part then
|
|
86
|
+
self:_removeReplicationFocus(part)
|
|
87
|
+
end
|
|
88
|
+
|
|
84
89
|
self._maid:DoCleaning()
|
|
85
90
|
self._part = nil
|
|
86
91
|
end
|
|
87
92
|
|
|
88
|
-
-- Mock-safe: a PlayerMock's backing Folder has no
|
|
89
|
-
function ReplicationFocusTracker.
|
|
93
|
+
-- Mock-safe: a PlayerMock's backing Folder has no AddReplicationFocus method; call its stand-in.
|
|
94
|
+
function ReplicationFocusTracker._addReplicationFocus(self: ReplicationFocusTracker, part: BasePart): ()
|
|
95
|
+
if PlayerMock.isMock(self._subject) then
|
|
96
|
+
PlayerMock.addReplicationFocus(self._subject, part)
|
|
97
|
+
else
|
|
98
|
+
self._subject:AddReplicationFocus(part)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
function ReplicationFocusTracker._removeReplicationFocus(self: ReplicationFocusTracker, part: BasePart): ()
|
|
90
103
|
if PlayerMock.isMock(self._subject) then
|
|
91
|
-
PlayerMock.
|
|
104
|
+
PlayerMock.removeReplicationFocus(self._subject, part)
|
|
92
105
|
else
|
|
93
|
-
self._subject
|
|
106
|
+
self._subject:RemoveReplicationFocus(part)
|
|
94
107
|
end
|
|
95
108
|
end
|
|
96
109
|
|
|
@@ -6,22 +6,44 @@
|
|
|
6
6
|
local require = require(script.Parent.loader).load(script)
|
|
7
7
|
|
|
8
8
|
local Jest = require("Jest")
|
|
9
|
+
local PlayerMock = require("PlayerMock")
|
|
9
10
|
local ReplicationFocusTracker = require("ReplicationFocusTracker")
|
|
10
11
|
|
|
11
12
|
local describe = Jest.Globals.describe
|
|
12
13
|
local expect = Jest.Globals.expect
|
|
13
14
|
local it = Jest.Globals.it
|
|
14
15
|
|
|
15
|
-
type FakeSubject = {
|
|
16
|
+
type FakeSubject = {
|
|
17
|
+
focuses: { BasePart },
|
|
18
|
+
AddReplicationFocus: (FakeSubject, BasePart) -> (),
|
|
19
|
+
RemoveReplicationFocus: (FakeSubject, BasePart) -> (),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
local function newFakeSubject(): FakeSubject
|
|
23
|
+
local focuses: { BasePart } = {}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
focuses = focuses,
|
|
27
|
+
AddReplicationFocus = function(_self, part)
|
|
28
|
+
table.insert(focuses, part)
|
|
29
|
+
end,
|
|
30
|
+
RemoveReplicationFocus = function(_self, part)
|
|
31
|
+
local index = table.find(focuses, part)
|
|
32
|
+
if index then
|
|
33
|
+
table.remove(focuses, index)
|
|
34
|
+
end
|
|
35
|
+
end,
|
|
36
|
+
}
|
|
37
|
+
end
|
|
16
38
|
|
|
17
39
|
describe("ReplicationFocusTracker", function()
|
|
18
|
-
it("creates a hidden part and
|
|
19
|
-
local subject
|
|
40
|
+
it("creates a hidden part and adds it as a replication focus", function()
|
|
41
|
+
local subject = newFakeSubject()
|
|
20
42
|
local tracker = ReplicationFocusTracker.new(subject :: any)
|
|
21
43
|
|
|
22
44
|
tracker:SetPosition(Vector3.new(1, 2, 3))
|
|
23
45
|
|
|
24
|
-
local part = subject.
|
|
46
|
+
local part = subject.focuses[1]
|
|
25
47
|
assert(part, "expected a focus part")
|
|
26
48
|
expect(part:IsA("BasePart")).toEqual(true)
|
|
27
49
|
expect(part.Position).toEqual(Vector3.new(1, 2, 3))
|
|
@@ -32,23 +54,24 @@ describe("ReplicationFocusTracker", function()
|
|
|
32
54
|
end)
|
|
33
55
|
|
|
34
56
|
it("reuses the same part across position updates", function()
|
|
35
|
-
local subject
|
|
57
|
+
local subject = newFakeSubject()
|
|
36
58
|
local tracker = ReplicationFocusTracker.new(subject :: any)
|
|
37
59
|
|
|
38
60
|
tracker:SetPosition(Vector3.new(1, 0, 0))
|
|
39
|
-
local first = subject.
|
|
61
|
+
local first = subject.focuses[1]
|
|
40
62
|
assert(first, "expected a focus part")
|
|
41
63
|
|
|
42
64
|
tracker:SetPosition(Vector3.new(5, 0, 0))
|
|
43
65
|
|
|
44
|
-
expect(subject.
|
|
66
|
+
expect(#subject.focuses).toEqual(1)
|
|
67
|
+
expect(subject.focuses[1]).toBe(first)
|
|
45
68
|
expect(first.Position).toEqual(Vector3.new(5, 0, 0))
|
|
46
69
|
|
|
47
70
|
tracker:Destroy()
|
|
48
71
|
end)
|
|
49
72
|
|
|
50
73
|
it("reports active state", function()
|
|
51
|
-
local subject
|
|
74
|
+
local subject = newFakeSubject()
|
|
52
75
|
local tracker = ReplicationFocusTracker.new(subject :: any)
|
|
53
76
|
|
|
54
77
|
expect(tracker:IsActive()).toEqual(false)
|
|
@@ -58,27 +81,44 @@ describe("ReplicationFocusTracker", function()
|
|
|
58
81
|
tracker:Destroy()
|
|
59
82
|
end)
|
|
60
83
|
|
|
61
|
-
it("
|
|
62
|
-
local subject
|
|
84
|
+
it("removes the replication focus and destroys the part on Destroy", function()
|
|
85
|
+
local subject = newFakeSubject()
|
|
63
86
|
local tracker = ReplicationFocusTracker.new(subject :: any)
|
|
64
87
|
|
|
65
88
|
tracker:SetPosition(Vector3.new(1, 2, 3))
|
|
66
|
-
local part = subject.
|
|
89
|
+
local part = subject.focuses[1]
|
|
67
90
|
assert(part, "expected a focus part")
|
|
68
91
|
|
|
69
92
|
tracker:Destroy()
|
|
70
93
|
|
|
71
|
-
expect(subject.
|
|
94
|
+
expect(#subject.focuses).toEqual(0)
|
|
72
95
|
-- Destroyed parts are reparented to nil.
|
|
73
96
|
expect(part.Parent).toEqual(nil)
|
|
74
97
|
end)
|
|
75
98
|
|
|
76
99
|
it("does nothing to a subject that was never positioned", function()
|
|
77
|
-
local subject
|
|
100
|
+
local subject = newFakeSubject()
|
|
78
101
|
local tracker = ReplicationFocusTracker.new(subject :: any)
|
|
79
102
|
|
|
80
103
|
tracker:Destroy()
|
|
81
104
|
|
|
82
|
-
expect(subject.
|
|
105
|
+
expect(#subject.focuses).toEqual(0)
|
|
106
|
+
end)
|
|
107
|
+
|
|
108
|
+
it("adds and removes the focus through the PlayerMock seam", function()
|
|
109
|
+
local player = PlayerMock.new()
|
|
110
|
+
local tracker = ReplicationFocusTracker.new(player)
|
|
111
|
+
|
|
112
|
+
tracker:SetPosition(Vector3.new(1, 2, 3))
|
|
113
|
+
|
|
114
|
+
local focuses = PlayerMock.getReplicationFocuses(player)
|
|
115
|
+
expect(#focuses).toEqual(1)
|
|
116
|
+
expect(focuses[1].Position).toEqual(Vector3.new(1, 2, 3))
|
|
117
|
+
|
|
118
|
+
tracker:Destroy()
|
|
119
|
+
|
|
120
|
+
expect(#PlayerMock.getReplicationFocuses(player)).toEqual(0)
|
|
121
|
+
|
|
122
|
+
player:Destroy()
|
|
83
123
|
end)
|
|
84
124
|
end)
|