diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 98045a6..a9b6740 100755 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,7 +5,7 @@ on: tags: ["v*"] jobs: - release: + build: name: Release runs-on: ubuntu-latest steps: @@ -38,4 +38,4 @@ jobs: - name: Publish run: npm publish env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d08086..8846380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ The format is based on [Keep a Changelog][kac], and this project adheres to ## [Unreleased] + +## [0.8.0] - 2024-04-22 + +### Changed +- Added `Views` for random-accessing entities within queries. + - Views are optimized for terse indexing, making them useful for traversing graphs of entities. +- Added `Debugger.loopParameterNames` which allows for labelling things passed to Loop. +- Disabled `AutoLocalize` on many Plasma Widgets. + - This removes unnecessary computations for `LocalizationService::attemptLocalization`. +- Improved `QueryResult:without` to narrow archetype invariants. + - The filter now works on the archetype-level rather than filtering entities + ad-hoc which will immensely improve query performance. + +### Fixed + +- Fixed the Scheduler not respecting priorties of systems. +- Fixed padding of items in the Debugger's state view. ## [0.7.1] - 2024-01-31 ### Changed diff --git a/README.md b/README.md index 871ac69..f6679e0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Docs status + + OSS Discord +
diff --git a/docs/BestPractices/Reconciliation.md b/docs/BestPractices/Reconciliation.md index 8b1bfd6..ef16f1c 100644 --- a/docs/BestPractices/Reconciliation.md +++ b/docs/BestPractices/Reconciliation.md @@ -26,7 +26,7 @@ Reconciliation, in this context, means taking state from one form and turning it When writing code in an ECS like Matter, it's ideal for all of our gameplay code to operate on the ECS world alone. In the Matter example game, for example, there are ships that fly to certain points in space. For example, instead of updating the ships in the Data Model directly, we store the current goal position in the Ship component. The Ship component knows nothing about the Data Model. It has no reference to the physical ship Instance in the Data Model, it only contains the state about the ship. -We can create another component (in the [Matter example game](https://github.com/evaera/matter/tree/main/example), we call it `Model`) that holds a reference to the ship Instance. +We can create another component (in the [Matter example game](https://github.com/matter-ecs/matter/tree/main/example), we call it `Model`) that holds a reference to the ship Instance. We can loop over all Ships that don't also have a Model, and create one for it. @@ -116,7 +116,6 @@ We can make a system that handles both of these cases for us. for id, transformRecord in world:queryChanged(Transform) do local model = world:get(id, Model) - -- Take care to ignore the changed event if it was us that triggered it if model and transformRecord.new and not transformRecord.new.doNotReconcile then model.instance:SetPrimaryPartCFrame(transformRecord.new.cframe) @@ -127,7 +126,6 @@ end for id, modelRecord in world:queryChanged(Model) do local transform = world:get(id, Transform) - if transform and modelRecord.new then modelRecord.new.model:SetPrimaryPartCFrame(transform.cframe) end diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index aa5a029..398d654 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -91,4 +91,4 @@ value, so we don't have to mutate the existing component. ## Next steps You should dive in to the [API reference](/api/Matter)! The Matter API is simple and documented in detail. -And if you haven't already, check out the [`/example` directory in the matter repo](https://github.com/evaera/matter/tree/main/example/). +And if you haven't already, check out the [`/example` directory in the matter repo](https://github.com/matter-ecs/matter/tree/main/example/). diff --git a/docs/Guides/CollectionService.md b/docs/Guides/CollectionService.md index 88a64e9..ac87c28 100644 --- a/docs/Guides/CollectionService.md +++ b/docs/Guides/CollectionService.md @@ -4,7 +4,7 @@ sidebar_position: 4 # Using CollectionService tags -As a pure ECS first and foremost, Matter provides no special functionality for CollectionService tags out of the box. However, it's rather simple to implement this yourself. Here's an example taken from the official [Matter example game](https://github.com/evaera/matter/tree/main/example/server). +As a pure ECS first and foremost, Matter provides no special functionality for CollectionService tags out of the box. However, it's rather simple to implement this yourself. Here's an example taken from the official [Matter example game](https://github.com/matter-ecs/matter/tree/main/example/src/server). ```lua local CollectionService = game:GetService("CollectionService") @@ -52,4 +52,8 @@ return setupTags ``` -This example can be modified to meet your game's needs as you see fit. \ No newline at end of file +<<<<<<< HEAD +This example can be modified to meet your game's needs as you see fit. +======= +This example can be modified to meet your game's needs as you see fit. +>>>>>>> 627635d72d2278846eacb4a446ee4a2e85817fa1 diff --git a/docs/Guides/HotReloading.md b/docs/Guides/HotReloading.md index f63e681..54ba8a7 100644 --- a/docs/Guides/HotReloading.md +++ b/docs/Guides/HotReloading.md @@ -89,5 +89,5 @@ end, function(_, context) end) ``` -That's it! For a real example of this in action, check out the [Matter example game](https://github.com/evaera/matter/blob/main/example/src/shared/start.lua). +That's it! For a real example of this in action, check out the [Matter example game](https://github.com/matter-ecs/matter/blob/main/example/src/shared/start.lua). diff --git a/docs/Guides/MatterDebugger.md b/docs/Guides/MatterDebugger.md index b912f73..b9f274d 100644 --- a/docs/Guides/MatterDebugger.md +++ b/docs/Guides/MatterDebugger.md @@ -26,18 +26,20 @@ local function spinSpinners(world, _, ui) end ``` -This is accomplished using [Plasma](https://eryn.io/plasma/), an immediate-mode widget library. The widgets are only created while the debugger is active. Leaving the widget calls in your systems all the time is fine, because calling a widget function when the debugger is not open is a no-op. +This is accomplished using [Plasma](https://matter-ecs.github.io/plasma/), an immediate-mode widget library. The widgets are only created while the debugger is active. Leaving the widget calls in your systems all the time is fine, because calling a widget function when the debugger is not open is a no-op. -The [Matter example game](https://github.com/evaera/matter/blob/main/example/shared/start.lua) comes with the debugger set up already. If you want to see an example of the debugger already set up in a game, check out that page. +The [Matter example game](https://github.com/matter-ecs/matter/blob/main/example/shared/start.lua) comes with the debugger set up already. If you want to see an example of the debugger already set up in a game, check out that page. ## Adding the Matter debugger to your game ### Installing Plasma -You need to install [Plasma](https://eryn.io/plasma/) as a dependency to your project. We recommend you do this with [Wally](https://wally.run), the Roblox open source package manager. +You need to install [Plasma](https://matter-ecs.github.io/plasma/) as a dependency to your project. We recommend you do this with [Wally](https://wally.run), the Roblox open source package manager. + +To find the latest version of Plasma, check out [this page](https://wally.run/package/matter-ecs/plasma) or run `wally search matter-ecs`. ```toml title="wally.toml" [dependencies] -plasma = "evaera/plasma@0.4.2" +plasma = "matter-ecs/plasma@0.4.2" ``` ### Create the debugger @@ -145,7 +147,7 @@ The following Plasma widgets are available: - table - window -For details on these widgets, check out the [Plasma docs](https://eryn.io/plasma/api/Plasma) +For details on these widgets, check out the [Plasma docs](https://matter-ecs.github.io/plasma/api/Plasma) ## Demo videos diff --git a/docs/Guides/Migration.md b/docs/Guides/Migration.md new file mode 100644 index 0000000..9af69ea --- /dev/null +++ b/docs/Guides/Migration.md @@ -0,0 +1,8 @@ +# Migration +## Migrating from evaera/matter to matter-ecs/matter +Migrating from `evaera/matter` to `matter-ecs/matter` is easy! The only thing you need to do is change the package name in your `wally.toml` file. + + ```toml title="wally.toml" + [dependencies] + matter = "matter-ecs/matter@0.7.0" + ``` diff --git a/docs/Guides/Replication.md b/docs/Guides/Replication.md index d8beeb1..8cb0a8e 100644 --- a/docs/Guides/Replication.md +++ b/docs/Guides/Replication.md @@ -2,7 +2,7 @@ Replication is not built into Matter, but it's easy to implement yourself. This guide will give you an overview of one way to implement replication with Matter. -This article will cover the way the [Matter example game](https://github.com/evaera/matter/blob/main/example/shared/start.lua) implements replication. +This article will cover the way the [Matter example game](https://github.com/matter-ecs/matter/blob/main/example/shared/start.lua) implements replication. ## Deciding which components to replicate @@ -35,7 +35,7 @@ RemoteEvent.Name = "MatterRemote" RemoteEvent.Parent = ReplicatedStorage ``` -Let's convert the list of component names into actual components. This is assuming you have a Components module that exports your components, like [the matter example game does](https://github.com/evaera/matter/blob/main/example/shared/components.lua). +Let's convert the list of component names into actual components. This is assuming you have a Components module that exports your components, like [the matter example game does](https://github.com/matter-ecs/matter/blob/main/example/shared/components.lua). ```lua local replicatedComponents = {} diff --git a/docs/Installation.md b/docs/Installation.md index f328812..0bd9cbd 100644 --- a/docs/Installation.md +++ b/docs/Installation.md @@ -24,7 +24,7 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" [dependencies] -matter = "evaera/matter@X.X.X" # Don't copy this. This won't work. +matter = "matter-ecs/matter@X.X.X" # Don't copy this. This won't work. # Copy real string from page linked above. ``` diff --git a/lib/Loop.lua b/lib/Loop.lua index 84252ce..4534d63 100644 --- a/lib/Loop.lua +++ b/lib/Loop.lua @@ -18,14 +18,6 @@ local function systemName(system: System) return debug.info(fn, "s") .. "->" .. debug.info(fn, "n") end -local function systemPriority(system: System) - if type(system) == "table" then - return system.priority or 0 - end - - return 0 -end - --[=[ @class Loop @@ -222,9 +214,46 @@ function Loop:replaceSystem(old: System, new: System) end local function orderSystemsByDependencies(unscheduledSystems: { System }) - local sortedUnscheduledSystems = table.clone(unscheduledSystems) + local systemPriorityMap = {} + local visiting = "v" + + local function systemPriority(system: System) + local priority = systemPriorityMap[system] + + if not priority then + priority = 0 + + systemPriorityMap[system] = visiting + + if type(system) == "table" then + if system.after then + for _, dependency in system.after do + if systemPriorityMap[dependency] ~= visiting then + priority = math.max(priority, systemPriority(dependency) + 1) + else + local errorStatement = { + `Cyclic dependency detected: System '{systemName(system)}' is set to execute after System '{systemName( + dependency + )}', and vice versa. This creates a loop that prevents the systems from being able to execute in a valid order.`, + "To resolve this issue, reconsider the dependencies between these systems. One possible solution is to update the 'after' field from one of the systems.", + } + error(table.concat(errorStatement, "\n")) + end + end + elseif system.priority then + priority = system.priority + end + end + + systemPriorityMap[system] = priority + end - table.sort(sortedUnscheduledSystems, function(a, b) + return priority + end + + local scheduledSystems = table.clone(unscheduledSystems) + + table.sort(scheduledSystems, function(a, b) local priorityA = systemPriority(a) local priorityB = systemPriority(b) @@ -242,39 +271,6 @@ local function orderSystemsByDependencies(unscheduledSystems: { System }) return priorityA < priorityB end) - local scheduledSystemsSet = {} - local scheduledSystems = {} - - local visited, explored = 1, 2 - - local function scheduleSystem(system) - scheduledSystemsSet[system] = visited - - if type(system) == "table" and system.after then - for _, dependency in system.after do - if scheduledSystemsSet[dependency] == nil then - scheduleSystem(dependency) - elseif scheduledSystemsSet[dependency] == visited then - error( - `Unable to schedule systems due to cyclic dependency between: \n{systemName(system)} \nAND \n{systemName( - dependency - )}` - ) - end - end - end - - scheduledSystemsSet[system] = explored - - table.insert(scheduledSystems, system) - end - - for _, system in sortedUnscheduledSystems do - if scheduledSystemsSet[system] == nil then - scheduleSystem(system) - end - end - return scheduledSystems end diff --git a/lib/Loop.spec.lua b/lib/Loop.spec.lua index ce871ce..37ad9c9 100644 --- a/lib/Loop.spec.lua +++ b/lib/Loop.spec.lua @@ -164,6 +164,48 @@ return function() connection.default:Disconnect() end) + it("should call systems in order with dependent system after priority system", function() + local loop = Loop.new() + + local order = {} + local systemB = { + system = function() + table.insert(order, "b") + end, + } + local systemC = { + system = function() + table.insert(order, "c") + end, + priority = 1000, + } + local systemA = { + system = function() + table.insert(order, "a") + end, + after = { systemC }, + } + + loop:scheduleSystems({ + systemB, + systemC, + systemA, + }) + + local connection = loop:begin({ default = bindable.Event }) + + expect(#order).to.equal(0) + + bindable:Fire() + + expect(#order).to.equal(3) + expect(order[1]).to.equal("b") + expect(order[2]).to.equal("c") + expect(order[3]).to.equal("a") + + connection.default:Disconnect() + end) + it("should not schedule systems more than once", function() local loop = Loop.new() diff --git a/lib/World.d.ts b/lib/World.d.ts index a0b8568..a7b8952 100644 --- a/lib/World.d.ts +++ b/lib/World.d.ts @@ -120,26 +120,7 @@ export class World { * @remarks * Component values returned are nullable if the components used to search for aren't associated with the entity (in real-time). */ - public get( - entity: a, - only: T, - ): Includes ? A : never, ReturnType> extends true - ? ReturnType - : ReturnType | undefined; - - /** - * Gets a specific set of components from a specific entity in this world. - * - * @param entity - The entity ID - * @param bundle - The components to fetch - * @returns Returns the component values in the same order they were passed to. - * @remarks - * Component values returned are nullable if the components used to search for aren't associated with the entity (in real-time). - */ - public get( - entity: a, - ...bundle: T - ): LuaTuple> ? InferComponents : NullableArray>>; + public get(entity: AnyEntity, ...bundle: T): LuaTuple>>; /** * Performs a query against the entities in this World. Returns a [QueryResult](/api/QueryResult), which iterates over diff --git a/lib/World.lua b/lib/World.lua index a8d4ff3..d305a14 100644 --- a/lib/World.lua +++ b/lib/World.lua @@ -192,9 +192,27 @@ function World:_newQueryArchetype(queryArchetype) for _, storage in self._storages do for entityArchetype in storage do - if areArchetypesCompatible(queryArchetype, entityArchetype) then - self._queryCache[queryArchetype][entityArchetype] = true + local archetypes = string.split(queryArchetype, "x") + local baseArchetype = table.remove(archetypes, 1) + + if not areArchetypesCompatible(baseArchetype, entityArchetype) then + continue + end + + local skip = false + + for _, exclude in archetypes do + if areArchetypesCompatible(exclude, entityArchetype) then + skip = true + break + end end + + if skip then + continue + end + + self._queryCache[queryArchetype][entityArchetype] = true end end end @@ -370,6 +388,24 @@ function World:get(id, ...) return unpack(components, 1, length) end +local function noop() end + +local noopQuery = setmetatable({ + next = noop, + snapshot = noop, + without = function(self) + return self + end, + view = { + get = noop, + contains = noop, + }, +}, { + __iter = function() + return noop + end, +}) + --[=[ @class QueryResult @@ -384,19 +420,85 @@ end end ``` ]=] + local QueryResult = {} QueryResult.__index = QueryResult -function QueryResult:__call() - return self._expand(self._next()) +function QueryResult.new(world, expand, queryArchetype, compatibleArchetypes) + return setmetatable({ + world = world, + seenEntities = {}, + currentCompatibleArchetype = next(compatibleArchetypes), + compatibleArchetypes = compatibleArchetypes, + storageIndex = 1, + _expand = expand, + _queryArchetype = queryArchetype, + }, QueryResult) +end + +local function nextItem(query) + local world = query.world + local currentCompatibleArchetype = query.currentCompatibleArchetype + local seenEntities = query.seenEntities + local compatibleArchetypes = query.compatibleArchetypes + + local entityId, entityData + + local storages = world._storages + repeat + local nextStorage = storages[query.storageIndex] + local currently = nextStorage[currentCompatibleArchetype] + if currently then + entityId, entityData = next(currently, query.lastEntityId) + end + + while entityId == nil do + currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) + + if currentCompatibleArchetype == nil then + query.storageIndex += 1 + + nextStorage = storages[query.storageIndex] + + if nextStorage == nil or next(nextStorage) == nil then + return + end + + currentCompatibleArchetype = nil + + if world._pristineStorage == nextStorage then + world:_markStorageDirty() + end + + continue + elseif nextStorage[currentCompatibleArchetype] == nil then + continue + end + + entityId, entityData = next(nextStorage[currentCompatibleArchetype]) + end + + query.lastEntityId = entityId + + until seenEntities[entityId] == nil + + query.currentCompatibleArchetype = currentCompatibleArchetype + + seenEntities[entityId] = true + + return entityId, entityData end function QueryResult:__iter() return function() - return self._expand(self._next()) + return self._expand(nextItem(self)) end end +function QueryResult:__call() + return self._expand(nextItem(self)) +end + --[=[ Returns the next set of values from the query result. Once all results have been returned, the QueryResult is exhausted and is no longer useful. @@ -424,7 +526,7 @@ end @return ...ComponentInstance -- The requested component values ]=] function QueryResult:next() - return self._expand(self._next()) + return self._expand(nextItem(self)) end local snapshot = { @@ -473,7 +575,7 @@ function QueryResult:snapshot() local list = setmetatable({}, snapshot) local function iter() - return self._next() + return nextItem(self) end for entityId, entityData in iter do @@ -505,32 +607,140 @@ end end ``` ]=] + function QueryResult:without(...) - local metatables = { ... } - return function(): any - while true do - local entityId, entityData = self._next() + local world = self.world + local filter = string.gsub(archetypeOf(...), "_", "x") - if not entityId then - break - end + local negativeArchetype = `{self._queryArchetype}x{filter}` - local skip = false - for _, metatable in ipairs(metatables) do - if entityData[metatable] then - skip = true - break - end - end + if world._queryCache[negativeArchetype] == nil then + world:_newQueryArchetype(negativeArchetype) + end - if skip then - continue + local compatibleArchetypes = world._queryCache[negativeArchetype] + + self.compatibleArchetypes = compatibleArchetypes + self.currentCompatibleArchetype = next(compatibleArchetypes) + return self +end + +--[=[ + @class View + + Provides random access to the results of a query. + + Calling the View is equivalent to iterating a query. + + ```lua + for id, player, health, poison in world:query(Player, Health, Poison):view() do + -- Do something + end + ``` +]=] + +--[=[ + Creates a View of the query and does all of the iterator tasks at once at an amortized cost. + This is used for many repeated random access to an entity. If you only need to iterate, just use a query. + + ```lua + local inflicting = world:query(Damage, Hitting, Player):view() + for _, source in world:query(DamagedBy) do + local damage = inflicting:get(source.from) + end + + for _ in world:query(Damage):view() do end -- You can still iterate views if you want! + ``` + + @return View See [View](/api/View) docs. +]=] + +function QueryResult:view() + local function iter() + return nextItem(self) + end + + local fetches = {} + local list = {} :: any + + local View = {} + View.__index = View + + function View:__iter() + local current = list.head + return function() + if not current then + return end + local entity = current.entity + local fetch = fetches[entity] + current = current.next - return self._expand(entityId, entityData) + return entity, unpack(fetch, 1, fetch.n) + end + end + + --[=[ + @within View + Retrieve the query results to corresponding `entity` + @param entity number - the entity ID + @return ...ComponentInstance + ]=] + function View:get(entity) + if not self:contains(entity) then + return + end + + local fetch = fetches[entity] + local queryLength = fetch.n + + if queryLength == 1 then + return fetch[1] + elseif queryLength == 2 then + return fetch[1], fetch[2] + elseif queryLength == 3 then + return fetch[1], fetch[2], fetch[3] + elseif queryLength == 4 then + return fetch[1], fetch[2], fetch[3], fetch[4] + elseif queryLength == 5 then + return fetch[1], fetch[2], fetch[3], fetch[4], fetch[5] + end + + return unpack(fetch, 1, fetch.n) + end + + --[=[ + @within View + Equivalent to `world:contains()` + @param entity number - the entity ID + @return boolean + ]=] + + function View:contains(entity) + return fetches[entity] ~= nil + end + + for entityId, entityData in iter do + if entityId then + -- We start at 2 on Select since we don't need want to pack the entity id. + local fetch = table.pack(select(2, self._expand(entityId, entityData))) + local node = { entity = entityId, next = nil } + + fetches[entityId] = fetch + + if not list.head then + list.head = node + else + local current = list.head + while current.next do + current = current.next + end + current.next = node + end end - return end + + return setmetatable({}, View) end --[=[ @@ -571,10 +781,7 @@ function World:query(...) if next(compatibleArchetypes) == nil then -- If there are no compatible storages avoid creating our complicated iterator - return setmetatable({ - _expand = function() end, - _next = function() end, - }, QueryResult) + return noopQuery end local queryOutput = table.create(queryLength) @@ -612,61 +819,11 @@ function World:query(...) return entityId, unpack(queryOutput, 1, queryLength) end - local currentCompatibleArchetype = next(compatibleArchetypes) - local lastEntityId - local storageIndex = 1 - if self._pristineStorage == self._storages[1] then self:_markStorageDirty() end - local seenEntities = {} - - local function nextItem() - local entityId, entityData - - repeat - if self._storages[storageIndex][currentCompatibleArchetype] then - entityId, entityData = next(self._storages[storageIndex][currentCompatibleArchetype], lastEntityId) - end - - while entityId == nil do - currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - - if currentCompatibleArchetype == nil then - storageIndex += 1 - - local nextStorage = self._storages[storageIndex] - - if nextStorage == nil or next(nextStorage) == nil then - return - end - - currentCompatibleArchetype = nil - - if self._pristineStorage == nextStorage then - self:_markStorageDirty() - end - - continue - elseif self._storages[storageIndex][currentCompatibleArchetype] == nil then - continue - end - - entityId, entityData = next(self._storages[storageIndex][currentCompatibleArchetype]) - end - lastEntityId = entityId - - until seenEntities[entityId] == nil - - seenEntities[entityId] = true - return entityId, entityData - end - - return setmetatable({ - _expand = expand, - _next = nextItem, - }, QueryResult) + return QueryResult.new(self, expand, archetype, compatibleArchetypes) end local function cleanupQueryChanged(hookState) diff --git a/lib/World.spec.lua b/lib/World.spec.lua index d4f9a4a..be1c3dc 100644 --- a/lib/World.spec.lua +++ b/lib/World.spec.lua @@ -138,6 +138,24 @@ return function() expect(world:size()).to.equal(1) end) + it("should not find any entities", function() + local world = World.new() + + local Hello = component() + local Bob = component() + local Shirley = component() + + local _helloBob = world:spawn(Hello(), Bob()) + local _helloShirley = world:spawn(Hello(), Shirley()) + + local withoutCount = 0 + for _ in world:query(Hello):without(Bob, Shirley) do + withoutCount += 1 + end + + expect(withoutCount).to.equal(0) + end) + it("should be queryable", function() local world = World.new() @@ -505,9 +523,10 @@ return function() }) ) - local snapshot = world:query(Health, Player):snapshot() + local query = world:query(Health, Player) + local snapshot = query:snapshot() - for entityId, health, player in world:query(Health, Player):snapshot() do + for entityId, health, player in snapshot do expect(type(entityId)).to.equal("number") expect(type(player.name)).to.equal("string") expect(type(health.value)).to.equal("number") @@ -521,6 +540,131 @@ return function() else expect(snapshot[2][1]).to.equal(1) end + + expect(#world:query(Player):without(Poison):snapshot()).to.equal(1) + end) + + it("should contain entity in view", function() + local ComponentA = component("ComponentA") + local ComponentB = component("ComponentB") + + local world = World.new() + + local entityA = world:spawn(ComponentA()) + local entityB = world:spawn(ComponentB()) + + local viewA = world:query(ComponentA):view() + local viewB = world:query(ComponentB):view() + + expect(viewA:contains(entityA)).to.equal(true) + expect(viewA:contains(entityB)).to.equal(false) + expect(viewB:contains(entityB)).to.equal(true) + expect(viewB:contains(entityA)).to.equal(false) + end) + + it("should get entity data from view", function() + local numComponents = 20 + local components = {} + + for i = 1, numComponents do + table.insert(components, component("Component" .. i)) + end + + local world = World.new() + + local componentInstances = {} + + for _, componentFn in components do + table.insert(componentInstances, componentFn()) + end + + local entityA = world:spawn(table.unpack(componentInstances)) + + local viewA = world:query(table.unpack(components)):view() + local viewB = world:query(components[1]):view() + + expect(select("#", viewA:get(entityA))).to.equal(numComponents) + expect(select("#", viewB:get(entityA))).to.equal(1) + + local viewAEntityAData = { viewA:get(entityA) } + + for index, componentData in viewAEntityAData do + expect(getmetatable(componentData)).to.equal(components[index]) + end + + local viewBEntityAData = { viewB:get(entityA) } + + expect(getmetatable(viewBEntityAData[1])).to.equal(components[1]) + end) + + it("should return view results in query order", function() + local Parent = component("Parent") + local Transform = component("Transform") + local Root = component("Root") + + local world = World.new() + + local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) + local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) + + local child = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.one }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _otherChild = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.new(0, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _grandChild = world:spawn( + Parent({ + entity = child, + fromChild = Transform({ pos = Vector2.new(-1, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local parents = world:query(Parent):view() + local roots = world:query(Transform, Root):view() + + expect(parents:contains(root)).to.equal(false) + + local orderOfIteration = {} + + for id in world:query(Transform, Parent) do + table.insert(orderOfIteration, id) + end + + local view = world:query(Transform, Parent):view() + local i = 0 + for id in view do + i += 1 + expect(orderOfIteration[i]).to.equal(id) + end + + for id, absolute, parent in world:query(Transform, Parent) do + local relative = parent.fromChild.pos + local ancestor = parent.entity + local current = parents:get(ancestor) + while current do + relative = current.fromChild.pos * relative + ancestor = current.entity + current = parents:get(ancestor) + end + + local pos = roots:get(ancestor).pos + + world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) + end + + expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) end) it("should not invalidate iterators", function() diff --git a/lib/debugger/debugger.lua b/lib/debugger/debugger.lua index 3ddb50d..4bfa0e9 100644 --- a/lib/debugger/debugger.lua +++ b/lib/debugger/debugger.lua @@ -67,7 +67,7 @@ Debugger.__index = Debugger @within Debugger Create this property in Debugger to specify a function that will be called to determine if a player should be - allowed to connect to the server-side debugger. + allowed to connect to the server-side debugger. In Studio, this property is ignored. If not specified, the default behavior is to allow anyone in Studio and disallow everyone in a live game. @@ -115,10 +115,24 @@ Debugger.__index = Debugger ``` ]=] +--[=[ + @prop loopParameterNames {string} + @within Debugger + + Create this property in Debugger to specify the names of the parameters to your Loop constructor. This is used to + display a more accurate name in the debugger. + + If not specified, the default behavior is to label Worlds as "World" and tables as "table", followed by its index. + + ```lua + debugger.loopParameterNames = {"World", "State", "Widgets"} + ``` +]=] + --[=[ Creates a new Debugger. - You need to depend on [Plasma](https://eryn.io/plasma/) in your project and pass a handle to it here. + You need to depend on [Plasma](https://matter-ecs.github.io/plasma/) in your project and pass a handle to it here. @param plasma Plasma -- The instance of Plasma used in your game. @return Debugger @@ -149,6 +163,7 @@ function Debugger.new(plasma) local self = setmetatable({ plasma = plasma, loop = nil, + loopParameterNames = {}, enabled = false, componentRefreshFrequency = 3, _windowCount = 0, @@ -158,6 +173,7 @@ function Debugger.new(plasma) _eventBridge = EventBridge.new(function(...) remoteEvent:FireClient(...) end), + _playersUsingDebugger = {}, _customWidgets = {}, }, Debugger) @@ -292,6 +308,7 @@ function Debugger:connectPlayer(player) end self._eventBridge:connectPlayer(player) + self._playersUsingDebugger[player] = true end function Debugger:disconnectPlayer(player) @@ -300,6 +317,7 @@ function Debugger:disconnectPlayer(player) end self._eventBridge:disconnectPlayer(player) + self._playersUsingDebugger[player] = nil if #self._eventBridge.players == 0 then self:_disable() @@ -334,6 +352,7 @@ function Debugger:autoInitialize(loop) parent.DisplayOrder = 2 ^ 31 - 1 parent.ResetOnSpawn = false parent.IgnoreGuiInset = true + parent.AutoLocalize = false if IS_CLIENT then parent.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") @@ -462,7 +481,7 @@ end --[=[ Returns a handle to the debug widgets you can pass to your systems. - All [plasma widgets](https://eryn.io/plasma/api/Plasma#arrow) are available under this namespace. + All [plasma widgets](https://matter-ecs.github.io/plasma/api/Plasma#widgets) are available under this namespace. ```lua -- ... diff --git a/lib/debugger/ui.lua b/lib/debugger/ui.lua index 7f7e171..e1127a7 100644 --- a/lib/debugger/ui.lua +++ b/lib/debugger/ui.lua @@ -82,8 +82,11 @@ local function ui(debugger, loop) local selected = (#objectStack > 0 and object == objectStack[#objectStack].value) or (debugger.debugWorld == object and worldViewOpen) + local name = debugger.loopParameterNames[index] + local defaultName = (if isWorld then "World" else "table") .. " " .. index + table.insert(items, { - text = (if isWorld then "World" else "table") .. " " .. index, + text = if name then name else defaultName, icon = if isWorld then "🌐" else "{}", object = object, selected = selected, diff --git a/lib/debugger/widgets/container.lua b/lib/debugger/widgets/container.lua index cc1c757..780dd6e 100644 --- a/lib/debugger/widgets/container.lua +++ b/lib/debugger/widgets/container.lua @@ -12,6 +12,7 @@ return function(Plasma) BackgroundTransparency = 1, Position = UDim2.new(0, 0, 0, 0), Size = UDim2.new(1, 0, 1, 0), + AutoLocalize = false, create("UIPadding", { PaddingTop = UDim.new(0, options.marginTop or 0), diff --git a/lib/debugger/widgets/frame.lua b/lib/debugger/widgets/frame.lua index 2b9d297..9c98d38 100644 --- a/lib/debugger/widgets/frame.lua +++ b/lib/debugger/widgets/frame.lua @@ -9,6 +9,7 @@ return function(Plasma) Frame.AnchorPoint = Vector2.new(0.5, 0.5) Frame.Size = UDim2.new(0, 50, 0, 40) Frame.Visible = false + Frame.AutoLocalize = false local UICorner = Instance.new("UICorner") UICorner.Parent = Frame diff --git a/lib/debugger/widgets/link.lua b/lib/debugger/widgets/link.lua index 8083566..aab93f0 100644 --- a/lib/debugger/widgets/link.lua +++ b/lib/debugger/widgets/link.lua @@ -16,7 +16,6 @@ return function(Plasma) local button = create("TextButton", { [ref] = "button", BackgroundTransparency = 1, - AutomaticSize = Enum.AutomaticSize.XY, Text = "", create("UIPadding", { diff --git a/package.json b/package.json index a7e6f14..7a32a88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/matter", - "version": "0.7.1", + "version": "0.8.0", "description": "", "main": "lib/init.lua", "repository": {