@quenty/streamingutils 10.20.0 → 10.22.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/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
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.22.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.21.0...@quenty/streamingutils@10.22.0) (2026-07-22)
7
+
8
+ **Note:** Version bump only for package @quenty/streamingutils
9
+
10
+ # [10.21.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.20.0...@quenty/streamingutils@10.21.0) (2026-07-20)
11
+
12
+ ### Features
13
+
14
+ - Integrate teleport data service directly into save slots for cross-game continuation ([ffa7496](https://github.com/Quenty/NevermoreEngine/commit/ffa7496047b80a82623440221c019fdfe09e31ea))
15
+ - **streamingutils:** add StreamingCinematicsService for camera streaming focus ([4853cd6](https://github.com/Quenty/NevermoreEngine/commit/4853cd6d64c9ca32ea454ca7ebe69697c0baba1e))
16
+
6
17
  # [10.20.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.19.0...@quenty/streamingutils@10.20.0) (2026-07-18)
7
18
 
8
19
  **Note:** Version bump only for package @quenty/streamingutils
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 93308475016118,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/streamingutils",
3
- "version": "10.20.0",
3
+ "version": "10.22.0",
4
4
  "description": "Provides utilities for working with Roblox's streaming system",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -29,11 +29,18 @@
29
29
  "Quenty"
30
30
  ],
31
31
  "dependencies": {
32
+ "@quenty/instanceutils": "13.32.0",
32
33
  "@quenty/loader": "10.11.0",
33
- "@quenty/promise": "10.20.0"
34
+ "@quenty/maid": "3.10.0",
35
+ "@quenty/nevermore-test-runner": "1.4.0",
36
+ "@quenty/promise": "10.20.0",
37
+ "@quenty/remoting": "12.35.0",
38
+ "@quenty/rx": "13.30.0",
39
+ "@quenty/servicebag": "11.19.0",
40
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
34
41
  },
35
42
  "publishConfig": {
36
43
  "access": "public"
37
44
  },
38
- "gitHead": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
45
+ "gitHead": "93f15b200c0f465a0b5304fcf05f5cfe7fdcbd1d"
39
46
  }
@@ -0,0 +1,122 @@
1
+ --!strict
2
+ --[=[
3
+ Client half of the streaming-cinematics system. While a cinematic camera is active, feeds the
4
+ camera position up to the [StreamingCinematicsService] (throttled) so the server can stream world
5
+ content in around it -- covering the cases where the player has no character, or the character is
6
+ far from the camera.
7
+
8
+ @client
9
+ @class StreamingCinematicsServiceClient
10
+ ]=]
11
+
12
+ local require = require(script.Parent.loader).load(script)
13
+
14
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
15
+ local Workspace = game:GetService("Workspace")
16
+
17
+ local Maid = require("Maid")
18
+ local Observable = require("Observable")
19
+ local Remoting = require("Remoting")
20
+ local Rx = require("Rx")
21
+ local RxInstanceUtils = require("RxInstanceUtils")
22
+ local ServiceBag = require("ServiceBag")
23
+
24
+ -- A handful of updates per second is plenty for streaming; the pan is slow and the streaming radius
25
+ -- dwarfs the per-update movement. leading+trailing so the first and final resting positions both send.
26
+ local SEND_RATE_SECONDS = 0.25
27
+
28
+ local StreamingCinematicsServiceClient = {}
29
+ StreamingCinematicsServiceClient.ServiceName = "StreamingCinematicsServiceClient"
30
+
31
+ export type StreamingCinematicsServiceClient = typeof(setmetatable(
32
+ {} :: {
33
+ _serviceBag: ServiceBag.ServiceBag,
34
+ _maid: Maid.Maid,
35
+ _remoting: any,
36
+ },
37
+ {} :: typeof({ __index = StreamingCinematicsServiceClient })
38
+ ))
39
+
40
+ function StreamingCinematicsServiceClient.Init(
41
+ self: StreamingCinematicsServiceClient,
42
+ serviceBag: ServiceBag.ServiceBag
43
+ ): ()
44
+ assert(not (self :: any)._remoting, "Already initialized")
45
+ self._serviceBag = assert(serviceBag, "No serviceBag")
46
+ self._maid = Maid.new()
47
+
48
+ self._remoting = self._maid:Add(Remoting.Client.new(ReplicatedStorage, "StreamingCinematics"))
49
+ end
50
+
51
+ --[=[
52
+ Sends a single focus position to the server (or nil to clear). Low-level; prefer
53
+ [StreamingCinematicsServiceClient:PushCameraFocus].
54
+ @param position Vector3?
55
+ ]=]
56
+ function StreamingCinematicsServiceClient.SetFocus(self: StreamingCinematicsServiceClient, position: Vector3?): ()
57
+ self._remoting.SetFocus:FireServer(position)
58
+ end
59
+
60
+ --[=[
61
+ Observes the current camera's position, following [Workspace.CurrentCamera] as it changes.
62
+ @return Observable<Vector3?>
63
+ ]=]
64
+ function StreamingCinematicsServiceClient.ObserveCurrentCameraPosition(
65
+ _self: StreamingCinematicsServiceClient
66
+ ): Observable.Observable<Vector3?>
67
+ local observeCamera: any = RxInstanceUtils.observeProperty(Workspace, "CurrentCamera")
68
+
69
+ local switchToCFrame: any = Rx.switchMap(function(camera): any
70
+ if not camera then
71
+ return Rx.of(nil)
72
+ end
73
+ return RxInstanceUtils.observeProperty(camera, "CFrame")
74
+ end)
75
+ local toPosition: any = Rx.map(function(cframe): Vector3?
76
+ return if cframe then cframe.Position else nil
77
+ end)
78
+
79
+ return observeCamera:Pipe({ switchToCFrame, toPosition })
80
+ end
81
+
82
+ --[=[
83
+ Streams world content around a cinematic camera until the returned cleanup runs. Defaults to
84
+ following the current camera; pass an observable of `Vector3?` to drive the focus explicitly.
85
+ Sends are throttled to [SEND_RATE_SECONDS].
86
+
87
+ @param observePosition Observable<Vector3?>? -- Optional override
88
+ @return function -- Cleanup; clears the focus when called
89
+ ]=]
90
+ function StreamingCinematicsServiceClient.PushCameraFocus(
91
+ self: StreamingCinematicsServiceClient,
92
+ observePosition: Observable.Observable<Vector3?>?
93
+ ): () -> ()
94
+ local source: any = observePosition or self:ObserveCurrentCameraPosition()
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
116
+ end
117
+
118
+ function StreamingCinematicsServiceClient.Destroy(self: StreamingCinematicsServiceClient): ()
119
+ self._maid:DoCleaning()
120
+ end
121
+
122
+ return StreamingCinematicsServiceClient
@@ -0,0 +1,91 @@
1
+ --!strict
2
+ --[=[
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 points that
5
+ player's `ReplicationFocus` at it via a [ReplicationFocusTracker], so world content streams
6
+ in around the cinematic camera. Sending nil clears it.
7
+
8
+ @server
9
+ @class StreamingCinematicsService
10
+ ]=]
11
+
12
+ local require = require(script.Parent.loader).load(script)
13
+
14
+ local Players = game:GetService("Players")
15
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
16
+
17
+ local Maid = require("Maid")
18
+ local Remoting = require("Remoting")
19
+ local ReplicationFocusTracker = require("ReplicationFocusTracker")
20
+
21
+ local StreamingCinematicsService = {}
22
+ StreamingCinematicsService.ServiceName = "StreamingCinematicsService"
23
+
24
+ export type StreamingCinematicsService = typeof(setmetatable(
25
+ {} :: {
26
+ _maid: Maid.Maid,
27
+ _trackers: { [Player]: ReplicationFocusTracker.ReplicationFocusTracker },
28
+ _remoting: any,
29
+ },
30
+ {} :: typeof({ __index = StreamingCinematicsService })
31
+ ))
32
+
33
+ function StreamingCinematicsService.Init(self: StreamingCinematicsService): ()
34
+ assert(not (self :: any)._remoting, "Already initialized")
35
+ self._maid = Maid.new()
36
+
37
+ self._trackers = {}
38
+
39
+ self._remoting = self._maid:Add(Remoting.Server.new(ReplicatedStorage, "StreamingCinematics"))
40
+ self._remoting:DeclareEvent("SetFocus")
41
+
42
+ self._maid:GiveTask(self._remoting.SetFocus:Connect(function(player: Player, position: Vector3?)
43
+ self:_setFocus(player, position)
44
+ end))
45
+
46
+ self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player: Player)
47
+ self:_clearFocus(player)
48
+ end))
49
+ end
50
+
51
+ function StreamingCinematicsService._setFocus(self: StreamingCinematicsService, player: Player, position: Vector3?): ()
52
+ if position == nil then
53
+ self:_clearFocus(player)
54
+ return
55
+ end
56
+
57
+ if typeof(position) ~= "Vector3" then
58
+ return
59
+ end
60
+
61
+ -- Never create a tracker for a player who is already leaving/gone; PlayerRemoving owns their
62
+ -- cleanup, so a late remote must not resurrect per-player state.
63
+ if not player:IsDescendantOf(game) then
64
+ return
65
+ end
66
+
67
+ local tracker = self._trackers[player]
68
+ if not tracker then
69
+ tracker = ReplicationFocusTracker.new(player)
70
+ self._trackers[player] = tracker
71
+ end
72
+
73
+ tracker:SetPosition(position)
74
+ end
75
+
76
+ function StreamingCinematicsService._clearFocus(self: StreamingCinematicsService, player: Player): ()
77
+ local tracker = self._trackers[player]
78
+ if tracker then
79
+ tracker:Destroy()
80
+ self._trackers[player] = nil
81
+ end
82
+ end
83
+
84
+ function StreamingCinematicsService.Destroy(self: StreamingCinematicsService): ()
85
+ for player in self._trackers do
86
+ self:_clearFocus(player)
87
+ end
88
+ self._maid:DoCleaning()
89
+ end
90
+
91
+ return StreamingCinematicsService
@@ -0,0 +1,87 @@
1
+ --!strict
2
+ --[=[
3
+ Keeps a single hidden part positioned at a point and assigned as a [Player]'s
4
+ `ReplicationFocus`, so Roblox streams world content around that point. Reuses one part
5
+ across position updates and clears the focus (and destroys the part) on cleanup.
6
+
7
+ The subject is duck-typed at runtime -- anything with a settable `ReplicationFocus` -- so tests
8
+ pass a plain table; the production caller always passes a [Player].
9
+
10
+ @class ReplicationFocusTracker
11
+ ]=]
12
+
13
+ local require = require(script.Parent.loader).load(script)
14
+
15
+ local Workspace = game:GetService("Workspace")
16
+
17
+ local Maid = require("Maid")
18
+
19
+ local FOCUS_PART_NAME = "StreamingCinematicFocus"
20
+
21
+ local ReplicationFocusTracker = {}
22
+ ReplicationFocusTracker.ClassName = "ReplicationFocusTracker"
23
+ ReplicationFocusTracker.__index = ReplicationFocusTracker
24
+
25
+ export type ReplicationFocusTracker = typeof(setmetatable(
26
+ {} :: {
27
+ _subject: Player,
28
+ _maid: Maid.Maid,
29
+ _part: BasePart?,
30
+ },
31
+ {} :: typeof({ __index = ReplicationFocusTracker })
32
+ ))
33
+
34
+ function ReplicationFocusTracker.new(subject: Player): ReplicationFocusTracker
35
+ local self: ReplicationFocusTracker = setmetatable({} :: any, ReplicationFocusTracker)
36
+
37
+ self._subject = assert(subject, "No subject")
38
+ self._maid = Maid.new()
39
+
40
+ return self
41
+ end
42
+
43
+ --[=[
44
+ Moves the focus to `position`, creating and assigning the part on first call.
45
+ @param position Vector3
46
+ ]=]
47
+ function ReplicationFocusTracker.SetPosition(self: ReplicationFocusTracker, position: Vector3): ()
48
+ assert(typeof(position) == "Vector3", "Bad position")
49
+
50
+ local existing = self._part
51
+ if existing then
52
+ existing.Position = position
53
+ return
54
+ end
55
+
56
+ local part = Instance.new("Part")
57
+ part.Name = FOCUS_PART_NAME
58
+ part.Anchored = true
59
+ part.CanCollide = false
60
+ part.CanQuery = false
61
+ part.CanTouch = false
62
+ part.Transparency = 1
63
+ part.Size = Vector3.one
64
+ part.Archivable = false
65
+ part.Position = position
66
+ part.Parent = Workspace.Terrain
67
+
68
+ self._part = part
69
+ self._maid:GiveTask(part)
70
+ self._subject.ReplicationFocus = part
71
+ end
72
+
73
+ --[=[
74
+ Whether a focus part currently exists (i.e. [ReplicationFocusTracker:SetPosition] has run).
75
+ @return boolean
76
+ ]=]
77
+ function ReplicationFocusTracker.IsActive(self: ReplicationFocusTracker): boolean
78
+ return self._part ~= nil
79
+ end
80
+
81
+ function ReplicationFocusTracker.Destroy(self: ReplicationFocusTracker): ()
82
+ self._subject.ReplicationFocus = nil
83
+ self._maid:DoCleaning()
84
+ self._part = nil
85
+ end
86
+
87
+ return ReplicationFocusTracker
@@ -0,0 +1,84 @@
1
+ --!strict
2
+ --[[
3
+ @class ReplicationFocusTracker.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local ReplicationFocusTracker = require("ReplicationFocusTracker")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local expect = Jest.Globals.expect
13
+ local it = Jest.Globals.it
14
+
15
+ type FakeSubject = { ReplicationFocus: BasePart? }
16
+
17
+ describe("ReplicationFocusTracker", function()
18
+ it("creates a hidden part and assigns it as the subject's ReplicationFocus", function()
19
+ local subject: FakeSubject = {}
20
+ local tracker = ReplicationFocusTracker.new(subject :: any)
21
+
22
+ tracker:SetPosition(Vector3.new(1, 2, 3))
23
+
24
+ local part = subject.ReplicationFocus
25
+ assert(part, "expected a focus part")
26
+ expect(part:IsA("BasePart")).toEqual(true)
27
+ expect(part.Position).toEqual(Vector3.new(1, 2, 3))
28
+ expect(part.Anchored).toEqual(true)
29
+ expect(part.CanCollide).toEqual(false)
30
+
31
+ tracker:Destroy()
32
+ end)
33
+
34
+ it("reuses the same part across position updates", function()
35
+ local subject: FakeSubject = {}
36
+ local tracker = ReplicationFocusTracker.new(subject :: any)
37
+
38
+ tracker:SetPosition(Vector3.new(1, 0, 0))
39
+ local first = subject.ReplicationFocus
40
+ assert(first, "expected a focus part")
41
+
42
+ tracker:SetPosition(Vector3.new(5, 0, 0))
43
+
44
+ expect(subject.ReplicationFocus).toBe(first)
45
+ expect(first.Position).toEqual(Vector3.new(5, 0, 0))
46
+
47
+ tracker:Destroy()
48
+ end)
49
+
50
+ it("reports active state", function()
51
+ local subject: FakeSubject = {}
52
+ local tracker = ReplicationFocusTracker.new(subject :: any)
53
+
54
+ expect(tracker:IsActive()).toEqual(false)
55
+ tracker:SetPosition(Vector3.new(0, 0, 0))
56
+ expect(tracker:IsActive()).toEqual(true)
57
+
58
+ tracker:Destroy()
59
+ end)
60
+
61
+ it("clears the ReplicationFocus and destroys the part on Destroy", function()
62
+ local subject: FakeSubject = {}
63
+ local tracker = ReplicationFocusTracker.new(subject :: any)
64
+
65
+ tracker:SetPosition(Vector3.new(1, 2, 3))
66
+ local part = subject.ReplicationFocus
67
+ assert(part, "expected a focus part")
68
+
69
+ tracker:Destroy()
70
+
71
+ expect(subject.ReplicationFocus).toEqual(nil)
72
+ -- Destroyed parts are reparented to nil.
73
+ expect(part.Parent).toEqual(nil)
74
+ end)
75
+
76
+ it("does nothing to a subject that was never positioned", function()
77
+ local subject: FakeSubject = {}
78
+ local tracker = ReplicationFocusTracker.new(subject :: any)
79
+
80
+ tracker:Destroy()
81
+
82
+ expect(subject.ReplicationFocus).toEqual(nil)
83
+ end)
84
+ end)
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "StreamingUtilsTest",
3
+ "tree": {
4
+ "$className": "DataModel",
5
+ "ServerScriptService": {
6
+ "$properties": {
7
+ "LoadStringEnabled": true
8
+ },
9
+ "streamingutils": {
10
+ "$path": ".."
11
+ },
12
+ "Script": {
13
+ "$path": "scripts/Server"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ --!nonstrict
2
+ --[[
3
+ @class ServerMain
4
+ ]]
5
+ local ServerScriptService = game:GetService("ServerScriptService")
6
+
7
+ local root = ServerScriptService.streamingutils
8
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
9
+ local require = require(loader).bootstrapGame(root)
10
+
11
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
12
+
13
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
14
+ return
15
+ end