diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4134ebae..e12f5eab 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' jobs: build: @@ -24,7 +24,7 @@ jobs: zip -r knit.zip Knit/** - name: Build place file run: | - rojo build publish.project.json -o Knit.rbxlx + rojo build publish.project.json -o Knit.rbxl - name: Publish Knit to Roblox shell: bash env: @@ -42,7 +42,7 @@ jobs: draft: false prerelease: false - name: Upload Release Asset - id: upload-release-asset + id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 57cd899a..e15bf7d9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ logo/logo_render*.png logo/*.psd logo/*.ai site/ -*.rbxlx \ No newline at end of file +*.rbxlx +*.rbxl \ No newline at end of file diff --git a/docs/util/component.md b/docs/util/component.md index 31a2325a..8d8c7130 100644 --- a/docs/util/component.md +++ b/docs/util/component.md @@ -1,11 +1,24 @@ The [Component](https://github.com/Sleitnick/Knit/blob/main/src/Util/Component.lua) class allows developers to bind custom component classes to in-game objects based on tags provided by the [CollectionService](https://developer.roblox.com/en-us/api-reference/class/CollectionService). +The best practice is to keep all components as descendants of a folder and then call `Component.Auto(folder)` to load all the components automatically. This process is looks for component modules in all descendants of the given folder. + +## Dance Floor Example + For instance, a component might be created called `DanceFloor`, which has the purpose of making a part flash random colors. Here's what our DanceFloor component module might look like: ```lua local DanceFloor = {} DanceFloor.__index = DanceFloor +-- The CollectionService tag to bind: +DanceFloor.Tag = "DanceFloor" + +-- [Optional] The RenderPriority to be used when using the RenderUpdate lifecycle method: +DanceFloor.RenderPriority = Enum.RenderPriority.Camera.Value + +-- [Optional] The other components that must exist on a given instance before this one can exist: +DanceFloor.RequiredComponents = {} + -- How often the color changes: local UPDATE_INTERVAL = 0.5 @@ -33,18 +46,345 @@ end return DanceFloor ``` -Now the Component module can be used to register the above component: +Within your runtime script, load in all components using `Component.Auto`: ```lua +local Knit = require(game:GetService("ReplicatedStorage").Knit) local Component = require(Knit.Util.Component) -local DanceFloor = require(somewhere.DanceFloor) -local danceFloor = Component.new("DanceFloor", DanceFloor) +Knit.Start():Await() + +-- Load all components in some folder: +Component.Auto(script.Parent.Components) +``` + +Simply assign parts within the game with the `DanceFloor` tag, and the DanceFloor component will automatically be instantiated for those objects. For editing tags within Studio, check out the [Tag Editor](https://www.roblox.com/library/948084095/Tag-Editor) plugin. + +Components can live in either the server or the client. It is _not_ recommended to use the exact same component module for both the server and the client. Instead, it is best to create separate components for the server and client. For instance, there could be a `DanceFloor` component on the server and a `ClientDanceFloor` component on the client. + +Because this component is flashing colors quickly, it is probably best to run this component on the client, rather than the server. + +--------------------------- + +## Component Instance + +A component _instance_ is the instantiated object from your component class. In other words, this is the object being created when your component's `.new()` constructor is called. + +```lua +function MyComponent.new(robloxInstance) + -- This is the component instance: + local self = setmetatable({}, MyComponent) + return self +end +``` + +### Roblox Instance + +Component instances are bound to a Roblox instance. This is injected into the component instance _after_ the constructor is completed (it is identical to the `robloxInstance` argument passed to the constructor). This can be accessed as the `.Instance` field on the component instance. For example, here is the Roblox instance being referenced within the initializer: + +```lua +function MyComponent:Init() + print("I am bound to: " .. self.Instance:GetFullName()) +end ``` -Lastly, simply assign parts within the game with the `DanceFloor` tag, and the DanceFloor component will automatically be instantiated for those objects. For editing tags within Studio, check out the [Tag Editor](https://www.roblox.com/library/948084095/Tag-Editor) plugin. +--------------------------- + +## Lifecycle Methods + +Components have special "lifecycle methods" which will automatically fire during the lifecycle of the component. The available methods are `Init`, `Deinit`, `Destroy`, `HeartbeatUpdate`, `SteppedUpdated`, and `RenderUpdate`. The only required of these is `Destroy`; the rest are optional. + +### Init & Deinit + +`Init` fires a tick/frame after the constructor has fired. `Deinit` fires right before the component's `Destroy` method is called. Both `Init` and `Deinit` are optional. + +### Destroy + +`Destroy` is fired internally when the component becomes unbound from the instance. A component is destroyed when one of the following conditions occurs: + +1. The bound instance is destroyed +1. The bound instance no longer has the component tag anymore +1. The bound instance no longer has the required components attached anymore (see section on [Required Components](#required-components)) + +It is recommended to use maids in components and to only have the maid cleanup within the `Destroy` method. Any other cleanup logic should just be added to the maid: + +```lua +function MyComponent.new(instance) + local self = setmetatable({}, MyComponent) + self._maid = Maid.new() + return self +end + +function MyComponent:Destroy() + self._maid:Destroy() +end +``` + +### HeartbeatUpdate & SteppedUpdate + +These optional methods are fired when `RunService.Heartbeat` and `RunService.Stepped` are fired. The delta time argument from the event is passed as an argument to the methods. + +```lua +function MyComponent:HeartbeatUpdate(dt) + print("Update!", dt) +end +function MyComponent:SteppedUpdate(dt) + print("Update!", dt) +end +``` + +### RenderUpdate + +The `RenderUpdate` optional method uses `RunService:BindToRenderStep` internally, using your component's RenderPriority field as the priority for binding. Just like `HeartbeatUpdate` and `SteppedUpdate`, the delta time is passed along to the method. + +```lua +MyComponent.RenderPriority = Enum.RenderPriority.Camera.Value + +function MyComponent:RenderUpdate(dt) + print("Render update", dt) +end +``` + +--------------------------- + +## Required Components + +Being able to extend instances by binding multiple components is very useful. However, if these components need to communicate, it is required to use the `RequiredComponents` optional table to indicate which components are necessary for instantiation. + +For example, let's say we have a `Vehicle` component and a `Truck` component. The `Truck` component _must_ have the `Vehicle` component in order to operate. The `Truck` component also needs to invoke methods on the `Vehicle` component. We can make this guarantee using the `RequiredComponents` table on the `Truck`: + +```lua +local Truck = {} +Truck.__index = Truck +Truck.Tag = "Truck" + +-- Set the 'Vehicle' as a required component: +Truck.RequiredComponents = {"Vehicle"} +``` + +With that done, the `Truck` component will _only_ bind to an instance with the "Truck" tag if the instance already has a `Vehicle` component bound to it. If the `Vehicle` component becomes unbound for any reason, the `Truck` component will also be unbound and destroyed. + +Because of this guarantee, we can reference the `Vehicle` component within the `Truck` constructor safely: + +```lua +local Knit = require(game:GetService("ReplicatedStorage").Knit) +local Component = require(Knit.Util.Component) + +... + +Truck.RequiredComponents = {"Vehicle"} + +function Truck.new(instance) + local self = setmetatable({}, Truck) + + -- Get the Vehicle component on this instance: + self.Vehicle = Component.FromTag("Vehicle"):GetFromInstance(instance) + + return self +end +``` + +## Component API + +### Static Methods + +``` +Component.Auto(folder: Instance): void +Component.FromTag(tag: string): ComponentInstance | nil +Component.ObserveFromTag(tag: string, observer: (component: Component, maid: Maid) -> void): Maid +``` + +#### `Auto` + +Automatically create components from the component module descendants of the given instance. + +```lua +Component.Auto(someFolder) +``` -The full API for components is listed within the [Component](https://github.com/Sleitnick/Knit/blob/main/src/Util/Component.lua) module. +#### `FromTag` + +Get a component from the tag name, which assumes the component class has already been loaded. This will return `nil` if not found. + +```lua +local MyComponent = Component.FromTag("MyComponent") +``` + +#### `ObserveFromTag` + +Observe a component with the given tag name. Unless component classes will be destroyed and reconstructed often, this method is most likely not going to be needed in your code. + +```lua +Component.ObserveFromTag("MyComponent", function(MyComponent, maid) + -- Use MyComponent +end) +``` + +### Constructor + +``` +Component.new(tag: string, class: table [, renderPriority: RenderPriority, requiredComponents: table]) +``` + +```lua +local MyComponentClass = require(somewhere.MyComponent) +local MyComponent = Component.new( + MyComponentClass.Tag, + MyComponentClass, + MyComponentClass.RenderPriority, + MyComponentClass.RequiredComponents +) +``` !!! note - If a component needs to be used on both the server and the client, it is recommended to make two separate component modules for each environment. In the above example, we made a DanceFloor. Ideally, such a module should only run on the client, since it is rapidly changing the color of the part at random. Another DanceFloor component could also be created for the server if desired. + While the constructor can be called directly, it is recommended to use `Component.Auto` instead. + +### Methods + +``` +component:GetAll(): ComponentInstance[] +component:GetFromInstance(instance: Instance): ComponentInstance | nil +component:Filter(filterFunc: (comp: ComponentInstance) -> boolean): ComponentInstance[] +component:WaitFor(instance: Instance [, timeout: number = 60]): Promise +component:Observe(instance: Instance, observer: (component: ComponentInstance, maid: Maid) -> void): Maid +component:Destroy() +``` + +#### `GetAll` +Gets all component instances for the given component class. + +```lua +local MyComponent = Component.FromTag("MyComponent") +for _,component in ipairs(MyComponent:GetAll()) do + print(component.Instance:GetFullName()) +end +``` + +#### `GetFromInstance` +Gets a component instance from the given Roblox instance. If no component is found, `nil` is returned. + +```lua +local MyComponent = Component.FromTag("MyComponent") +local component = MyComponent:GetFromInstance(workspace.SomePart) +``` + +#### `Filter` +Returns a filtered list from all components for a given component class. This is equivalent to calling `GetAll` and running it through `TableUtil.Filter`. + +```lua +local MyComponent = Component.FromTag("MyComponent") +local componentsStartWithC = MyComponent:Filter(function(component) + return component.Instance.Name:sub(1, 1):lower() == "c" +end) +``` + +#### `WaitFor` +Waits for a component to be bound to a given instance. Returns a promise that is resolved when the component is bound, or rejected when either the timeout is reached or the instance is removed. + +```lua +local MyComponent = Component.FromTag("MyComponent") +MyComponent:WaitFor(workspace.SomePart):Then(function(component) + print("Got component") +end):Catch(warn) +``` + +#### `Observe` +Observes when a component is bound to a given instance. Returns a maid that can be destroyed. + +```lua +local MyComponent = Component.FromTag("MyComponent") +local observeMaid = MyComponent:Observe(workspace.SomePart, function(component, maid) + -- Do something + maid:GiveTask(function() + -- Cleanup + end) +end) +``` + +!!! warning + This does _not_ clean itself up if the instance is destroyed. This should be handled explicitly in your code. + +#### `Destroy` +If the component is not needed anymore, `Destroy` can be called to clean it up. Typically, components are never destroyed. + +```lua +local MyComponent = Component.FromTag("MyComponent") +MyComponent:Destroy() +``` + +### Events + +``` +component.Added(obj: ComponentInstance) +component.Removed(obj: ComponentInstance) +``` + +## Boilerplate Examples + +Here is the most basic component with the recommended Maid pattern: +```lua +local Knit = require(game:GetService("ReplicatedStorage").Knit) +local Maid = require(Knit.Util.Maid) + +local MyComponent = {} +MyComponent.__index = MyComponent + +MyComponent.Tag = "MyComponent" + +function MyComponent.new(instance) + local self = setmetatable({}, MyComponent) + self._maid = Maid.new() + return self +end + +function MyComponent:Destroy() + self._maid:Destroy() +end + +return MyComponent +``` + +Here is a more robust example with lifecycles and required components: +```lua +local Knit = require(game:GetService("ReplicatedStorage").Knit) +local Maid = require(Knit.Util.Maid) + +local MyComponent = {} +MyComponent.__index = MyComponent + +MyComponent.Tag = "MyComponent" +MyComponent.RenderPriority = Enum.RenderPriority.Camera.Value +MyComponent.RequiredComponents = {"AnotherComponent", "YetAnotherComponent"} + +function MyComponent.new(instance) + local self = setmetatable({}, MyComponent) + self._maid = Maid.new() + return self +end + +function MyComponent:Init() + print("Initialized. Bound to: ", self.Instance:GetFullName()) +end + +function MyComponent:Deinit() + print("About to clean up") +end + +function MyComponent:HeartbeatUpdate(dt) + print("Heartbeat", dt) +end + +function MyComponent:SteppedUpdate(dt) + print("Stepped", dt) +end + +function MyComponent:RenderUpdate(dt) + print("Render", dt) +end + +function MyComponent:Destroy() + self._maid:Destroy() +end + +return MyComponent +``` \ No newline at end of file diff --git a/foreman.toml b/foreman.toml index 6c47091d..e9c6f86e 100644 --- a/foreman.toml +++ b/foreman.toml @@ -2,4 +2,4 @@ # Install latest selene selene = { source = "Kampfkarren/selene", version = "x" } rojo = { source = "rojo-rbx/rojo", version = "6.1.0" } -remodel = { source = "rojo-rbx/remodel", version = "0.7.1"} +remodel = { source = "rojo-rbx/remodel", version = "0.8.1"} diff --git a/mkdocs.yml b/mkdocs.yml index ff9d2d0b..bd612e12 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,7 +44,7 @@ theme: accent: light blue - media: "(prefers-color-scheme: dark)" scheme: slate - primary: indigo + primary: blue accent: light blue highlightjs: true hljs_languages: diff --git a/publish.lua b/publish.lua index d9588128..b7a61d61 100644 --- a/publish.lua +++ b/publish.lua @@ -3,7 +3,7 @@ local KNIT_ASSET_ID = "5530714855" print("Loading Knit") -local place = remodel.readPlaceFile("Knit.rbxlx") +local place = remodel.readPlaceFile("Knit.rbxl") local Knit = place.ReplicatedStorage.Knit print("Writing Knit module to Roblox...") diff --git a/src/Util/Component.lua b/src/Util/Component.lua index f1f1a0fe..632bf3f4 100644 --- a/src/Util/Component.lua +++ b/src/Util/Component.lua @@ -4,22 +4,25 @@ --[[ - Component.Auto(folder) + Component.Auto(folder: Instance) -> Create components automatically from descendant modules of this folder -> Each module must have a '.Tag' string property -> Each module optionally can have '.RenderPriority' number property - component = Component.FromTag(tag) + component = Component.FromTag(tag: string) -> Retrieves an existing component from the tag name - component = Component.new(tag, class [, renderPriority]) + Component.ObserveFromTag(tag: string, observer: (component: Component, maid: Maid) -> void): Maid + + component = Component.new(tag: string, class: table [, renderPriority: RenderPriority, requireComponents: {string}]) -> Creates a new component from the tag name, class module, and optional render priority component:GetAll(): ComponentInstance[] - component:GetFromInstance(instance): ComponentInstance | nil - component:GetFromID(id): ComponentInstance | nil - component:Filter(filterFunc): ComponentInstance[] + component:GetFromInstance(instance: Instance): ComponentInstance | nil + component:GetFromID(id: number): ComponentInstance | nil + component:Filter(filterFunc: (comp: ComponentInstance) -> boolean): ComponentInstance[] component:WaitFor(instanceOrName: Instance | string [, timeout: number = 60]): Promise + component:Observe(instance: Instance, observer: (component: ComponentInstance, maid: Maid) -> void): Maid component:Destroy() component.Added(obj: ComponentInstance) @@ -97,6 +100,9 @@ Component.__index = Component local componentsByTag = {} +local componentByTagCreated = Signal.new() +local componentByTagDestroyed = Signal.new() + local function IsDescendantOfWhitelist(instance) for _,v in ipairs(DESCENDANT_WHITELIST) do @@ -113,12 +119,38 @@ function Component.FromTag(tag) end +function Component.ObserveFromTag(tag, observer) + local maid = Maid.new() + local observeMaid = Maid.new() + maid:GiveTask(observeMaid) + local function OnCreated(component) + if (component._tag == tag) then + observer(component, observeMaid) + end + end + local function OnDestroyed(component) + if (component._tag == tag) then + observeMaid:DoCleaning() + end + end + do + local component = Component.FromTag(tag) + if (component) then + Thread.SpawnNow(OnCreated, component) + end + end + maid:GiveTask(componentByTagCreated:Connect(OnCreated)) + maid:GiveTask(componentByTagDestroyed:Connect(OnDestroyed)) + return maid +end + + function Component.Auto(folder) local function Setup(moduleScript) local m = require(moduleScript) assert(type(m) == "table", "Expected table for component") assert(type(m.Tag) == "string", "Expected .Tag property") - Component.new(m.Tag, m, m.RenderPriority) + Component.new(m.Tag, m, m.RenderPriority, m.RequiredComponents) end for _,v in ipairs(folder:GetDescendants()) do if (v:IsA("ModuleScript")) then @@ -133,7 +165,7 @@ function Component.Auto(folder) end -function Component.new(tag, class, renderPriority) +function Component.new(tag, class, renderPriority, requireComponents) assert(type(tag) == "string", "Argument #1 (tag) should be a string; got " .. type(tag)) assert(type(class) == "table", "Argument #2 (class) should be a table; got " .. type(class)) @@ -143,9 +175,6 @@ function Component.new(tag, class, renderPriority) local self = setmetatable({}, Component) - self.Added = Signal.new() - self.Removed = Signal.new() - self._maid = Maid.new() self._lifecycleMaid = Maid.new() self._tag = tag @@ -158,38 +187,112 @@ function Component.new(tag, class, renderPriority) self._hasInit = (type(class.Init) == "function") self._hasDeinit = (type(class.Deinit) == "function") self._renderPriority = renderPriority or Enum.RenderPriority.Last.Value + self._requireComponents = requireComponents or {} self._lifecycle = false self._nextId = 0 - self._maid:GiveTask(CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance) - if (IsDescendantOfWhitelist(instance)) then - self:_instanceAdded(instance) + self.Added = Signal.new(self._maid) + self.Removed = Signal.new(self._maid) + + local observeMaid = Maid.new() + self._maid:GiveTask(observeMaid) + + local function ObserveTag() + + local function HasRequiredComponents(instance) + for _,reqComp in ipairs(self._requireComponents) do + local comp = Component.FromTag(reqComp) + if (comp:GetFromInstance(instance) == nil) then + return false + end + end + return true end - end)) - self._maid:GiveTask(CollectionService:GetInstanceRemovedSignal(tag):Connect(function(instance) - self:_instanceRemoved(instance) - end)) + observeMaid:GiveTask(CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance) + if (IsDescendantOfWhitelist(instance) and HasRequiredComponents(instance)) then + self:_instanceAdded(instance) + end + end)) + + observeMaid:GiveTask(CollectionService:GetInstanceRemovedSignal(tag):Connect(function(instance) + self:_instanceRemoved(instance) + end)) + + for _,reqComp in ipairs(self._requireComponents) do + local comp = Component.FromTag(reqComp) + observeMaid:GiveTask(comp.Added:Connect(function(obj) + if (CollectionService:HasTag(obj.Instance, tag) and HasRequiredComponents(obj.Instance)) then + self:_instanceAdded(obj.Instance) + end + end)) + observeMaid:GiveTask(comp.Removed:Connect(function(obj) + if (CollectionService:HasTag(obj.Instance, tag)) then + self:_instanceRemoved(obj.Instance) + end + end)) + end - self._maid:GiveTask(self._lifecycleMaid) + observeMaid:GiveTask(function() + self:_stopLifecycle() + for instance in pairs(self._instancesToObjects) do + self:_instanceRemoved(instance) + end + end) - do - local b = Instance.new("BindableEvent") - for _,instance in ipairs(CollectionService:GetTagged(tag)) do - if (IsDescendantOfWhitelist(instance)) then - local c = b.Event:Connect(function() - self:_instanceAdded(instance) - end) - b:Fire() - c:Disconnect() + do + local b = Instance.new("BindableEvent") + for _,instance in ipairs(CollectionService:GetTagged(tag)) do + if (IsDescendantOfWhitelist(instance) and HasRequiredComponents(instance)) then + local c = b.Event:Connect(function() + self:_instanceAdded(instance) + end) + b:Fire() + c:Disconnect() + end + end + b:Destroy() + end + + end + + if (#self._requireComponents == 0) then + ObserveTag() + else + -- Only observe tag when all required components are available: + local tagsReady = {} + for _,reqComp in ipairs(self._requireComponents) do + tagsReady[reqComp] = false + end + local function Check() + for _,ready in pairs(tagsReady) do + if (not ready) then + return + end end + ObserveTag() + end + local function Cleanup() + observeMaid:DoCleaning() + end + for _,requiredComponent in ipairs(self._requireComponents) do + tagsReady[requiredComponent] = false + self._maid:GiveTask(Component.ObserveFromTag(requiredComponent, function(_component, maid) + tagsReady[requiredComponent] = true + Check() + maid:GiveTask(function() + tagsReady[requiredComponent] = false + Cleanup() + end) + end)) end - b:Destroy() end componentsByTag[tag] = self + componentByTagCreated:Fire(self) self._maid:GiveTask(function() componentsByTag[tag] = nil + componentByTagDestroyed:Fire(self) end) return self @@ -280,6 +383,7 @@ end function Component:_instanceRemoved(instance) + if (not self._instancesToObjects[instance]) then return end self._instancesToObjects[instance] = nil for i,obj in ipairs(self._objects) do if (obj.Instance == instance) then @@ -347,6 +451,30 @@ function Component:WaitFor(instance, timeout) end +function Component:Observe(instance, observer) + local maid = Maid.new() + local observeMaid = Maid.new() + maid:GiveTask(observeMaid) + maid:GiveTask(self.Added:Connect(function(obj) + if (obj.Instance == instance) then + observer(obj, observeMaid) + end + end)) + maid:GiveTask(self.Removed:Connect(function(obj) + if (obj.Instance == instance) then + observeMaid:DoCleaning() + end + end)) + for _,obj in ipairs(self._objects) do + if (obj.Instance == instance) then + Thread.SpawnNow(observer, obj, observeMaid) + break + end + end + return maid +end + + function Component:Destroy() self._maid:Destroy() end diff --git a/src/Version.txt b/src/Version.txt index 2f113dbc..cd8f1c51 100644 --- a/src/Version.txt +++ b/src/Version.txt @@ -1 +1 @@ -0.0.17-alpha \ No newline at end of file +0.0.18-alpha \ No newline at end of file