Skip to content

Commit f08dd7b

Browse files
authored
Merge pull request #24 from C6H15/add-tests
Player Tracking Stability & Testing
2 parents 073fa06 + adef227 commit f08dd7b

11 files changed

Lines changed: 126 additions & 29 deletions

File tree

docs/intro.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ This test highlights the fundamental flaw in traditional Zone-Centric libraries.
8989
Add the following to your wally.toml file:
9090

9191
```toml
92-
ldgerrits/quickzone@1.4.4
92+
ldgerrits/quickzone@1.4.5
9393
```
9494

9595
### NPM

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rbxts/quickzone",
3-
"version": "1.4.4",
3+
"version": "1.4.5",
44
"description": "A high-performance, physics-free spatial query library for Roblox. Maintain 60 FPS with 1M+ zones.",
55
"main": "src/init.lua",
66
"keywords": [

src/Classes/Group.luau

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ function Group._remove(self: Types.InternalGroup, reference: any): Types.Group
685685
State.dirtyTopology[entity] = nil
686686

687687
local ref = State.entityToReference[entity]
688-
if ref then
688+
if ref and not (typeof(ref) == 'Instance' and ref:IsA('Player')) then
689689
State.entityToReference[entity] = nil
690690
-- Ensure we only clear the reverse map if it still points to this exact entity
691691
if State.referenceToEntity[ref] == entity then

src/Classes/Observer.luau

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,31 +113,34 @@ local Observer = {}
113113
Observer.__index = Observer
114114

115115
--[=[
116-
Creates an Observer. Observers listen for entities entering or exiting assigned Zones.
116+
Creates an Observer. Observers listen for entities subscribed Groups entering or exiting attached Zones.
117117
118118
```lua
119119
local observer = QuickZone.Observer.new({
120120
groups = { enemyGroup, playerGroup } -- Immediately subscribe to these groups
121121
zones = { damageZone } -- Immediately subscribe to these zones
122-
priority = 20, -- The priority value is used to resolve overlaps
123-
updateRate = 20, -- Check at 20Hz
124-
precision = 0.5, -- Ignore movement smaller than 0.5 studs
125-
enabled = false, -- Observer will not start processing spatial checks
126-
safety = false, -- Do not wrap callbacks in task.spawn
122+
priority = 20, -- The priority value is used to resolve overlaps (defaults to 0)
123+
updateRate = 20, -- Check at 20Hz (defaults to 30)
124+
precision = 0.5, -- Ignore movement smaller than 0.5 studs (defaults to 0.1)
125+
enabled = false, -- Observer will not start processing spatial checks (defaults to true)
126+
safety = false, -- Wrap callbacks in task.spawn (defaults to true)
127127
})
128128
```
129129
130+
:::warning Safety
131+
If safety is set to false, any yielding will result in breaking QuickZone.
132+
Only set it to false if you do not want the overhead of virtual threads.
133+
:::
134+
130135
:::info Resolution Priority
131136
When an entity is inside multiple zones watched by different observers,
132137
higher priority observers take complete control.
133138
:::
134139
135-
:::warning Safety
136-
If set to unsafe, you are not allowed to yield in the callbacks anymore. If you do, QuickZone will throw errors.
137-
:::
138-
139140
:::note Dynamic Instantiation
140-
QuickZone will warn you if an Observer is created without groups or zones. To suppress this warning when building observers dynamically, explicitly pass an empty table (`zones = {}`) or instantiate the observer as disabled (`enabled = false`).
141+
QuickZone will warn you if an Observer is created without groups or zones.
142+
To suppress this warning when building observers dynamically,
143+
explicitly pass an empty table (`zones = {}`) or instantiate the observer as disabled (`enabled = false`).
141144
:::
142145
143146
@tag Constructor

src/Classes/Zone.luau

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ local function setupAutoSync(zone: Types.InternalZone, reference: BasePart | Att
100100
end
101101

102102
zone.syncConnections = connections
103+
zone:sync()
103104
end
104105

105106
local function updateObserverZoneCount(observerId: number, isDynamic: boolean, delta: number)
@@ -110,6 +111,11 @@ local function updateObserverZoneCount(observerId: number, isDynamic: boolean, d
110111

111112
-- Only flag entities dirty if the observer's topology fundamentally changed
112113
if (current == 0 and newCount == 1) or (current == 1 and newCount == 0) then
114+
-- Only force a global logic override when an observer loses its last zone
115+
if current == 1 and newCount == 0 then
116+
State.logicVersion += 1
117+
end
118+
113119
for groupId, observers in State.groupToObservers do
114120
if not observers[observerId] then
115121
continue

src/Core/Scheduler.luau

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ function Scheduler.update(dt: number)
460460
ctx_candidateIndex[observerId] = nil
461461
ctx_candidateList[i] = nil
462462

463+
if not observerTrackingEntities[observerId] then
464+
continue
465+
end
466+
463467
if priority < ctx_highestWin then
464468
continue
465469
end

src/init.luau

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
--!strict
2-
--v1.4.4 • LDGerrits
2+
--v1.4.5 • LDGerrits
33
--[=[
44
@class QuickZone
55
@@ -169,17 +169,22 @@ end
169169
@return QuickZone
170170
]=]
171171
function QuickZone:setReference(entity: Entity, reference: any?): QuickZone
172+
local oldReference = entityToReference[entity]
173+
172174
if reference == nil then
173-
local oldReference = entityToReference[entity]
174-
if oldReference then
175-
referenceToEntity[oldReference] = nil
175+
if not (typeof(oldReference) == 'Instance' and oldReference:IsA('Player')) then
176+
if oldReference then
177+
referenceToEntity[oldReference] = nil
178+
end
179+
entityToReference[entity] = nil
176180
end
177-
entityToReference[entity] = nil
178181
return self
179182
end
180183

181-
entityToReference[entity] = reference
182-
referenceToEntity[reference] = entity
184+
if not (typeof(reference) == 'Instance' and reference:IsA('Player')) then
185+
entityToReference[entity] = reference
186+
referenceToEntity[reference] = entity
187+
end
183188

184189
return self
185190
end

tests/Specifications/2-Zone.spec.luau

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -616,13 +616,17 @@ return function(Tiniest: Tiniest)
616616
end)
617617

618618
Test('Rewires autoSync to the new reference, disconnecting from the old one.', function()
619-
local size = Vector3.one * 20
619+
local size = Vector3.one * 50
620620
local partA = CreatePart({ Size = Vector3.one * 10 })
621-
local partB = CreatePart({ Size = Vector3.one * 10 })
621+
local partB = CreatePart({ Size = Vector3.one * 25 })
622622
local zone = Zone.fromPart(partA, { autoSync = true })
623623

624+
Expect(zone:getSize()).is(Vector3.one * 10)
625+
624626
zone:setReference(partB)
625627

628+
Expect(zone:getSize()).is(Vector3.one * 25)
629+
626630
partB.Size = size
627631
task.wait()
628632

tests/Specifications/4-Observer.spec.luau

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,32 @@ return function(Tiniest: Tiniest)
237237
observer:destroy()
238238
zone:destroy()
239239
end)
240+
241+
Test('Detaching the only attached zone fires onExit for its entities.', function()
242+
local exits = 0
243+
local part = CreatePart()
244+
local zone = Zone.new({ cframe = CFrame.identity, size = Vector3.one * 10 })
245+
local group = Group.new({ entities = { part } })
246+
local observer = Observer.new({ zones = { zone }, groups = { group }, safety = false })
247+
248+
observer:onExit(function()
249+
exits += 1
250+
end)
251+
QuickZone:update(1 / 10)
252+
253+
Expect(exits).is(0)
254+
255+
observer:detach(zone)
256+
QuickZone:update(1 / 10)
257+
258+
Expect(exits).is(1)
259+
Expect(#observer:getEntitiesInside()).is(0)
260+
261+
observer:destroy()
262+
group:destroy()
263+
zone:destroy()
264+
part:Destroy()
265+
end)
240266
end)
241267

242268
Describe('Observer:observe', function()
@@ -1067,7 +1093,7 @@ return function(Tiniest: Tiniest)
10671093
local part = CreatePart({ CFrame = CFrame.new(3, 0, 0) })
10681094
local zone = Zone.new({ cframe = CFrame.identity, size = Vector3.one * 10 })
10691095
local group = Group.new({ entities = { part } })
1070-
local observer = Observer.new({ zones = { zone }, groups = { group }, precision = 0.1, safety = false })
1096+
local observer = Observer.new({ zones = { zone }, groups = { group }, precision = 0.5, safety = false })
10711097

10721098
observer:onExit(function()
10731099
exits += 1
@@ -1086,6 +1112,34 @@ return function(Tiniest: Tiniest)
10861112
zone:destroy()
10871113
part:Destroy()
10881114
end)
1115+
1116+
Test('The tighter precision applies when two observers share an entity.', function()
1117+
local laxExits, tightExits = 0, 0
1118+
local part = CreatePart({ CFrame = CFrame.new(3, 0, 0) })
1119+
local zone = Zone.new({ cframe = CFrame.identity, size = Vector3.one * 10 })
1120+
local group = Group.new({ entities = { part } })
1121+
local lax = Observer.new({ zones = { zone }, groups = { group }, precision = 5, safety = false })
1122+
local tight = Observer.new({ zones = { zone }, groups = { group }, precision = 0.5, safety = false })
1123+
1124+
lax:onExit(function()
1125+
laxExits += 1
1126+
end)
1127+
tight:onExit(function()
1128+
tightExits += 1
1129+
end)
1130+
QuickZone:update(1 / 10)
1131+
part.CFrame = CFrame.new(5.5, 0, 0)
1132+
QuickZone:update(1 / 10)
1133+
1134+
Expect(laxExits).is(1)
1135+
Expect(tightExits).is(1)
1136+
1137+
tight:destroy()
1138+
lax:destroy()
1139+
group:destroy()
1140+
zone:destroy()
1141+
part:Destroy()
1142+
end)
10891143
end)
10901144

10911145
Describe('Observer:isPointInside', function()
@@ -1166,12 +1220,14 @@ return function(Tiniest: Tiniest)
11661220
group = Group.new({ entities = { entity } })
11671221
observer = Observer.new({ zones = { zones }, groups = { group } })
11681222
task.wait()
1223+
QuickZone:update(1 / 10)
11691224

11701225
Expect(#observer:getEntitiesInside()).is(1)
11711226

11721227
-- Streaming out
11731228
part.Parent = nil
11741229
task.wait()
1230+
QuickZone:update(1 / 10)
11751231

11761232
Expect(#observer:getEntitiesInside()).is(0)
11771233

@@ -1587,5 +1643,26 @@ return function(Tiniest: Tiniest)
15871643
zone:destroy()
15881644
part:Destroy()
15891645
end)
1646+
1647+
Test('Observer keeps tracking the shared entity after destroying another observer.', function()
1648+
local part = CreatePart()
1649+
local zone = Zone.new({ cframe = CFrame.identity, size = Vector3.one * 10 })
1650+
local group = Group.new({ entities = { part } })
1651+
local observerA = Observer.new({ zones = { zone }, groups = { group }, safety = false })
1652+
local observerB = Observer.new({ zones = { zone }, groups = { group }, safety = false })
1653+
1654+
observerA:onEnter(function()
1655+
observerB:destroy()
1656+
end)
1657+
QuickZone:update(1 / 10)
1658+
1659+
Expect(observerA:getEntitiesInside()).has_value(part)
1660+
Expect(observerA:getZoneOfEntity(part)).is(zone)
1661+
1662+
observerA:destroy()
1663+
group:destroy()
1664+
zone:destroy()
1665+
part:Destroy()
1666+
end)
15901667
end)
15911668
end

tests/Util.luau

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ type InstanceTree = {
66
}
77

88
local PART_DEFAULTS: { [string]: any } = {
9-
Transparency = 1,
9+
Transparency = 1,
1010
Anchored = true,
1111
CanCollide = false,
1212
CanQuery = false,
1313
CanTouch = false,
1414
AudioCanCollide = false,
1515
EnableFluidForces = false,
16-
Parent = workspace,
1716
}
1817

1918
local Util = {}
@@ -25,6 +24,7 @@ function Util.create<C>(tree: InstanceTree): C
2524
for property, value in PART_DEFAULTS do
2625
object[property] = value
2726
end
27+
object.Parent = workspace
2828
end
2929
if tree.properties ~= nil then
3030
for property, value in tree.properties do
@@ -49,11 +49,9 @@ end
4949

5050
function Util.createParts(count: number, properties: { [string]: any }?): { BasePart }
5151
local parts: { BasePart } = table.create(count)
52-
5352
for i = 1, count do
5453
table.insert(parts, Util.createPart(properties))
5554
end
56-
5755
return parts
5856
end
5957

0 commit comments

Comments
 (0)