Repository: nidorx/ecs-lua Branch: master Commit: c6be4a85b13c Files: 127 Total size: 947.8 KB Directory structure: gitextract_abg99oc9/ ├── .editorconfig ├── .gitignore ├── .luacov ├── .travis.yml ├── CONTRIBUTING.md ├── ECS.lua ├── ECS_concat.lua ├── LICENSE ├── README.md ├── build.lua ├── docs/ │ ├── .nojekyll │ ├── README.md │ ├── _coverpage.md │ ├── _navbar.md │ ├── _sidebar.md │ ├── api.md │ ├── architecture.md │ ├── assets/ │ │ ├── boids.rbxl │ │ ├── logo-r.psd │ │ ├── pipeline_ecs.psd │ │ ├── pipeline_old.psd │ │ ├── repository-open-graph.psd │ │ ├── tutorial.rbxl │ │ └── version.psd │ ├── faq.md │ ├── favicon/ │ │ ├── browserconfig.xml │ │ └── manifest.json │ ├── getting-started.md │ ├── index.html │ ├── pt-br/ │ │ ├── README.md │ │ ├── _coverpage.md │ │ ├── _navbar.md │ │ ├── _sidebar.md │ │ ├── api.md │ │ ├── architecture.md │ │ ├── faq.md │ │ ├── getting-started.md │ │ ├── tutorial-boids.md │ │ ├── tutorial-pacman.md │ │ ├── tutorial-shoot.md │ │ └── tutorial.md │ ├── style.css │ ├── tutorial-boids.md │ ├── tutorial-pacman.md │ ├── tutorial-shoot.md │ ├── tutorial.md │ ├── z_old_TECH_DETAILS.md │ └── z_old_TUTORIAL.md ├── examples/ │ └── pong/ │ ├── .editorconfig │ ├── .gitignore │ ├── default.project.json │ ├── pong.rbxlx │ └── src/ │ ├── client/ │ │ ├── Constants.lua │ │ ├── Main.client.lua │ │ ├── Utility.lua │ │ ├── components/ │ │ │ ├── AudioSource.lua │ │ │ ├── Ball.lua │ │ │ ├── BasePart.lua │ │ │ ├── Paddle.lua │ │ │ ├── Player.lua │ │ │ ├── Position.lua │ │ │ ├── Score.lua │ │ │ └── Velocity.lua │ │ └── systems/ │ │ ├── AudioSystem.lua │ │ ├── BallSystem.lua │ │ ├── CameraSystem.lua │ │ ├── MoveSystem.lua │ │ ├── PaddleHitSystem.lua │ │ ├── PaddleSystem.lua │ │ ├── PlayerAiThinkSystem.lua │ │ ├── PlayerHumanInputSystem.lua │ │ ├── RenderSystem.lua │ │ └── ScoreSystem.lua │ ├── server/ │ │ └── Main.server.lua │ └── shared/ │ └── ECS.lua ├── modules/ │ ├── bin/ │ │ └── luacov │ ├── luacov/ │ │ ├── defaults.lua │ │ ├── hook.lua │ │ ├── linescanner.lua │ │ ├── reporter/ │ │ │ └── default.lua │ │ ├── reporter.lua │ │ ├── runner.lua │ │ ├── stats.lua │ │ ├── tick.lua │ │ └── util.lua │ ├── luacov.lua │ ├── luaunit.lua │ └── minify.lua ├── roblox/ │ ├── README.md │ ├── RobloxUtils.lua │ └── tutorial/ │ ├── default.project.json │ └── src/ │ ├── client/ │ │ ├── benchmark/ │ │ │ ├── init.client.lua │ │ │ └── soa.lua │ │ └── tutorial/ │ │ └── init.client.lua │ ├── server/ │ │ └── tutorial/ │ │ └── init.server.lua │ └── shared/ │ ├── teste.lua │ └── tutorial/ │ ├── component/ │ │ ├── FiringComponent.lua │ │ └── WeaponComponent.lua │ └── system/ │ ├── CleanupFiringSystem.lua │ ├── FiringSystem.lua │ └── PlayerShootingSystem.lua ├── src/ │ ├── Archetype.lua │ ├── Component.lua │ ├── ComponentFSM.lua │ ├── ECS.lua │ ├── Entity.lua │ ├── EntityRepository.lua │ ├── Event.lua │ ├── Query.lua │ ├── QueryResult.lua │ ├── RobloxLoopManager.lua │ ├── System.lua │ ├── SystemExecutor.lua │ ├── Timer.lua │ ├── Utility.lua │ └── World.lua ├── test/ │ ├── README.md │ ├── test_Archetype.lua │ ├── test_Component.lua │ ├── test_Entity.lua │ ├── test_EntityRepository.lua │ ├── test_Event.lua │ ├── test_Query.lua │ ├── test_QueryResult.lua │ ├── test_SystemExecutor.lua │ └── test_World.lua └── test.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true # Unix-style newlines with a newline ending every file [*] end_of_line = lf insert_final_newline = true [*.{lua,js,json}] charset = utf-8 indent_style = space indent_size = 3 ================================================ FILE: .gitignore ================================================ # IDE .idea .vscode luacov.report.out src/*.zip ================================================ FILE: .luacov ================================================ return { include = { "^src", "src%/.+$" }, exclude = { "%.test$", }, runreport = true, deletestats = true, --reporter = "html" } ================================================ FILE: .travis.yml ================================================ language: python env: - LUA="lua=5.1" - LUA="lua=5.2" - LUA="lua=5.3" - LUA="lua=5.4" - LUA="luajit=2.0" - LUA="luajit=2.1" before_install: - pip install hererocks - hererocks lua_install --$LUA -r latest - source lua_install/bin/activate script: - lua test.lua -v && tail -22 ./luacov.report.out && lua build.lua ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to ECS Lua Thanks for considering contributing to ECS Lua! This guide has a few tips and guidelines to make contributing to the project as easy as possible. ## Bug Reports Any bugs (or things that look like bugs) can be reported on the [GitHub issue tracker](https://github.com/nidorx/ecs-lua/issues). Make sure you check to see if someone has already reported your bug first! Don't fret about it; if we notice a duplicate we'll send you a link to the right issue! ## Feature Requests If there are any features you think are missing from ECS Lua, you can post a request in the [GitHub issue tracker](https://github.com/nidorx/ecs-lua/issues). Just like bug reports, take a peak at the issue tracker for duplicates before opening a new feature request. ## Documentation [ECS Lua documentation](https://nidorx.github.io/ecs-lua/) is built using [Docsify](https://docsify.js.org/#/), a fairly simple documentation generator. ## Working on ECS Lua To get started working on ECS Lua, you'll need: * Git * Lua 5.1 You can run all of ECS Lua tests with: ```sh lua test.lua -v ``` The LuaCov coverage report is available in the `luacov.report.out` file. To build the concatenated and minified versions, run the command ```sh lua build.lua ``` ## Pull Requests Before starting a pull request, open an issue about the feature or bug. This helps us prevent duplicated and wasted effort. These issues are a great place to ask for help if you run into problems! ### Code Style In short: - **SPACE** for indentation - Identation size = 3 spaces - Double quotes - One statement per line ### Tests When submitting a bug fix, create a test that verifies the broken behavior and that the bug fix works. This helps us avoid regressions! When submitting a new feature, add tests for all functionality. We use [LuaCov](https://keplerproject.github.io/luacov) for keeping track of code coverage. We'd like it to be as close to 100% as possible, but it's not always possible. Adding tests just for the purpose of getting coverage isn't useful; we should strive to make only useful tests! ================================================ FILE: ECS.lua ================================================ --[[ ECS Lua v2.2.0 ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development. This is a minified version of ECS Lua, to see the full source code visit https://github.com/nidorx/ecs-lua Discussions about this script are at https://devforum.roblox.com/t/841175 ------------------------------------------------------------------------------ MIT License Copyright (c) 2021 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local a,b={},{}local function c(d)if(not a[d])then a[d]={r=b[d]()}end return a[d].r end b["Archetype"]=function()local d={}local e={}local f={}local g=0 local h={}h.__index=h function h.Of(i)local j={}local k={}for m,n in ipairs(i)do if(n.IsCType and not n.isComponent)then if n.IsQualifier then if k[n]==nil then k[n]=true table.insert(j,n.Id)end n=n.SuperClass end if k[n]==nil then k[n]=true table.insert(j,n.Id)end end end table.sort(j)local l="_"..table.concat(j,"_")if d[l]==nil then d[l]=setmetatable({id=l,_components=k},h)g=g+1 end return d[l]end function h.Version()return g end function h:Has(i)return(self._components[i]==true)end function h:With(i)if self._components[i]==true then return self end local j=e[self]if not j then j={}e[self]=j end local k=j[i]if k==nil then local l={i}for m,n in pairs(self._components)do table.insert(l,m)end k=h.Of(l)j[i]=k end return k end function h:WithAll(i)local j={}for k,l in pairs(self._components)do table.insert(j,k)end for k,l in ipairs(i)do if self._components[l]==nil then table.insert(j,l)end end return h.Of(j)end function h:Without(i)if self._components[i]==nil then return self end local j=f[self]if not j then j={}f[self]=j end local k=j[i]if k==nil then local l={}for m,n in pairs(self._components)do if m~=i then table.insert(l,m)end end k=h.Of(l)j[i]=k end return k end function h:WithoutAll(i)local j={}for l,m in ipairs(i)do j[m]=true end local k={}for l,m in pairs(self._components)do if j[l]==nil then table.insert(k,l)end end return h.Of(k)end h.EMPTY=h.Of({})return h end b["Component"]=function()local d=c("Utility")local e=c("ComponentFSM")local f=d.copyDeep local g=d.mergeDeep local h=0 local function i(l,m)h=h+1 local n={Id=h,IsCType=true,SuperClass=m}n.__index=n if m==nil then m=n m._Qualifiers={["Primary"]=n}m._QualifiersArr={n}m._Initializers={}else m.HasQualifier=true n.IsQualifier=true n.HasQualifier=true end local o=m._Qualifiers local p=m._QualifiersArr setmetatable(n,{__call=function(q,r)return n.New(r)end,__index=function(q,r)if(r=="States")then return m.__States end if(r=="Case"or r=="StateInitial")then return rawget(m,r)end end,__newindex=function(q,r,s)if(r=="Case"or r=="States"or r=="StateInitial")then if n==m then if(r=="States")then if not m.IsFSM then e.AddCapability(m,s)for t,u in pairs(o)do if u~=m then e.AddMethods(m,u)end end end else rawset(q,r,s)end end else rawset(q,r,s)end end})if m.IsFSM then e.AddMethods(m,n)end function n.Qualifier(q)if type(q)~="string"then for s,t in ipairs(p)do if t==q then return q end end return nil end local r=o[q]if r==nil then r=i(l,m)o[q]=r table.insert(p,r)end return r end function n.Qualifiers(...)local q={...}if#q==0 then return p else local r={}local s={}for t,u in ipairs({...})do local v=n.Qualifier(u)if v and s[v]==nil then s[v]=true table.insert(r,v)end end return r end end function n.New(q)if(q~=nil and type(q)~="table")then q={value=q}end local r=setmetatable(l(q)or{},n)for s,t in ipairs(m._Initializers)do t(r)end r.isComponent=true r._qualifiers={[n]=r}return r end function n:GetType()return n end function n:Is(q)return q==n or q==m end function n:Primary()return self._qualifiers[m]end function n:Qualified(q)return self._qualifiers[n.Qualifier(q)]end function n:QualifiedAll()local q={}for r,s in pairs(o)do q[r]=self._qualifiers[s]end return q end function n:Merge(q)if m.HasQualifier then if self==q then return end if self._qualifiers==q._qualifiers then return end if not q:Is(m)then return end local r=n local s=q:GetType()local t if r==m then t=self._qualifiers elseif s==m then t=q._qualifiers elseif self._qualifiers[m]~=nil then t=self._qualifiers[m]._qualifiers elseif q._qualifiers[m]~=nil then t=q._qualifiers[m]._qualifiers end if t~=nil then if self._qualifiers~=t then for u,v in pairs(self._qualifiers)do if m~=u then t[u]=v v._qualifiers=t end end end if q._qualifiers~=t then for u,v in pairs(q._qualifiers)do if m~=u then t[u]=v v._qualifiers=t end end end else for u,v in pairs(q._qualifiers)do if r~=u then self._qualifiers[u]=v v._qualifiers=self._qualifiers end end end end end function n:Detach()if not m.HasQualifier then return end self._qualifiers[n]=nil self._qualifiers={[n]=self}end return n end local function j(l)return l or{}end local k={}function k.Create(l)local m=j if l~=nil then local n=type(l)if(n=="function")then m=l else if(n~="table")then l={value=l}end m=function(o)local p=f(l)if(o~=nil)then g(p,o)end return p end end end return i(m,nil)end return k end b["ComponentFSM"]=function()local d=c("Query")local e=d.Filter(function(g,h)local i=h.States local j=h.IsSuperClass local k=h.ComponentClass if j then local l=k.Qualifiers()for m,n in ipairs(l)do local o=g[n]if(o~=nil and i[o:GetState()]==true)then return true end end return false else local l=g[k]if l==nil then return false end return i[l:GetState()]==true end end)local f={}function f.AddCapability(g,h)g.IsFSM=true local i=setmetatable({},{__newindex=function(j,k,l)if(type(l)~="table")then l={l}end if table.find(l,"*")then rawset(j,k,"*")else local m=table.find(l,k)if m~=nil then table.remove(l,m)if#l==0 then l="*"end end rawset(j,k,l)end end})rawset(g,"__States",i)for j,k in pairs(h)do if g.StateInitial==nil then g.StateInitial=j end i[j]=k end f.AddMethods(g,g)table.insert(g._Initializers,function(j)j:SetState(g.StateInitial)end)end function f.AddMethods(g,h)h.IsFSM=true local i=g.States function h.In(...)local j={}local k=0 for l,m in ipairs({...})do if(i[m]~=nil and j[m]==nil)then k=k+1 j[m]=true end end if k==0 then return{}end return e({States=j,IsSuperClass=(h==g),ComponentClass=h,})end function h:SetState(j)if(j==nil or i[j]==nil)then return end local k=self:GetState()if(k==j)then return end if(k~=nil)then local m=i[k]if(m~="*"and table.find(m,j)==nil)then return end end self._state=j self._statePrev=k self._stateTime=os.clock()local l=g.Case and g.Case[j]if l then l(self,k)end end function h:GetState()return self._state or g.StateInitial end function h:GetPrevState()return self._statePrev or nil end function h:GetStateTime()return self._stateTime or 0 end end return f end b["ECS"]=function()local d=c("Query")local e=c("World")local f=c("System")local g=c("Archetype")local h=c("Component")local function i(k)e.LoopManager=k end pcall(function()if(game and game.ClassName=="DataModel")then i(c("RobloxLoopManager")())end end)local j={Query=d,World=e.New,System=f.Create,Archetype=g,Component=h.Create,SetLoopManager=i}if _G.ECS==nil then _G.ECS=j else local k=_G.warn or print k("ECS Lua was not registered in the global variables, there is already another object registered.")end return j end b["Entity"]=function()local d=c("Archetype")local e=0 local function f(m,...)local n={...}local o=m._data if(#n==1)then local q=n[1]if(q.IsCType and not q.isComponent)then return o[q]else return nil end end local p={}for q,r in ipairs(n)do if(r.IsCType and not r.isComponent)then table.insert(p,o[r])end end return table.unpack(p)end local function g(m,n,o)local p=m._data local q for r,s in ipairs(o.Qualifiers())do if s~=o then q=p[s]if q then break end end end if q then q:Merge(n)end end local function h(m,...)local n={...}local o=m._data local p=m.archetype local q=p local r={}local s=n[1]if(s and s.IsCType and not s.isComponent)then local t=n[2]local u if t==nil then local v=o[s]if v then v:Detach()end o[s]=nil q=q:Without(s)elseif(type(t)=="table"and t.isComponent)then local v=o[s]if(v~=t)then if v then v:Detach()end s=t:GetType()o[s]=t q=q:With(s)if(s.HasQualifier or s.IsQualifier)then g(m,t,s)end end else local v=o[s]if v then v:Detach()end local w=s(t)o[s]=w q=q:With(s)if(s.HasQualifier or s.IsQualifier)then g(m,w,s)end end else for t,u in ipairs(n)do if(u.isComponent)then local v=u:GetType()local w=o[v]if(w~=u)then if w then w:Detach()end o[v]=u q=q:With(v)if(v.HasQualifier or v.IsQualifier)then g(m,u,v)end end end end end if(p~=q)then m.archetype=q m._onChange:Fire(m,p)end end local function i(m,...)local n=m._data local o=m.archetype local p=o for q,r in ipairs({...})do if r.isComponent then local s=r:GetType()local t=n[s]if t then t:Detach()end n[s]=nil p=p:Without(s)elseif r.IsCType then local s=n[r]if s then s:Detach()end n[r]=nil p=p:Without(r)end end if m.archetype~=p then m.archetype=p m._onChange:Fire(m,o)end end local function j(m,n)local o=m._data local p={}if(n~=nil and n.IsCType and not n.isComponent)then local q=n.Qualifiers()for r,s in ipairs(q)do local t=o[s]if t then table.insert(p,t)end end else for q,r in pairs(o)do table.insert(p,r)end end return p end local function k(m,n)if(n~=nil and n.IsCType and not n.isComponent)then local o=m._data local p=n.Qualifiers()for q,r in ipairs(p)do local s=o[r]if s then return s end end end end local l={__index=function(m,n)if(type(n)=="table")then return f(m,n)end end,__newindex=function(m,n,o)local p=true if(type(n)=="table"and(n.IsCType and not n.isComponent))then h(m,n,o)else rawset(m,n,o)end end}function l.New(m,n)local o=d.EMPTY local p={}if(n~=nil and#n>0)then local q={}for r,s in ipairs(n)do local t=s:GetType()table.insert(q,t)p[t]=s end o=d.Of(q)end e=e+1 return setmetatable({_data=p,_onChange=m,id=e,isAlive=false,archetype=o,Get=f,Set=h,Unset=i,GetAll=j,GetAny=k,},l)end return l end b["EntityRepository"]=function()local d=c("Event")local e={}e.__index=e function e.New()return setmetatable({_archetypes={},_entitiesArchetype={},},e)end function e:Insert(f)if(self._entitiesArchetype[f]==nil)then local g=f.archetype local h=self._archetypes[g]if(h==nil)then h={count=0,entities={}}self._archetypes[g]=h end h.entities[f]=true h.count=h.count+1 self._entitiesArchetype[f]=g else self:Update(f)end end function e:Remove(f)local g=self._entitiesArchetype[f]if g==nil then return end self._entitiesArchetype[f]=nil local h=self._archetypes[g]if(h~=nil and h.entities[f]==true)then h.entities[f]=nil h.count=h.count-1 if(h.count==0)then self._archetypes[g]=nil end end end function e:Update(f)local g=self._entitiesArchetype[f]if(g==nil or g==f.archetype)then return end self:Remove(f)self:Insert(f)end function e:Query(f)local g={}for h,i in pairs(self._archetypes)do if f:Match(h)then table.insert(g,i.entities)end end return f:Result(g),#g>0 end function e:FastCheck(f)for g,h in pairs(self._archetypes)do if f:Match(g)then return true end end return false end return e end b["Event"]=function()local d={}d.__index=d function d.New(f,g)return setmetatable({_event=f,_handler=g},d)end function d:Disconnect()local f=self._event if(f and not f.destroyed)then local g=table.find(f._handlers,self._handler)if g~=nil then table.remove(f._handlers,g)end end setmetatable(self,nil)end local e={}e.__index=e function e.New()return setmetatable({_handlers={}},e)end function e:Connect(f)if(type(f)=="function")then table.insert(self._handlers,f)return d.New(self,f)end error(("Event:Connect(%s)"):format(typeof(f)),2)end function e:Fire(...)if not self.destroyed then for f,g in ipairs(self._handlers)do g(table.unpack({...}))end end end function e:Destroy()setmetatable(self,nil)self._handlers=nil self.destroyed=true end return e end b["Query"]=function()local d=c("QueryResult")local e={}local f={}f.__index=f setmetatable(f,{__call=function(i,j,k,l)return f.New(j,k,l)end,})local function g(i,j,k)local l={}local m={}local n={}for o,p in ipairs(i)do if(l[p]==nil)then if(p.IsCType and not p.isComponent)then l[p]=true table.insert(m,p)table.insert(n,p.Id)else if p.Filter then l[p]=true p[j]=true table.insert(k,p)end end end end if#m>0 then table.sort(n)local o="_"..table.concat(n,"_")return m,o end end function f.New(i,j,k)local l={}local m,n,o if(j~=nil)then j,m=g(j,"IsAnyFilter",l)end if(i~=nil)then i,n=g(i,"IsAllFilter",l)end if(k~=nil)then k,o=g(k,"IsNoneFilter",l)end return setmetatable({isQuery=true,_any=j,_all=i,_none=k,_anyKey=m,_allKey=n,_noneKey=o,_cache={},_clauses=#l>0 and l or nil},f)end function f:Result(i)return d.New(i,self._clauses)end function f:Match(i)local j=self._cache local k=j[i]if k~=nil then return k else local l=e[i]if(l==nil)then l={Any={},All={},None={}}e[i]=l end local m=self._noneKey if m then local p=l.None[m]if(p==nil)then p=true for q,r in ipairs(self._none)do if i:Has(r)then p=false break end end l.None[m]=p end if(p==false)then j[i]=false return false end end local n=self._anyKey if n then local p=l.Any[n]if(p==nil)then p=false if(l.All[n]==true)then p=true else for q,r in ipairs(self._any)do if i:Has(r)then p=true break end end end l.Any[n]=p end if(p==false)then j[i]=false return false end end local o=self._allKey if o then local p=l.All[o]if(p==nil)then local q=true for r,s in ipairs(self._all)do if(not i:Has(s))then q=false break end end if q then p=true else p=false end l.All[o]=p end j[i]=p return p end j[i]=true return true end end local function h()local i={isQueryBuilder=true}local j function i.All(...)j=nil i._all={...}return i end function i.Any(...)j=nil i._any={...}return i end function i.None(...)j=nil i._none={...}return i end function i.Build()if j==nil then j=f.New(i._all,i._any,i._none)end return j end return i end function f.All(...)return h().All(...)end function f.Any(...)return h().Any(...)end function f.None(...)return h().None(...)end function f.Filter(i)return function(j)return{Filter=i,Config=j}end end return f end b["QueryResult"]=function()local function d(l,m,n)return m,(l(m)==true),true end local function e(l,m,n)return l(m),true,true end local function f(l,m,n)local o=(n<=l)return m,o,o end local function g(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=false break end end return m,o,true end local function h(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==false)then o=false break end end return m,o,true end local function i(l,m,n)local o=false for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=true break end end return m,o,true end local j={}local k={}k.__index=k function k.New(l,m)local n=j if(m and#m>0)then local o={}local p={}local q={}n={}for r,s in ipairs(m)do if s.IsNoneFilter then table.insert(q,s)elseif s.IsAnyFilter then table.insert(p,s)else table.insert(o,s)end end if(#q>0)then table.insert(n,{g,q})end if(#o>0)then table.insert(n,{h,o})end if(#p>0)then table.insert(n,{i,p})end end return setmetatable({chunks=l,_pipeline=n,},k)end function k:With(l,m)local n={}for o,p in ipairs(self._pipeline)do table.insert(n,p)end table.insert(n,{l,m})return setmetatable({chunks=self.chunks,_pipeline=n,},k)end function k:Filter(l)return self:With(d,l)end function k:Map(l)return self:With(e,l)end function k:Limit(l)return self:With(f,l)end function k:AnyMatch(l)local m=false self:ForEach(function(n)if l(n)then m=true end return m end)return m end function k:AllMatch(l)local m=true self:ForEach(function(n)if(not l(n))then m=false end return m==false end)return m end function k:FindAny()local l self:ForEach(function(m)l=m return true end)return l end function k:ToArray()local l={}self:ForEach(function(m)table.insert(l,m)end)return l end function k:Iterator()local l=coroutine.create(function()self:ForEach(function(m,n)coroutine.yield(m,n)end)end)return function()local m,n,o=coroutine.resume(l)return o,n end end function k:ForEach(l)local m=1 local n=self._pipeline local o=#n>0 if(not o)then for p,q in ipairs(self.chunks)do for r,s in pairs(q)do if(l(r,m)==true)then return end m=m+1 end end else for p,q in ipairs(self.chunks)do for r,s in pairs(q)do local t=false local u=true local v=r if(u and o)then for w,x in ipairs(n)do local y,z,A=x[1](x[2],v,m)if(not A)then t=true end if z then v=y else u=false break end end end if u then if(l(v,m)==true)then return end m=m+1 end if t then return end end end end end return k end b["RobloxLoopManager"]=function()local function d()local e=game:GetService("RunService")return{Register=function(f)local g=e.Stepped:Connect(function()f:Update("process",os.clock())end)local h=e.Heartbeat:Connect(function()f:Update("transform",os.clock())end)local i if(not e:IsServer())then i=e.RenderStepped:Connect(function()f:Update("render",os.clock())end)end return function()g:Disconnect()h:Disconnect()if i then i:Disconnect()end end end}end return d end b["System"]=function()local d={"task","render","process","transform"}local e={}function e.Create(f,g,h,i)if(f==nil or not table.find(d,f))then error("The step parameter must one of ",table.concat(d,", "))end if(g and type(g)=="function")then i=g g=nil elseif h and type(h)=="function"then i=h h=nil end if(g and type(g)=="table"and(g.isQuery or g.isQueryBuilder))then h=g g=nil end if(g==nil or g<0)then g=50 end if type(h)=="function"then i=h h=nil end if(h and h.isQueryBuilder)then h=h.Build()end local j={Step=f,Order=g,Query=h,}j.__index=j function j.New(k,l)local m=setmetatable({version=0,_world=k,_config=l,},j)if m.Initialize then m:Initialize(l)end return m end function j:GetType()return j end function j:Result(k)return self._world:Exec(k or j.Query)end function j:Destroy()if self.OnDestroy then self.OnDestroy()end setmetatable(self,nil)for k,l in pairs(self)do self[k]=nil end end if i and type(i)=="function"then j.Update=i end return j end return e end b["SystemExecutor"]=function()local function d(i)local j={}local k={}for l,m in ipairs(i)do local n=m:GetType()if(m._TaskState==nil)then m._TaskState="suspended"end if not k[n]then local o={Type=n,System=m,Depends={}}k[n]=o table.insert(j,o)end end for l,m in ipairs(j)do local n=m.Type.Before if(n~=nil and#n>0)then for p,q in ipairs(n)do local r=k[q]if r then r.Depends[m]=true end end end local o=m.Type.After if(o~=nil and#o>0)then for p,q in ipairs(o)do local r=k[q]if r then m.Depends[r]=true end end end end return j end local function e(i,j)return i.Order0 do local j=false local k,l=0,#self._schedulers-1 while k<=l do k=k+1 local m=self._schedulers[k]local n,o=m.Resume(i)if o then j=true end i=i-(n+0.00001)if(i<=0)then break end end if not j then return end end end local function h(i,j,k,l)local m=i.System m._TaskState="running"if(m.ShouldUpdate==nil or m.ShouldUpdate(j))then k.version=k.version+1 m:Update(j)m.version=k.version end m._TaskState="suspended"l(i)end function f:ScheduleTasks(i)local j=self._world local k={}local l={}local m={}local n={}local o={}local p,q=0,#self._task-1 while p<=q do p=p+1 local s=self._task[p]if(s.System._TaskState=="suspended")then s.System._TaskState="scheduled"local t=false for u,v in pairs(s.Depends)do t=true if o[u]==nil then o[u]={}end table.insert(o[u],s)end if(not t)then table.insert(k,s)end m[s]=true end end local function r(s)s.Thread=nil s.LastExecTime=nil n[s]=true if o[s]then local t=o[s]local u,v=0,#t-1 while u<=v do u=u+1 local w=t[u]if m[w]then local x=true for y,z in pairs(w.Depends)do if n[y]~=true then x=false break end end if x then m[w]=nil w.LastExecTime=0 w.Thread=coroutine.create(h)table.insert(l,w)end end end end end if#k>0 then local s,t=0,#k-1 while s<=t do s=s+1 local v=k[s]m[v]=nil v.LastExecTime=0 v.Thread=coroutine.create(h)table.insert(l,v)end local u u={Resume=function(v)table.sort(l,function(A,B)return A.LastExecTimev)then break end end end for A,B in ipairs(l)do if B.Thread==nil then local C=table.find(l,B)if C~=nil then table.remove(l,C)end end end local z=#l>0 if(not z)then local A=table.find(self._schedulers,u)if A~=nil then table.remove(self._schedulers,A)end end return w,z end}table.insert(self._schedulers,u)end end return f end b["Timer"]=function()local d=4 local function e(g)local h=0.0 local i=0.0 return function(j,k,l,m)local n=g.DeltaFixed local o=j-i if o>0.25 then o=0.25 end i=j g.Now=j h=h+o if k=="process"then if h>=n then g.Interpolation=1 l(g)local p=0 while(h>=n and p table} @param superClass {ComponentClass} @return ComponentClass ]] local function createComponentClass(initializer, superClass) CLASS_SEQ = CLASS_SEQ + 1 local ComponentClass = { Id = CLASS_SEQ, IsCType = true, -- Primary component SuperClass = superClass } ComponentClass.__index = ComponentClass if superClass == nil then superClass = ComponentClass superClass._Qualifiers = { ["Primary"] = ComponentClass } superClass._QualifiersArr = { ComponentClass } superClass._Initializers = {} else superClass.HasQualifier = true ComponentClass.IsQualifier = true ComponentClass.HasQualifier = true end local Qualifiers = superClass._Qualifiers local QualifiersArr = superClass._QualifiersArr setmetatable(ComponentClass, { __call = function(t, value) return ComponentClass.New(value) end, __index = function(t, key) if (key == "States") then return superClass.__States end if (key == "Case" or key == "StateInitial") then return rawget(superClass, key) end end, __newindex = function(t, key, value) if (key == "Case" or key == "States" or key == "StateInitial") then -- (FMS) Finite State Machine if ComponentClass == superClass then if (key == "States") then if not superClass.IsFSM then ComponentFSM.AddCapability(superClass, value) for _, qualifiedClass in pairs(Qualifiers) do if qualifiedClass ~= superClass then ComponentFSM.AddMethods(superClass, qualifiedClass) end end end else rawset(t, key, value) end end else rawset(t, key, value) end end }) if superClass.IsFSM then ComponentFSM.AddMethods(superClass, ComponentClass) end --[[ Gets a qualifier for this type of component. If the qualifier does not exist, a new class will be created, otherwise it brings the already registered class qualifier reference with the same name. @param qualifier {string|ComponentClass} @return ComponentClass ]] function ComponentClass.Qualifier(qualifier) if type(qualifier) ~= "string" then for _, qualifiedClass in ipairs(QualifiersArr) do if qualifiedClass == qualifier then return qualifier end end return nil end local qualifiedClass = Qualifiers[qualifier] if qualifiedClass == nil then qualifiedClass = createComponentClass(initializer, superClass) Qualifiers[qualifier] = qualifiedClass table.insert(QualifiersArr, qualifiedClass) end return qualifiedClass end --[[ Get all qualified class @param ... {string|ComponentClass} (Optional) Allows to filter the specific qualifiers @return ComponentClass[] ]] function ComponentClass.Qualifiers(...) local filter = {...} if #filter == 0 then return QualifiersArr else local qualifiers = {} local cTypes = {} for _,qualifier in ipairs({...}) do local qualifiedClass = ComponentClass.Qualifier(qualifier) if qualifiedClass and cTypes[qualifiedClass] == nil then cTypes[qualifiedClass] = true table.insert(qualifiers, qualifiedClass) end end return qualifiers end end --[[ Constructor @param value {any} If the value is not a table, it will be converted to the format "{ value = value}" @return Component ]] function ComponentClass.New(value) if (value ~= nil and type(value) ~= "table") then -- local MyComponent = Component({ value = Vector3.new(0, 0, 0) }) -- local component = MyComponent(Vector3.new(10, 10, 10)) value = { value = value } end local component = setmetatable(initializer(value) or {}, ComponentClass) for _, fn in ipairs(superClass._Initializers) do fn(component) end component.isComponent = true component._qualifiers = { [ComponentClass] = component } return component end --[[ Get this component's class @return ComponentClass ]] function ComponentClass:GetType() return ComponentClass end --[[ Check if this component is of the type informed @param componentClass {ComponentClass} @return bool ]] function ComponentClass:Is(componentClass) return componentClass == ComponentClass or componentClass == superClass end --[[ Get the instance for the primary qualifier of this class @return Component|nil ]] function ComponentClass:Primary() return self._qualifiers[superClass] end --[[ Get the instance for the given qualifier of this class @param name {string|ComponentClass} @return Component|nil ]] function ComponentClass:Qualified(qualifier) return self._qualifiers[ComponentClass.Qualifier(qualifier)] end --[[ Get all instances for all qualifiers of that class @return Component[] ]] function ComponentClass:QualifiedAll() local qualifiedAll = {} for name, qualifiedClass in pairs(Qualifiers) do qualifiedAll[name] = self._qualifiers[qualifiedClass] end return qualifiedAll end --[[ Merges data from the other component into the current component. This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. @param other {Component} ]] function ComponentClass:Merge(other) if superClass.HasQualifier then if self == other then return end if self._qualifiers == other._qualifiers then return end if not other:Is(superClass) then return end local selfClass = ComponentClass local otherClass = other:GetType() -- does anyone know the reference to the primary entity? local primaryQualifiers if selfClass == superClass then primaryQualifiers = self._qualifiers elseif otherClass == superClass then primaryQualifiers = other._qualifiers elseif self._qualifiers[superClass] ~= nil then primaryQualifiers = self._qualifiers[superClass]._qualifiers elseif other._qualifiers[superClass] ~= nil then primaryQualifiers = other._qualifiers[superClass]._qualifiers end if primaryQualifiers ~= nil then if self._qualifiers ~= primaryQualifiers then for qualifiedClass, component in pairs(self._qualifiers) do if superClass ~= qualifiedClass then primaryQualifiers[qualifiedClass] = component component._qualifiers = primaryQualifiers end end end if other._qualifiers ~= primaryQualifiers then for qualifiedClass, component in pairs(other._qualifiers) do if superClass ~= qualifiedClass then primaryQualifiers[qualifiedClass] = component component._qualifiers = primaryQualifiers end end end else -- none of the instances know the Primary, use the current object reference for qualifiedClass, component in pairs(other._qualifiers) do if selfClass ~= qualifiedClass then self._qualifiers[qualifiedClass] = component component._qualifiers = self._qualifiers end end end end end --[[ Unlink this component with the other qualifiers ]] function ComponentClass:Detach() if not superClass.HasQualifier then return end -- remove old unlink self._qualifiers[ComponentClass] = nil -- new link self._qualifiers = { [ComponentClass] = self } end return ComponentClass end local function defaultInitializer(value) return value or {} end --[[ A Component is an object that can store data but should have not behaviour (As that should be handled by systems). ]] local Component = {} --[[ Register a new ComponentClass @param template {table|function(table?) -> table} When `table`, this template will be used for creating component instances When it's a `function`, it will be invoked when a new component is instantiated. The creation parameter of the component is passed to template function If the template type is different from `table` and `function`, **ECS Lua** will generate a template in the format `{ value = template }`. @return ComponentClass ]] function Component.Create(template) local initializer = defaultInitializer if template ~= nil then local ttype = type(template) if (ttype == "function") then initializer = template else if (ttype ~= "table") then template = { value = template } end initializer = function(value) local data = copyDeep(template) if (value ~= nil) then mergeDeep(data, value) end return data end end end return createComponentClass(initializer, nil) end return Component end __F__["ComponentFSM"] = function() -- src/ComponentFSM.lua --[[ Facilitate the construction and use of a Finite State Machine (FSM) using ECS Example: local Movement = ECS.Component({ Speed = 0 }) Movement.States = { Standing = "*", Walking = {"Standing", "Running"}, Running = {"Walking"} } Movement.StateInitial = "Standing" Movement.Case = { Standing = function(self, previous) self.Speed = 0 end, Walking = function(self, previous) self.Speed = 5 end, Running = function(self, previous) self.Speed = 10 end } local movement = entity[Movement] print(movement:GetState()) -- "Standing" movement:SetState("Walking") print(movement:GetPrevState()) -- "Standing" movement:GetStateTime() if (movement:GetState() == "Standing") then movement.Speed = 0 end ]] local Query = __REQUIRE__("Query") --[[ Filter used in Query and QueryResult @see QueryResult.lua Ex. ECS.Query.All(Movement.In("Standing", "Walking")) ]] local queryFilterCTypeStateIn = Query.Filter(function(entity, config) local states = config.States local isSuperClass = config.IsSuperClass local componentClass = config.ComponentClass if isSuperClass then local qualifiers = componentClass.Qualifiers() for _, qualifier in ipairs(qualifiers) do local component = entity[qualifier] if (component ~= nil and states[component:GetState()] == true) then return true end end return false else local component = entity[componentClass] if component == nil then return false end return states[component:GetState()] == true end end) local ComponentFSM = {} --[[ Adds FSM capability to a ComponentClass @param superClass {ComonentClass} @param states { {[key=string] => string|string[]}} @see Component.lua - createComponentClass() - ComponentClass__newindex ]] function ComponentFSM.AddCapability(superClass, states) superClass.IsFSM = true local cTypeStates = setmetatable({}, { __newindex = function(states, newState, value) if (type(value) ~= "table") then value = {value} end if table.find(value, "*") then rawset(states, newState, "*") else local idxSelf = table.find(value, newState) if idxSelf ~= nil then table.remove(value, idxSelf) if #value == 0 then value = "*" end end rawset(states, newState, value) end end }) rawset(superClass, "__States", cTypeStates) for state,value in pairs(states) do if superClass.StateInitial == nil then superClass.StateInitial = state end cTypeStates[state] = value end ComponentFSM.AddMethods(superClass, superClass) table.insert(superClass._Initializers, function(component) component:SetState(superClass.StateInitial) end) end --[[ Adds FSM state change methods to a ComponentClass @param superClass {ComponentClass} @param componentClass {ComponentClass} ]] function ComponentFSM.AddMethods(superClass, componentClass) componentClass.IsFSM = true local cTypeStates = superClass.States --[[ Creates a clause used to filter repository entities in a Query or QueryResult @param ... {string[]} @return Clause Ex. ECS.Query.All(Movement.In("Walking", "Running")) ]] function componentClass.In(...) local states = {} local count = 0 for _,state in ipairs({...}) do if (cTypeStates[state] ~= nil and states[state] == nil) then count = count + 1 states[state] = true end end if count == 0 then -- In any state return {} end return queryFilterCTypeStateIn({ States = states, IsSuperClass = (componentClass == superClass), ComponentClass = componentClass, }) end --[[ Defines the current state of the FSM @param newState {string} ]] function componentClass:SetState(newState) if (newState == nil or cTypeStates[newState] == nil) then return end local actual = self:GetState() if (actual == newState) then return end if (actual ~= nil ) then local transtions = cTypeStates[actual] if (transtions ~= "*" and table.find(transtions, newState) == nil) then -- not allowed return end end self._state = newState self._statePrev = actual self._stateTime = os.clock() local action = superClass.Case and superClass.Case[newState] if action then action(self, actual) end end --[[ Get the current state of the FSM @return string ]] function componentClass:GetState() return self._state or superClass.StateInitial end --[[ Get the previous state of the FSM @return string|nil ]] function componentClass:GetPrevState() return self._statePrev or nil end --[[ Gets the time it changed to the current state ]] function componentClass:GetStateTime() return self._stateTime or 0 end end return ComponentFSM end __F__["ECS"] = function() -- src/ECS.lua --[[ ECS Lua v2.2.0 ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development. https://github.com/nidorx/ecs-lua Discussions about this script are at https://devforum.roblox.com/t/841175 ------------------------------------------------------------------------------ MIT License Copyright (c) 2020 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Query = __REQUIRE__("Query") local World = __REQUIRE__("World") local System = __REQUIRE__("System") local Archetype = __REQUIRE__("Archetype") local Component = __REQUIRE__("Component") local function setLoopManager(manager) World.LoopManager = manager end pcall(function() if (game and game.ClassName == "DataModel") then -- is roblox setLoopManager(__REQUIRE__("RobloxLoopManager")()) end end) --[[ @TODO - Server entities - Client - Server sincronization (snapshot, delta, spatial index, grid manhatham distance) - Table pool (avoid GC) - System readonly? Paralel execution - Debugging? - Benchmark (Local Script vs ECS implementation) - Basic physics (managed) - SharedComponent? - Serializaton - world:Serialize() - world:Serialize(entity) - entity:Serialize() - component:Serialize() ]] local ECS = { Query = Query, World = World.New, System = System.Create, Archetype = Archetype, Component = Component.Create, SetLoopManager = setLoopManager } if _G.ECS == nil then _G.ECS = ECS else local warn = _G.warn or print warn("ECS Lua was not registered in the global variables, there is already another object registered.") end return ECS end __F__["Entity"] = function() -- src/Entity.lua --[[ The entity is a fundamental part of the Entity Component System. Everything in your game that has data or an identity of its own is an entity. However, an entity does not contain either data or behavior itself. Instead, the data is stored in the components and the behavior is provided by the systems that process those components. ]] local Archetype = __REQUIRE__("Archetype") local SEQ = 0 --[[ [GET] 01) comp1 = entity[CompType1] 02) comp1 = entity:Get(CompType1) 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ]] local function getComponent(entity, ...) local values = {...} local data = entity._data if (#values == 1) then local cType = values[1] if (cType.IsCType and not cType.isComponent) then -- 01) comp1 = entity[CompType1] -- 02) comp1 = entity:Get(CompType1) return data[cType] else return nil end end -- 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) local components = {} for i,cType in ipairs(values) do if (cType.IsCType and not cType.isComponent) then table.insert(components, data[cType]) end end return table.unpack(components) end --[[ Merges the qualifiers of a new added component. @param entity {Entity} @param newComponent {Component} @param newComponentClass {ComponentClass} ]] local function mergeComponents(entity, newComponent, newComponentClass) local data = entity._data local otherComponent -- get first instance for _,oCType in ipairs(newComponentClass.Qualifiers()) do if oCType ~= newComponentClass then otherComponent = data[oCType] if otherComponent then break end end end if otherComponent then otherComponent:Merge(newComponent) end end --[[ [SET] 01) entity[CompType1] = nil 02) entity[CompType1] = value 03) entity:Set(CompType1, nil) 04) entity:Set(CompType1, value) 05) entity:Set(comp1) 06) entity:Set(comp1, comp2, ...) ]] local function setComponent(entity, ...) local values = {...} local data = entity._data local archetypeOld = entity.archetype local archetypeNew = archetypeOld local toMerge = {} local cType = values[1] if (cType and cType.IsCType and not cType.isComponent) then local value = values[2] local component -- 01) entity[CompType1] = nil -- 02) entity[CompType1] = value -- 03) entity:Set(CompType1, nil) -- 04) entity:Set(CompType1, value) if value == nil then local old = data[cType] if old then old:Detach() end data[cType] = nil archetypeNew = archetypeNew:Without(cType) elseif (type(value) == "table" and value.isComponent) then local old = data[cType] if (old ~= value) then if old then old:Detach() end cType = value:GetType() data[cType] = value archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, value, cType) end end else local old = data[cType] if old then old:Detach() end local component = cType(value) data[cType] = component archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, component, cType) end end else -- 05) entity:Set(comp1) -- 06) entity:Set(comp1, comp2, ...) for i,component in ipairs(values) do if (component.isComponent) then local cType = component:GetType() local old = data[cType] if (old ~= component) then if old then old:Detach() end data[cType] = component archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, component, cType) end end end end end if (archetypeOld ~= archetypeNew) then entity.archetype = archetypeNew entity._onChange:Fire(entity, archetypeOld) end end --[[ [UNSET] 01) enity:Unset(comp1) 02) entity[CompType1] = nil 03) enity:Unset(CompType1) 04) enity:Unset(comp1, comp1, ...) 05) enity:Unset(CompType1, CompType2, ...) ]] local function unsetComponent(entity, ...) local data = entity._data local archetypeOld = entity.archetype local archetypeNew = archetypeOld for _,value in ipairs({...}) do if value.isComponent then -- 01) enity:Unset(comp1) -- 04) enity:Unset(comp1, comp1, ...) local cType = value:GetType() local old = data[cType] if old then old:Detach() end data[cType] = nil archetypeNew = archetypeNew:Without(cType) elseif value.IsCType then -- 02) entity[CompType1] = nil -- 03) enity:Unset(CompType1) -- 05) enity:Unset(CompType1, CompType2, ...) local old = data[value] if old then old:Detach() end data[value] = nil archetypeNew = archetypeNew:Without(value) end end if entity.archetype ~= archetypeNew then entity.archetype = archetypeNew entity._onChange:Fire(entity, archetypeOld) end end --[[ 01) comps = entity:GetAll() 01) qualifiers = entity:GetAll(PrimaryClass) ]] local function getAll(entity, qualifier) local data = entity._data local components = {} if (qualifier ~= nil and qualifier.IsCType and not qualifier.isComponent) then local ctypes = qualifier.Qualifiers() for _,cType in ipairs(ctypes) do local component = data[cType] if component then table.insert(components, component) end end else for _, component in pairs(data) do table.insert(components, component) end end return components end --[[ 01) comp = entity:GetAny(PrimaryClass) ]] local function getAny(entity, qualifier) if (qualifier ~= nil and qualifier.IsCType and not qualifier.isComponent) then local data = entity._data local ctypes = qualifier.Qualifiers() for _,cType in ipairs(ctypes) do local component = data[cType] if component then return component end end end end local Entity = { __index = function(e, key) if (type(key) == "table") then -- 01) local comp1 = entity[CompType1] -- 01) local comps = entity[{CompType1, CompType2, ...}] return getComponent(e, key) end end, __newindex = function(e, key, value) local isComponentSet = true if (type(key) == "table" and (key.IsCType and not key.isComponent)) then -- 01) entity[CompType1] = nil -- 02) entity[CompType1] = value setComponent(e, key, value) else rawset(e, key, value) end end } --[[ Creates an entity having components of the specified types. @param onChange {Event} @param components {Component[]} (Optional) ]] function Entity.New(onChange, components) local archetype = Archetype.EMPTY local data = {} if (components ~= nil and #components > 0) then local cTypes = {} for _, component in ipairs(components) do local cType = component:GetType() table.insert(cTypes, cType) data[cType] = component end archetype = Archetype.Of(cTypes) end SEQ = SEQ + 1 return setmetatable({ _data = data, _onChange = onChange, id = SEQ, isAlive = false, archetype = archetype, Get = getComponent, Set = setComponent, Unset = unsetComponent, GetAll = getAll, GetAny = getAny, }, Entity) end return Entity end __F__["EntityRepository"] = function() -- src/EntityRepository.lua local Event = __REQUIRE__("Event") --[[ The repository (database) of entities in a world. The repository indexes entities by archetype. Whenever the entity's archetype is changed, the entity is transported to the correct storage. ]] local EntityRepository = {} EntityRepository.__index = EntityRepository --[[ Create a new repository @return EntityRepository ]] function EntityRepository.New() return setmetatable({ _archetypes = {}, _entitiesArchetype = {}, }, EntityRepository) end --[[ Insert an entity into this repository @param entity {Entity} ]] function EntityRepository:Insert(entity) if (self._entitiesArchetype[entity] == nil) then local archetype = entity.archetype local storage = self._archetypes[archetype] if (storage == nil) then storage = { count = 0, entities = {} } self._archetypes[archetype] = storage end storage.entities[entity] = true storage.count = storage.count + 1 self._entitiesArchetype[entity] = archetype else self:Update(entity) end end --[[ Remove an entity from this repository @param entity {Entity} ]] function EntityRepository:Remove(entity) local archetypeOld = self._entitiesArchetype[entity] if archetypeOld == nil then return end self._entitiesArchetype[entity] = nil local storage = self._archetypes[archetypeOld] if (storage ~= nil and storage.entities[entity] == true) then storage.entities[entity] = nil storage.count = storage.count - 1 if (storage.count == 0) then self._archetypes[archetypeOld] = nil end end end --[[ Updates the entity in the repository, if necessary, moves the entity from one storage to another @param entity {Entity} ]] function EntityRepository:Update(entity) local archetypeOld = self._entitiesArchetype[entity] if (archetypeOld == nil or archetypeOld == entity.archetype) then return end self:Remove(entity) self:Insert(entity) end --[[ Execute the query entered in this repository @param query {Query} @return QueryResult ]] function EntityRepository:Query(query) local chunks = {} for archetype, storage in pairs(self._archetypes) do if query:Match(archetype) then table.insert(chunks, storage.entities) end end return query:Result(chunks), #chunks > 0 end --[[ Quick check to find out if a query is applicable. @param query {Query} @return bool ]] function EntityRepository:FastCheck(query) for archetype, storage in pairs(self._archetypes) do if query:Match(archetype) then return true end end return false end return EntityRepository end __F__["Event"] = function() -- src/Event.lua --[[ Subscription ]] local Connection = {} Connection.__index = Connection function Connection.New(event, handler) return setmetatable({ _event = event, _handler = handler }, Connection) end -- Unsubscribe function Connection:Disconnect() local event = self._event if (event and not event.destroyed) then local idx = table.find(event._handlers, self._handler) if idx ~= nil then table.remove(event._handlers, idx) end end setmetatable(self, nil) end --[[ Observer Pattern Allows the application to fire events of a particular type. ]] local Event = {} Event.__index = Event function Event.New() return setmetatable({ _handlers = {} }, Event) end function Event:Connect(handler) if (type(handler) == "function") then table.insert(self._handlers, handler) return Connection.New(self, handler) end error(("Event:Connect(%s)"):format(typeof(handler)), 2) end function Event:Fire(...) if not self.destroyed then for i,handler in ipairs(self._handlers) do handler(table.unpack({...})) end end end function Event:Destroy() setmetatable(self, nil) self._handlers = nil self.destroyed = true end return Event end __F__["Query"] = function() -- src/Query.lua local QueryResult = __REQUIRE__("QueryResult") --[[ Global cache result. The validated components are always the same (reference in memory, except within the archetypes), in this way, you can save the result of a query in an archetype, reducing the overall execution time (since we don't need to iterate all the time) @type KEY string = concat(Array, "_") @Type { [Archetype] = { Any = { [KEY] = bool }, All = { [KEY] = bool }, None = { [KEY] = bool }, } } ]] local CACHE = {} --[[ Interface for creating filters for existing entities in the ECS world ]] local Query = {} Query.__index = Query setmetatable(Query, { __call = function(t, all, any, none) return Query.New(all, any, none) end, }) local function parseFilters(list, clauseGroup, clauses) local indexed = {} local cTypes = {} local cTypeIds = {} for i,item in ipairs(list) do if (indexed[item] == nil) then if (item.IsCType and not item.isComponent) then indexed[item] = true table.insert(cTypes, item) table.insert(cTypeIds, item.Id) else -- clauses if item.Filter then indexed[item] = true item[clauseGroup] = true table.insert(clauses, item) end end end end if #cTypes > 0 then table.sort(cTypeIds) local cTypesKey = "_" .. table.concat(cTypeIds, "_") return cTypes, cTypesKey end end --[[ Create a new Query used to filter entities in the world. It makes use of local and global cache in order to decrease the validation time (avoids looping in runtime of systems) @param all {Array} Optional All component types in this array must exist in the archetype @param any {Array} Optional At least one of the component types in this array must exist in the archetype @param none {Array} Optional None of the component types in this array can exist in the archetype ]] function Query.New(all, any, none) -- used by QueryResult local clauses = {} local anyKey, allKey, noneKey if (any ~= nil) then any, anyKey = parseFilters(any, "IsAnyFilter", clauses) end if (all ~= nil) then all, allKey = parseFilters(all, "IsAllFilter", clauses) end if (none ~= nil) then none, noneKey = parseFilters(none, "IsNoneFilter", clauses) end return setmetatable({ isQuery = true, _any = any, _all = all, _none = none, _anyKey = anyKey, _allKey = allKey, _noneKey = noneKey, _cache = {}, -- local cache (L1) _clauses = #clauses > 0 and clauses or nil }, Query) end --[[ Generate a QueryResult with the chunks entered and the clauses of the current query @param chunks {Chunk} @return QueryResult ]] function Query:Result(chunks) return QueryResult.New(chunks, self._clauses) end --[[ Checks if the entered archetype is valid by the query definition @param archetype {Archetype} @return bool ]] function Query:Match(archetype) -- cache L1 local localCache = self._cache -- check local cache (L1) local cacheResult = localCache[archetype] if cacheResult ~= nil then return cacheResult else -- check global cache (executed by other filter instance) local globalCache = CACHE[archetype] if (globalCache == nil) then globalCache = { Any = {}, All = {}, None = {} } CACHE[archetype] = globalCache end -- check if these combinations exist in this component array local noneKey = self._noneKey if noneKey then local isNoneValid = globalCache.None[noneKey] if (isNoneValid == nil) then isNoneValid = true for _, cType in ipairs(self._none) do if archetype:Has(cType) then isNoneValid = false break end end globalCache.None[noneKey] = isNoneValid end if (isNoneValid == false) then localCache[archetype] = false return false end end local anyKey = self._anyKey if anyKey then local isAnyValid = globalCache.Any[anyKey] if (isAnyValid == nil) then isAnyValid = false if (globalCache.All[anyKey] == true) then isAnyValid = true else for _, cType in ipairs(self._any) do if archetype:Has(cType) then isAnyValid = true break end end end globalCache.Any[anyKey] = isAnyValid end if (isAnyValid == false) then localCache[archetype] = false return false end end local allKey = self._allKey if allKey then local isAllValid = globalCache.All[allKey] if (isAllValid == nil) then local haveAll = true for _, cType in ipairs(self._all) do if (not archetype:Has(cType)) then haveAll = false break end end if haveAll then isAllValid = true else isAllValid = false end globalCache.All[allKey] = isAllValid end localCache[archetype] = isAllValid return isAllValid end -- empty query = SELECT * FROM localCache[archetype] = true return true end end local function builder() local builder = { isQueryBuilder = true } local query function builder.All(...) query = nil builder._all = {...} return builder end function builder.Any(...) query = nil builder._any = {...} return builder end function builder.None(...) query = nil builder._none = {...} return builder end function builder.Build() if query == nil then query = Query.New(builder._all, builder._any, builder._none) end return query end return builder end function Query.All(...) return builder().All(...) end function Query.Any(...) return builder().Any(...) end function Query.None(...) return builder().None(...) end --[[ Create custom filters that can be used in Queries. Its execution is delayed, invoked only in QueryResult methods The result of executing the clause depends on how it was used in the query. Ex. If used in Query.All() the result is the inverse of using the same clause in Query.None() local Player = ECS.Component({ health = 100 }) local HealthPlayerFilter = ECS.Query.Filter(function(entity, config) local player = entity[Player] return player.health >= config.minHealth and player.health <= config.maxHealth end) local healthyClause = HealthPlayerFilter({ minHealth = 80, maxHealth = 100, }) local healthyQuery = ECS.Query.All(Player, healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is very healthy end) local notHealthyQuery = ECS.Query.All(Player).None(healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is NOT very healthy end) local dyingClause = HealthPlayerClause({ minHealth = 1, maxHealth = 20, }) local dyingQuery = ECS.Query.All(Player, dyingClause) world:Exec(dyingQuery):ForEach(function(entity) -- this player is about to die end) local notDyingQuery = ECS.Query.All(Player).None(dyingClause) world:Exec(notDyingQuery):ForEach(function(entity) -- this player is NOT about to die end) @param filter {function(entity, config):bool} @return function(config):Clause ]] function Query.Filter(filter) return function (config) return { Filter = filter, Config = config } end end return Query end __F__["QueryResult"] = function() -- src/QueryResult.lua --[[ OperatorFunction = function(param, value, count) => newValue, acceptItem, mustContinue ]] local function operatorFilter(predicate, value, count) return value, (predicate(value) == true), true end local function operatorMap(mapper, value, count) return mapper(value), true, true end local function operatorLimit(limit, value, count) local accept = (count <= limit) return value, accept, accept end local function operatorClauseNone(clauses, value, count) local acceptItem = true for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == true) then acceptItem = false break end end return value, acceptItem, true end local function operatorClauseAll(clauses, value, count) local acceptItem = true for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == false) then acceptItem = false break end end return value, acceptItem, true end local function operatorClauseAny(clauses, value, count) local acceptItem = false for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == true) then acceptItem = true break end end return value, acceptItem, true end local EMPTY_OBJECT = {} --[[ The result of a Query that was executed on an EntityStorage. QueryResult provides several methods to facilitate the filtering of entities resulting from the execution of the query. ]] local QueryResult = {} QueryResult.__index = QueryResult --[[ Build a new QueryResult @param chunks { Array<{ [Entity] = true }> } @clauses {Clause[]} @see Query.lua @see EntityRepository:Query(query) @return QueryResult ]] function QueryResult.New(chunks, clauses) local pipeline = EMPTY_OBJECT if (clauses and #clauses > 0) then local all = {} local any = {} local none = {} pipeline = {} for i,clause in ipairs(clauses) do if clause.IsNoneFilter then table.insert(none, clause) elseif clause.IsAnyFilter then table.insert(any, clause) else table.insert(all, clause) end end if (#none > 0) then table.insert(pipeline, {operatorClauseNone, none}) end if (#all > 0) then table.insert(pipeline, {operatorClauseAll, all}) end if (#any > 0) then table.insert(pipeline, {operatorClauseAny, any}) end end return setmetatable({ chunks = chunks, _pipeline = pipeline, }, QueryResult) end --[[ ------------------------------------------------------------------------------------------------------------------- Intermediate Operations Intermediate operations return a new QueryResult. They are always lazy; executing an intermediate operation such as QueryResult:Filter() does not actually perform any filtering, but instead creates a new QueryResult that, when traversed, contains the elements of the initial QueryResult that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed. ]] --------------------------------------------------------------------------------------------------------------------- --[[ Returns a QueryResult consisting of the elements of this QueryResult with a new pipeline operation @param operation {function(param, value, count) -> newValue, accept, continues} @param param {any} @return the new QueryResult ]] function QueryResult:With(operation, param) local pipeline = {} for _,operator in ipairs(self._pipeline) do table.insert(pipeline, operator) end table.insert(pipeline, { operation, param }) return setmetatable({ chunks = self.chunks, _pipeline = pipeline, }, QueryResult) end --[[ Returns a QueryResult consisting of the elements of this QueryResult that match the given predicate. @param predicate {function(value) -> bool} a predicate to apply to each element to determine if it should be included @return the new QueryResult ]] function QueryResult:Filter(predicate) return self:With(operatorFilter, predicate) end --[[ Returns a QueryResult consisting of the results of applying the given function to the elements of this QueryResult. @param mapper {function(value) -> newValue} a function to apply to each element @return the new QueryResult ]] function QueryResult:Map(mapper) return self:With(operatorMap, mapper) end --[[ Returns a QueryResult consisting of the elements of this QueryResult, truncated to be no longer than maxSize in length. This is a short-circuiting stateful intermediate operation. @param maxSize {number} @return the new QueryResult ]] function QueryResult:Limit(maxSize) return self:With(operatorLimit, maxSize) end --[[ ------------------------------------------------------------------------------------------------------------------- Terminal Operations Terminal operations, such as QueryResult:ForEach or QueryResult.AllMatch, may traverse the QueryResult to produce a result or a side-effect. After the terminal operation is performed, the pipeline is considered consumed, and can no longer be used; if you need to traverse the same data source again, you must return to the data source to get a new QueryResult. ]] --------------------------------------------------------------------------------------------------------------------- --[[ Returns whether any elements of this result match the provided predicate. @param predicate { function(value) -> bool} a predicate to apply to elements of this result @returns true if any elements of the result match the provided predicate, otherwise false ]] function QueryResult:AnyMatch(predicate) local anyMatch = false self:ForEach(function(value) if predicate(value) then anyMatch = true end -- break if true return anyMatch end) return anyMatch end --[[ Returns whether all elements of this result match the provided predicate. @param predicate { function(value) -> bool} a predicate to apply to elements of this result @returns true if either all elements of the result match the provided predicate or the result is empty, otherwise false ]] function QueryResult:AllMatch(predicate) local allMatch = true self:ForEach(function(value) if (not predicate(value)) then allMatch = false end -- break if false return allMatch == false end) return allMatch end --[[ Returns some element of the result, or nil if the result is empty. This is a short-circuiting terminal operation. The behavior of this operation is explicitly nondeterministic; it is free to select any element in the result. Multiple invocations on the same result may not return the same value. @return {any} ]] function QueryResult:FindAny() local out self:ForEach(function(value) out = value -- break return true end) return out end --[[ Returns an array containing the elements of this QueryResult. This is a terminal operation. ]] function QueryResult:ToArray() local array = {} self:ForEach(function(value) table.insert(array, value) end) return array end --[[ Returns an Iterator, to use in for loop for count, entity in result:Iterator() do print(entity.id) end ]] function QueryResult:Iterator() local thread = coroutine.create(function() self:ForEach(function(value, count) -- These will be passed back again next iteration coroutine.yield(value, count) end) end) return function() local success, item, index = coroutine.resume(thread) return index, item end end --[[ Performs an action for each element of this QueryResult. This is a terminal operation. The behavior of this operation is explicitly nondeterministic. This operation does not guarantee to respect the encounter order of the QueryResult. @param action {function(value, count) -> bool} A action to perform on the elements, breaks execution case returns true ]] function QueryResult:ForEach(action) local count = 1 local pipeline = self._pipeline local hasPipeline = #pipeline > 0 if (not hasPipeline) then -- faster for _, entities in ipairs(self.chunks) do for entity, _ in pairs(entities) do if (action(entity, count) == true) then return end count = count + 1 end end else -- Pipeline this QueryResult, applying callback to each value for i, entities in ipairs(self.chunks) do for entity,_ in pairs(entities) do local mustStop = false local itemAccepted = true local value = entity if (itemAccepted and hasPipeline) then for _, operator in ipairs(pipeline) do local newValue, acceptItem, canContinue = operator[1](operator[2], value, count) if (not canContinue) then mustStop = true end if acceptItem then value = newValue else itemAccepted = false break end end end if itemAccepted then if (action(value, count) == true) then return end count = count + 1 end if mustStop then return end end end end end return QueryResult end __F__["RobloxLoopManager"] = function() -- src/RobloxLoopManager.lua local function InitManager() local RunService = game:GetService("RunService") return { Register = function(world) -- if not RunService:IsRunning() then -- return -- end local beforePhysics = RunService.Stepped:Connect(function() world:Update("process", os.clock()) end) local afterPhysics = RunService.Heartbeat:Connect(function() world:Update("transform", os.clock()) end) local beforeRender if (not RunService:IsServer()) then beforeRender = RunService.RenderStepped:Connect(function() world:Update("render", os.clock()) end) end return function() beforePhysics:Disconnect() afterPhysics:Disconnect() if beforeRender then beforeRender:Disconnect() end end end } end return InitManager end __F__["System"] = function() -- src/System.lua local STEPS = { "task", "render", "process", "transform" } local System = {} --[[ Create new System Class @param step {process|transform|render|task} @param order {number} (Optional) Allows you to set an execution order (for systems that are not `task`). Default 50 @param query {Query|QueryBuilder} (Optional) Filters the entities that will be processed by this system @param updateFn {function(self, Time)} (Optional) A shortcut for creating systems that only have the Update method @return SystemClass ]] function System.Create(step, order, query, updateFn) if (step == nil or not table.find(STEPS, step)) then error("The step parameter must one of ", table.concat(STEPS, ", ")) end if (order and type(order) == "function") then updateFn = order order = nil elseif query and type(query) == "function" then updateFn = query query = nil end if (order and type(order) == "table" and (order.isQuery or order.isQueryBuilder)) then query = order order = nil end if (order == nil or order < 0) then order = 50 end if type(query) == "function" then updateFn = query query = nil end if (query and query.isQueryBuilder) then query = query.Build() end local SystemClass = { Step = step, -- Allows you to define the execution priority level for this system Order = order, Query = query, -- After = {SystemC, SystemD}, When the system is a task, it allows you to define that this system should run AFTER other specific systems. -- Before = {SystemA, SystemB}, When the system is a task, it allows you to define that this system should run BEFORE other specific systems. --[[ ShouldUpdate(Time) -> bool - Invoked before 'Update', allows you to control the execution of the update Update(Time) [QuerySystem] OnRemove(Time, enity) OnExit(Time, entity) OnEnter(Time, entity) ]] } SystemClass.__index = SystemClass --[[ Create an instance of this system @param world {World} @param config {table} ]] function SystemClass.New(world, config) local system = setmetatable({ version = 0, _world = world, _config = config, }, SystemClass) if system.Initialize then system:Initialize(config) end return system end --[[ Get this system class @return SystemClass ]] function SystemClass:GetType() return SystemClass end --[[ Run a query in the world. A shortcut to `self._world:Exec(query)` @query {Query|QueryBuilder} Optional If nil, use default query @return QueryResult ]] function SystemClass:Result(query) return self._world:Exec(query or SystemClass.Query) end --[[ destroy this instance ]] function SystemClass:Destroy() if self.OnDestroy then self.OnDestroy() end setmetatable(self, nil) for k,v in pairs(self) do self[k] = nil end end if updateFn and type(updateFn) == "function" then SystemClass.Update = updateFn end return SystemClass end return System end __F__["SystemExecutor"] = function() -- src/SystemExecutor.lua --[[ After = {SystemC, SystemD}, An update order that requests ECS update this system after it updates another specified system. Before = {SystemA, SystemB}, An update order that requests ECS update this system before it updates another specified system. ]] local function mapTaskDependencies(systems) local nodes = {} local nodesByType = {} for i,system in ipairs(systems) do local sType = system:GetType() if (system._TaskState == nil) then -- suspended, scheduled, running system._TaskState = "suspended" end if not nodesByType[sType] then local node = { Type = sType, System = system, -- @type {[Node]=true} Depends = {} } nodesByType[sType] = node table.insert(nodes, node) end end for _, node in ipairs(nodes) do -- this system will update Before another specified system local before = node.Type.Before if (before ~= nil and #before > 0) then for _,sTypeOther in ipairs(before) do local otherNode = nodesByType[sTypeOther] if otherNode then otherNode.Depends[node] = true end end end -- this system will update After another specified system local after = node.Type.After if (after ~= nil and #after > 0) then for _,sTypeOther in ipairs(after) do local otherNode = nodesByType[sTypeOther] if otherNode then node.Depends[otherNode] = true end end end end return nodes end local function orderSystems(a, b) return a.Order < b.Order end --[[ Responsible for coordinating and executing the systems methods ]] local SystemExecutor = {} SystemExecutor.__index = SystemExecutor function SystemExecutor.New(world) local executor = setmetatable({ _world = world, _onExit = {}, _onEnter = {}, _onRemove = {}, _task = {}, _render = {}, _process = {}, _transform = {}, _schedulers = {}, _lastFrameMatchQueries = {}, _currentFrameMatchQueries = {}, }, SystemExecutor) world:OnQueryMatch(function(query) executor._currentFrameMatchQueries[query] = true end) return executor end function SystemExecutor:SetSystems(systems) local onExit = {} local onEnter = {} local onRemove = {} -- system:Update() local updateTask = {} local updateRender = {} local updateProcess = {} local updateTransform = {} for _, system in pairs(systems) do local step = system.Step if system.Update then if step == "task" then table.insert(updateTask, system) elseif step == "process" then table.insert(updateProcess, system) elseif step == "transform" then table.insert(updateTransform, system) elseif step == "render" then table.insert(updateRender, system) end end if (system.Query and system.Query.isQuery and step ~= "task") then if system.OnExit then table.insert(onExit, system) end if system.OnEnter then table.insert(onEnter, system) end if system.OnRemove then table.insert(onRemove, system) end end end updateTask = mapTaskDependencies(updateTask) table.sort(onExit, orderSystems) table.sort(onEnter, orderSystems) table.sort(onRemove, orderSystems) table.sort(updateRender, orderSystems) table.sort(updateProcess, orderSystems) table.sort(updateTransform, orderSystems) self._onExit = onExit self._onEnter = onEnter self._onRemove = onRemove self._task = updateTask self._render = updateRender self._process = updateProcess self._transform = updateTransform end --[[ @param Time @param changedEntities { { [Entity] = Old } } ]] function SystemExecutor:ExecOnExitEnter(Time, changedEntities) local isEmpty = true -- { [Old] = { [New] = {Entity, Entity, ...} } } local oldIndexed = {} for entity, archetypeOld in pairs(changedEntities) do local newIndexed = oldIndexed[archetypeOld] if not newIndexed then newIndexed = {} oldIndexed[archetypeOld] = newIndexed end local archetypeNew = entity.archetype local entities = newIndexed[archetypeNew] if not entities then entities = {} newIndexed[archetypeNew] = entities end table.insert(entities, entity) isEmpty = false end if isEmpty then return end self:_ExecOnEnter(Time, oldIndexed) self:_ExecOnExit(Time, oldIndexed) end --[[ Executes the systems' OnEnter method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:_ExecOnEnter(Time, oldIndexed) local world = self._world for _, system in ipairs(self._onEnter) do local query = system.Query for archetypeOld, newIndexed in pairs(oldIndexed) do if not query:Match(archetypeOld) then for archetypeNew, entities in pairs(newIndexed) do if query:Match(archetypeNew) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnEnter(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end end end --[[ Executes the systems' OnExit method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:_ExecOnExit(Time, oldIndexed) local world = self._world for _, system in ipairs(self._onExit) do local query = system.Query for archetypeOld, newIndexed in pairs(oldIndexed) do if query:Match(archetypeOld) then for archetypeNew, entities in pairs(newIndexed) do if not query:Match(archetypeNew) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnExit(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end end end --[[ Executes the systems' OnRemove method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:ExecOnRemove(Time, removedEntities) local isEmpty = true local oldIndexed = {} for entity, archetypeOld in pairs(removedEntities) do local entities = oldIndexed[archetypeOld] if not entities then entities = {} oldIndexed[archetypeOld] = entities end table.insert(entities, entity) isEmpty = false end if isEmpty then return end local world = self._world for _, system in ipairs(self._onRemove) do for archetypeOld, entities in pairs(oldIndexed) do if system.Query:Match(archetypeOld) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnRemove(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end local function execUpdate(executor, systems, Time) local world = executor._world local lastFrameMatchQueries = executor._lastFrameMatchQueries local currentFrameMatchQueries = executor._currentFrameMatchQueries for j, system in ipairs(systems) do local canExec = true if system.Query then local query = system.Query if lastFrameMatchQueries[query] == true or currentFrameMatchQueries[query] == true then -- If the query ran in the last frame, it is likely to run successfully on this canExec = true else -- Always revalidates, the repository undergoes constant change canExec = world:FastCheck(query) currentFrameMatchQueries[query] = canExec end end if canExec then if (system.ShouldUpdate == nil or system.ShouldUpdate(Time)) then world.version = world.version + 1 -- increment Global System Version (GSV) system:Update(Time) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end function SystemExecutor:ExecProcess(Time) self._currentFrameMatchQueries = {} execUpdate(self, self._process, Time) end function SystemExecutor:ExecTransform(Time) execUpdate(self, self._transform, Time) end function SystemExecutor:ExecRender(Time) execUpdate(self, self._render, Time) self._lastFrameMatchQueries = self._currentFrameMatchQueries end --[[ Starts the execution of Jobs. Each Job is performed in an individual coroutine @param maxExecTime {number} limits the amount of time jobs can run ]] function SystemExecutor:ExecTasks(maxExecTime) while maxExecTime > 0 do local hasMore = false -- https://github.com/wahern/cqueues/issues/231#issuecomment-562838785 local i, len = 0, #self._schedulers-1 while i <= len do i = i + 1 local scheduler = self._schedulers[i] local tasksTime, hasMoreTask = scheduler.Resume(maxExecTime) if hasMoreTask then hasMore = true end maxExecTime = maxExecTime - (tasksTime + 0.00001) if (maxExecTime <= 0) then break end end if not hasMore then return end end end local function execTask(node, Time, world, onComplete) local system = node.System system._TaskState = "running" if (system.ShouldUpdate == nil or system.ShouldUpdate(Time)) then world.version = world.version + 1 -- increment Global System Version (GSV) system:Update(Time) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end system._TaskState = "suspended" onComplete(node) end --[[ Invoked at the beginning of each frame, it schedules the execution of the next tasks ]] function SystemExecutor:ScheduleTasks(Time) local world = self._world local rootNodes = {} -- Node[] local runningNodes = {} -- Node[] local scheduled = {} -- { [Node] = true } local completed = {} -- { [Node] = true } local dependents = {} -- { [Node] = Node[] } local i, len = 0, #self._task-1 while i <= len do i = i + 1 local node = self._task[i] if (node.System._TaskState == "suspended") then -- will be executed node.System._TaskState = "scheduled" local hasDependencies = false for other,_ in pairs(node.Depends) do hasDependencies = true if dependents[other] == nil then dependents[other] = {} end table.insert(dependents[other], node) end if (not hasDependencies) then table.insert(rootNodes, node) end scheduled[node] = true end end -- suspended, scheduled, running local function onComplete(node) node.Thread = nil node.LastExecTime = nil completed[node] = true -- alguma outra tarefa depende da execucao deste no para executar? if dependents[node] then local dependentsFromNode = dependents[node] local i, len = 0, #dependentsFromNode-1 while i <= len do i = i + 1 local dependent = dependentsFromNode[i] if scheduled[dependent] then local allDependenciesCompleted = true for otherNode,_ in pairs(dependent.Depends) do if completed[otherNode] ~= true then allDependenciesCompleted = false break end end if allDependenciesCompleted then scheduled[dependent] = nil dependent.LastExecTime = 0 dependent.Thread = coroutine.create(execTask) table.insert(runningNodes, dependent) end end end end end if #rootNodes > 0 then local i, len = 0, #rootNodes-1 while i <= len do i = i + 1 local node = rootNodes[i] scheduled[node] = nil node.LastExecTime = 0 node.Thread = coroutine.create(execTask) table.insert(runningNodes, node) end local scheduler scheduler = { Resume = function(maxExecTime) -- orders the threads, executing the ones with the least execution time first this prevents long tasks -- from taking up all the processing time table.sort(runningNodes, function(nodeA, nodeB) return nodeA.LastExecTime < nodeB.LastExecTime end) local totalTime = 0 -- https://github.com/wahern/cqueues/issues/231#issuecomment-562838785 local i, len = 0, #runningNodes-1 while i <= len do i = i + 1 local node = runningNodes[i] if node.Thread ~= nil then local execTime = os.clock() node.LastExecTime = execTime coroutine.resume(node.Thread, node, Time, world, onComplete) totalTime = totalTime + (os.clock() - execTime) if (totalTime > maxExecTime) then break end end end -- remove completed for i,node in ipairs(runningNodes) do if node.Thread == nil then local idx = table.find(runningNodes, node) if idx ~= nil then table.remove(runningNodes, idx) end end end local hasMore = #runningNodes > 0 if (not hasMore) then local idx = table.find(self._schedulers, scheduler) if idx ~= nil then table.remove(self._schedulers, idx) end end return totalTime, hasMore end } table.insert(self._schedulers, scheduler) end end return SystemExecutor end __F__["Timer"] = function() -- src/Timer.lua -- if execution is slow, perform a maximum of 4 simultaneous updates in order to keep the fixrate local MAX_SKIP_FRAMES = 4 local function loop(Time) local accumulator = 0.0 local lastStepTime = 0.0 return function (newTime, stepName, beforeUpdateFn, updateFn) local dtFixed = Time.DeltaFixed local stepTime = newTime - lastStepTime if stepTime > 0.25 then stepTime = 0.25 end lastStepTime = newTime Time.Now = newTime -- 1000/30/1000 = 0.03333333333333333 accumulator = accumulator + stepTime --[[ Adjusting the framerate, the world must run on the same frequency, this ensures determinism in the execution of the scripts Each system in "transform" step is executed at a predetermined frequency (in Hz). Ex. If the game is running on the client at 30FPS but a system needs to be run at 120Hz or 240Hz, this logic will ensure that this frequency is reached @see https://gafferongames.com/post/fix_your_timestep/ @see https://gameprogrammingpatterns.com/game-loop.html @see https://bell0bytes.eu/the-game-loop/ ]] if stepName == "process" then if accumulator >= dtFixed then Time.Interpolation = 1 beforeUpdateFn(Time) local nLoops = 0 while (accumulator >= dtFixed and nLoops < MAX_SKIP_FRAMES) do updateFn(Time) nLoops = nLoops + 1 Time.Process = Time.Process + dtFixed accumulator = accumulator - dtFixed end end else Time.Interpolation = math.min(math.max(accumulator/dtFixed, 0), 1) beforeUpdateFn(Time) updateFn(Time) end end end local Timer = {} Timer.__index = Timer function Timer.New(frequency) local Time = { Now = 0, -- The time at the beginning of this frame. The world receives the current time at the beginning -- of each frame, with the value increasing per frame. Frame = 0, Process = 0, -- The time the latest process step has started. Delta = 0, -- The completion time in seconds since the last frame. DeltaFixed = 0, -- INTERPOLATION: The proportion of time since the previous transform relative to processDeltaTime Interpolation = 0 } local timer = setmetatable({ -- Public, visible by systems Time = Time, Frequency = 0, _update = loop(Time) }, Timer) timer:SetFrequency(frequency) return timer end --[[ Changes the frequency of execution of the "process" step @param frequency {number} ]] function Timer:SetFrequency(frequency) -- frequency: number, -- The maximum times per second this system should be updated. Defaults 30 if frequency == nil then frequency = 30 end local safeFrequency = math.floor(math.abs(frequency)/2)*2 if safeFrequency < 2 then safeFrequency = 2 end if frequency ~= safeFrequency then frequency = safeFrequency end self.Frequency = frequency self.Time.DeltaFixed = 1000/frequency/1000 end function Timer:Update(now, step, beforeUpdate, update) self._update(now, step, beforeUpdate, update) end return Timer end __F__["Utility"] = function() -- src/Utility.lua --[[ Utility library. ]] local Utility = {} if table.unpack == nil then table.unpack = unpack end if table.find == nil then --[[ Within the given array-like table haystack, find the first occurrence of value needle, starting from index init or the beginning if not provided. If the value is not found, nil is returned. A linear search algorithm is performed. ]] table.find = function(haystack, needle, init) local len = #haystack for i = init or 1, len, 1 do if haystack[i] == needle then return i end end return nil end end local function copyDeep(src) local copy = {} for k, v in pairs(src) do if type(v) == "table" then v = copyDeep(v) end copy[k] = v end return copy end Utility.copyDeep = copyDeep --[[ Faz o merge dos atributos src com o dest Quando o um atributo do segundo é um "table", faz uma copia do valor ]] local function mergeDeep(dest, src) for k,valueSrc in pairs(src) do if (type(valueSrc) == "table") then local valueDest = dest[k] if (valueDest == nil or type(valueDest) ~= "table") then dest[k] = copyDeep(valueSrc) else dest[k] = mergeDeep(valueDest, valueSrc) end else dest[k] = valueSrc end end return dest end Utility.mergeDeep = mergeDeep return Utility end __F__["World"] = function() -- src/World.lua local Timer = __REQUIRE__("Timer") local Event = __REQUIRE__("Event") local Entity = __REQUIRE__("Entity") local Archetype = __REQUIRE__("Archetype") local SystemExecutor = __REQUIRE__("SystemExecutor") local EntityRepository = __REQUIRE__("EntityRepository") local World = {} World.__index = World --[[ Create a new world instance @param systemClasses {SystemClass[]} (Optional) Array of system classes @param frequency {number} (Optional) define the frequency that the `process` step will be executed. Default 30 @param disableAutoUpdate {bool} (Optional) when `~= false`, the world automatically registers in the `LoopManager`, receiving the `World:Update()` method from it. Default false ]] function World.New(systemClasses, frequency, disableAutoUpdate) local world = setmetatable({ --[[ Global System Version (GSV). Before executing the Update method of each system, the world version is incremented, so at this point, the world version will always be higher than the running system version. Whenever an entity archetype is changed (received or lost component) the entity's version is updated to the current version of the world. After executing the System Update method, the version of this system is updated to the current world version. This mechanism allows a system to know if an entity has been modified after the last execution of this same system, as the entity's version is superior to the version of the last system execution. Thus, a system can contain logic if it only operates on "dirty" entities, which have undergone changes. The code for this validation on a system is `local isDirty = entity.version > self.version` ]] version = 0, --[[ Allows you to define the maximum time that the JobSystem can operate in each frame. The default value is 0.011666666666666665 = ((1000/60/1000)*0.7) A game that runs at 30fps has 0.0333 seconds to do all the processing for each frame, including rendering - 30FPS = ((1000/30/1000)*0.7)/3 = 0.007777777777777777 A game that runs at 60fps has 0.0166 seconds to do all the processing for each frame, including rendering - 60FPS = ((1000/60/1000)*0.7)/3 = 0.0038888888888888883 ]] maxTasksExecTime = 0.013333333333333334, _dirty = false, -- True when create/remove entity, add/remove entity component (change archetype) _timer = Timer.New(frequency), _systems = {}, -- systems in this world _repository = EntityRepository.New(), _entitiesCreated = {}, -- created during the execution of the Update _entitiesRemoved = {}, -- removed during execution (only removed after the last execution step) _entitiesUpdated = {}, -- changed during execution (received or lost components, therefore, changed the archetype) _onQueryMatch = Event.New(), _onChangeArchetypeEvent = Event.New(), }, World) -- System execution plan world._executor = SystemExecutor.New(world) world._onChangeArchetypeEvent:Connect(function(entity, archetypeOld, archetypeNew) world:_OnChangeArchetype(entity, archetypeOld, archetypeNew) end) -- add systems if (systemClasses ~= nil) then for _, systemClass in ipairs(systemClasses) do world:AddSystem(systemClass) end end if (not disableAutoUpdate and World.LoopManager) then world._loopCancel = World.LoopManager.Register(world) end return world end --[[ Changes the frequency of execution of the "process" step @param frequency {number} ]] function World:SetFrequency(frequency) frequency = self._timer:SetFrequency(frequency) end --[[ Get the frequency of execution of the "process" step @return number ]] function World:GetFrequency(frequency) return self._timer.Frequency end --[[ Add a new system to the world. Only one instance per type is accepted. If there is already another instance of this system in the world, any new invocation of this method will be ignored. @param systemClass {SystemClass} The system to be added in the world @param config {table} (Optional) System instance configuration ]] function World:AddSystem(systemClass, config) if systemClass then if config == nil then config = {} end if self._systems[systemClass] == nil then self._systems[systemClass] = systemClass.New(self, config) self._executor:SetSystems(self._systems) end end end --[[ Create a new entity. The entity is created in DEAD state (entity.isAlive == false) and will only be visible for queries after the cleaning step (OnRemove, OnEnter, OnExit) of the current step @param args {Component[]} @return Entity ]] function World:Entity(...) local entity = Entity.New(self._onChangeArchetypeEvent, {...}) self._dirty = true self._entitiesCreated[entity] = true entity.version = self.version -- update entity version using current Global System Version (GSV) entity.isAlive = false return entity end --[[ Performs immediate removal of an entity. If the entity was created in this step and the cleanup process has not happened yet (therefore the entity is inactive, entity.isAlive == false), the `OnRemove` event will never be fired. If the entity is alive (entity.isAlive == true), even though it is removed immediately, the `OnRemove` event will be fired at the end of the current step. @param entity {Entity} ]] function World:Remove(entity) if self._entitiesRemoved[entity] == true then return end if self._entitiesCreated[entity] == true then self._entitiesCreated[entity] = nil else self._repository:Remove(entity) self._entitiesRemoved[entity] = true if self._entitiesUpdated[entity] == nil then self._entitiesUpdated[entity] = entity.archetype end end self._dirty = true entity.isAlive = false end --[[ Run a query in this world @param query {Query|QueryBuilder} @return QueryResult ]] function World:Exec(query) if (query.isQueryBuilder) then query = query.Build() end local result, match = self._repository:Query(query) if match then self._onQueryMatch:Fire(query) end return result end --[[ Quick check to find out if a query is applicable. @param query {Query|QueryBuilder} @return QueryResult ]] function World:FastCheck(query) if (query.isQueryBuilder) then query = query.Build() end return self._repository:FastCheck(query) end --[[ Add a callback that is reported whenever a query has been successfully executed. Used internally to quickly find out if a QuerySystem will run. ]] function World:OnQueryMatch(callback) return self._onQueryMatch:Connect(callback) end --[[ Perform world update. When registered, LoopManager will invoke World Update for each step in the sequence. - process At the beginning of each frame - transform After the game engine's physics engine runs - render Before rendering the current frame @param step {"process"|"transform"|"render"} @param now {number} Usually os.clock() ]] function World:Update(step, now) self._timer:Update( now, step, function(Time) --[[ JobSystem .------------------. | pipeline | |------------------| | s:ShouldUpdate() | | s:Update() | '------------------' ]] if step == "process" then self._executor:ScheduleTasks(Time) end -- run suspended Tasks self._executor:ExecTasks(self.maxTasksExecTime) end, function(Time) --[[ .------------------. | pipeline | |------------------| | s:ShouldUpdate() | | s:Update() | | | |-- CLEAR ---------| | s:OnRemove() | | s:OnExit() | | s:OnEnter() | '------------------' ]] if step == "process" then self._executor:ExecProcess(Time) elseif step == "transform" then self._executor:ExecTransform(Time) else self._executor:ExecRender(Time) end -- cleans up after running scripts while self._dirty do self._dirty = false -- 1: remove entities local entitiesRemoved = {} for entity,_ in pairs(self._entitiesRemoved) do entitiesRemoved[entity] = self._entitiesUpdated[entity] self._entitiesUpdated[entity] = nil end self._entitiesRemoved = {} self._executor:ExecOnRemove(Time, entitiesRemoved) entitiesRemoved = nil local changed = {} local hasChange = false -- 2: Update entities in memory for entity, archetypeOld in pairs(self._entitiesUpdated) do if (archetypeOld ~= entity.archetype) then hasChange = true changed[entity] = archetypeOld end end self._entitiesUpdated = {} -- 3: Add new entities for entity, _ in pairs(self._entitiesCreated) do hasChange = true changed[entity] = Archetype.EMPTY entity.isAlive = true self._repository:Insert(entity) end self._entitiesCreated = {} if hasChange then self._executor:ExecOnExitEnter(Time, changed) changed = nil end end end ) end --[[ Destroy this instance, removing all entities, systems and events ]] function World:Destroy() if self._loopCancel then self._loopCancel() self._loopCancel = nil end if self._onChangeArchetypeEvent then self._onChangeArchetypeEvent:Destroy() self._onChangeArchetypeEvent = nil end self._repository = nil if self._systems then for _,system in pairs(self._systems) do system:Destroy() end self._systems = nil end self._timer = nil self._ExecPlan = nil self._entitiesCreated = nil self._entitiesUpdated = nil self._entitiesRemoved = nil setmetatable(self, nil) end function World:_OnChangeArchetype(entity, archetypeOld, archetypeNew) if entity.isAlive then if self._entitiesUpdated[entity] == nil then self._dirty = true self._entitiesUpdated[entity] = archetypeOld end self._repository:Update(entity) -- update entity version using current Global System Version (GSV) entity.version = self.version end end return World end return __REQUIRE__("ECS") ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

https://nidorx.github.io/ecs-lua

Build Status

Read the Documentation

# What is it? ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development.
![](docs/assets/diagram-1.png)
The basic idea of this pattern is to stop defining entities using a [hierarchy](https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)) of classes and start doing use of [composition](https://en.wikipedia.org/wiki/Object_composition) in a Data Oriented Programming paradigm. ([More information on Wikipedia](https://en.wikipedia.org/wiki/Entity_component_system)). Programming with an ECS can result in code that is more efficient and easier to extend over time. # How does it work?
![ECS Lua pipeline](docs/assets/pipeline.png)
# Talk is cheap. Show me the code! ```lua local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component local Health = Component(100) local Position = Component({ x = 0, y = 0}) local isInAcid = Query.Filter(function() return true -- it's wet season end) local InAcidSystem = System("process", Query.All( Health, Position, isInAcid() )) function InAcidSystem:Update() for i, entity in self:Result():Iterator() do local health = entity[Health] health.value = health.value - 0.01 end end local world = World({ InAcidSystem }) world:Entity(Position({ x = 5.0 }), Health()) ``` # Features **ECS Lua** has no external dependencies and is compatible and tested with [Lua 5.1], [Lua 5.2], [Lua 5.3], [Lua 5.4], [LuaJit] and [Roblox Luau](https://luau-lang.org/) - **Game engine agnostic**: It can be used in any engine that has the Lua scripting language. - **Ergonomic**: Focused on providing a simple yet efficient API - **FSM**: Finite State Machines in an easy and intuitive way - **JobSystem**: To running systems in parallel (through [coroutines]) - **Reactive**: Systems can be informed when an entity changes - **Predictable**: - The systems will work in the order they were registered or based on the priority set when registering them. - Reactive events do not generate a random callback when issued, they are executed at a predefined step. # Goal To be a lightweight, simple, ergonomic and high-performance ECS library that can be easily extended. The **ECS Lua** does not strictly follow _"pure ECS design"_. # Usage Read our [Full Documentation][docs] to learn how to use **ECS Lua**. # Get involved All kinds of contributions are welcome! 🐛 **Found a bug?** Let me know by [creating an issue][new-issue]. ❓ **Have a question?** [Roblox DevForum][discussions] is a good place to start. ⚙️ **Interested in fixing a [bug][bugs] or adding a [feature][features]?** Check out the [contributing guidelines](CONTRIBUTING.md). 📖 **Can we improve [our documentation][docs]?** Pull requests even for small changes can be helpful. Each page in the docs can be edited by clicking the "Edit on GitHub" link at the bottom right. [docs]: https://nidorx.github.io/ecs-lua [bugs]: https://github.com/nidorx/ecs-lua/issues?q=is%3Aissue+is%3Aopen+label%3Abug [features]: https://github.com/nidorx/ecs-lua/issues?q=is%3Aissue+is%3Aopen+label%3Afeature [new-issue]: https://github.com/nidorx/ecs-lua/issues/new/choose [discussions]: https://devforum.roblox.com/t/841175 [Lua 5.1]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.2]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.3]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.4]:https://app.travis-ci.com/github/nidorx/ecs-lua [LuaJit]:https://app.travis-ci.com/github/nidorx/ecs-lua [coroutines]:http://www.lua.org/pil/9.1.html # License This code is distributed under the terms and conditions of the [MIT license](LICENSE). ================================================ FILE: build.lua ================================================ local OUTPUT_CONCAT = "ECS_concat" local OUTPUT_MINIFIED = "ECS" local SRC_FILES = { "Archetype", "Component", "ComponentFSM", "ECS", "Entity", "EntityRepository", "Event", "Query", "QueryResult", "RobloxLoopManager", "System", "SystemExecutor", "Timer", "Utility", "World" } local HEADER = [[ ECS Lua v2.2.0 ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development. This is a minified version of ECS Lua, to see the full source code visit https://github.com/nidorx/ecs-lua Discussions about this script are at https://devforum.roblox.com/t/841175 ------------------------------------------------------------------------------ MIT License Copyright (c) 2021 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] HEADER = "--[[\n"..HEADER.."\n]]\n" package.path = package.path .. ";modules/?.lua" local function concat() local concatContent = { HEADER, "local __M__, __F__ = {}, {}", "local function __REQUIRE__(m)", " if (not __M__[m]) then", " __M__[m] = { r = __F__[m]() }", " end", " return __M__[m].r", "end", "", } for i,name in ipairs(SRC_FILES) do local sourceFile = io.open("./src/"..name..".lua", "r") if not sourceFile then error("Could not open the input file `" .. OUTPUT_MINIFIED .. "`", 0) end local content = sourceFile:read( "*a" ) for _,oname in ipairs(SRC_FILES) do content = content:gsub('require[(]["\']'..oname..'["\'][)]', '__REQUIRE__("'..oname..'")') end table.insert(concatContent, table.concat({ '__F__["'..name..'"] = function()', (" -- src/"..name..".lua\n"..content):gsub("\n", "\n "), "end", "", }, "\n")) sourceFile:close() end table.insert(concatContent, 'return __REQUIRE__("ECS")') -- write ECS_concat.lua local fileConcat = io.open(OUTPUT_CONCAT..".lua", "w" ) fileConcat:write( table.concat(concatContent, "\n")) fileConcat:close() -- teste import local ecsConcat = require(OUTPUT_CONCAT) _G.ECS = nil end local function minify() local min = require('minify') local sourceFile = io.open(OUTPUT_CONCAT..".lua", 'r') if not sourceFile then error("Could not open the input file `" .. OUTPUT_CONCAT..".lua" .. "`", 0) end local data = sourceFile:read('*all') local ast = min.CreateLuaParser(data) local global_scope, root_scope = min.AddVariableInfo(ast) min.MinifyVariables(global_scope, root_scope) min.StripAst(ast) local minifiedContent = min.AstToString(ast) -- write ECS.lua local fileMinified = io.open(OUTPUT_MINIFIED..".lua", "w" ) fileMinified:write(HEADER .. minifiedContent) fileMinified:close() -- teste import local ecsMinified = require(OUTPUT_MINIFIED) _G.ECS = nil end concat() minify() ================================================ FILE: docs/.nojekyll ================================================ ================================================ FILE: docs/README.md ================================================ # What is it? **ECS Lua** is a fast and easy to use ECS (Entity Component System) engine for game development. ![](assets/diagram-1.png) The basic idea of this pattern is to stop defining entities using a [hierarchy](https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)) of classes and start doing use of [composition](https://en.wikipedia.org/wiki/Object_composition) in a Data Oriented Programming paradigm. ([More information on Wikipedia](https://en.wikipedia.org/wiki/Entity_component_system)). Programming with an ECS can result in code that is more efficient and easier to extend over time. # How does it work? ![ECS Lua pipeline](assets/pipeline.png) # Talk is cheap. Show me the code! ```lua local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component local Health = Component(100) local Position = Component({ x = 0, y = 0}) local isInAcid = Query.Filter(function() return true -- it's wet season end) local InAcidSystem = System("process", Query.All( Health, Position, isInAcid() )) function InAcidSystem:Update() for i, entity in self:Result():Iterator() do local health = entity[Health] health.value = health.value - 0.01 end end local world = World({ InAcidSystem }) world.Entity(Position({ x: 5.0 }), Health()) ``` # Features **ECS Lua** has no external dependencies and is compatible and tested with [Lua 5.1], [Lua 5.2], [Lua 5.3], [Lua 5.4], [LuaJit] and [Roblox Luau](https://luau-lang.org/) - **Game engine agnostic**: It can be used in any engine that has the Lua scripting language. - **Ergonomic**: Focused on providing a simple yet efficient API - **FSM**: Finite State Machines in an easy and intuitive way - **JobSystem**: To running systems in parallel (through [coroutines]) - **Reactive**: Systems can be informed when an entity changes - **Predictable**: - The systems will work in the order they were registered or based on the priority set when registering them. - Reactive events do not generate a random callback when issued, they are executed at a predefined step. # Goal To be a lightweight, simple, ergonomic and high-performance ECS library that can be easily extended. The **ECS Lua** does not strictly follow _"pure ECS design"_. # Next steps You can browse or search for specific subjects in the side menu. Here are some relevant links:

[![Installation](assets/icon-download.png ":no-zoom")](/getting-started?id=installation)
[Installation](/getting-started?id=installation)
[![General Concepts](assets/icon-parts.png ":no-zoom")](/getting-started?id=general-concepts)
[General Concepts](/getting-started?id=general-concepts)
[![Architecture](assets/icon-advanced.png ":no-zoom")](/architecture)
[Architecture](/architecture)
[![Tutorials](assets/icon-tutorial.png ":no-zoom")](/tutorial)
[Tutorials](/tutorial)
[Lua 5.1]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.2]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.3]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.4]:https://app.travis-ci.com/github/nidorx/ecs-lua [LuaJit]:https://app.travis-ci.com/github/nidorx/ecs-lua ================================================ FILE: docs/_coverpage.md ================================================
# ECS Lua [![Build Status](https://app.travis-ci.com/nidorx/ecs-lua.svg?branch=master)](https://app.travis-ci.com/nidorx/ecs-lua) [GitHub](https://github.com/nidorx/ecs-lua) [Get Started](/?id=what-is-it) ![color](#ffffff) ================================================ FILE: docs/_navbar.md ================================================ - [Home](/)     - Language - [English](/) - [Português do Brasil](/pt-br/) ================================================ FILE: docs/_sidebar.md ================================================ - [Home](/) - [Installation](/getting-started?id=installation) - [General Concepts](/getting-started?id=general-concepts) - [Component](/getting-started?id=component) - [Systems and Queries](/getting-started?id=systems-and-queries) - [World](/getting-started?id=world) - [Entity](/getting-started?id=entity) - [Example](/getting-started?id=putting-everything-together) - [Architecture](/architecture) - [Component](/architecture?id=component) - [Qualifiers](/architecture?id=qualifiers) - [FSM - Finite State Machines](/architecture?id=fsm-finite-state-machines) - [Entity](/architecture?id=entity) - [Query](/architecture?id=query) - [Sistema](/architecture?id=system) - [Tasks](/architecture?id=task) - [World](/architecture?id=world) - [Tutorials](/tutorial) - [Shoot](/tutorial-shoot) - [Pacman](/tutorial-pacman) - [Boids](/tutorial-boids) - [API](/api) - [ECS](/api?id=ecs) - [Archetype](/api?id=archetype) - [Component](/api?id=component) - [Entity](/api?id=entity) - [LoopManager](/api?id=loopmanager) - [Query](/api?id=query) - [QueryBuilder](/api?id=querybuilder) - [QueryResult](/api?id=queryresult) - [System](/api?id=system) - [Time](/api?id=time) - [World](/api?id=world) ================================================ FILE: docs/api.md ================================================ # API
# ECS - `ECS.Query` - _@type_ `QueryClass` - `ECS.Archetype` - _@type_ `ArchetypeClass` - `ECS.World(systemClasses, frequency, disableAutoUpdate)` - Create a new world instance - _@param_ `systemClasses` `SystemClass[]` _optional_ Array of system classes - _@param_ `frequency` `number` _optional_ Define the frequency that the `process` step will be executed. Default 30 - _@param_ `disableAutoUpdate` `bool` _optional_ When `~= false`, the world automatically registers in the `LoopManager`, receiving the `World:Update()` method from it. Default false - _@return_ `World` - `ECS.System(step, order, query, updateFn)` - Create new System Class - _@param_ `step` `process|transform|render|task` - _@param_ `order` `number` _optional_ Allows you to set an execution order (for systems that are not `task`). Default 50 - _@param_ `query` `Query|QueryBuilder` _optional_ Filters the entities that will be processed by this system - _@param_ `updateFn` `Function(self, Time)` _optional_ A shortcut for creating systems that only have the Update method - _@return_ `SystemClass` - `ECS.Component(template)` - Register a new ComponentClass - _@param_ `template` `table|function(table):table|any` - When `table`, this template will be used for creating component instances - When it's a `function`, it will be invoked when a new component is instantiated. The creation parameter of the component is passed to template function - If the template type is different from `table` and `function`, **ECS Lua** will generate a template in the format `{ value = template }`. - _@return_ `ComponentClass` - `ECS.SetLoopManager(manager)` - Defines the LoopManager that will be used by the worlds to receive the automatic update - _@param_ `manager` `LoopManager` # Archetype An Archetype is a unique combination of component types. The EntityRepository uses the archetype to group all entities that have the same sets of components. An entity can change archetype fluidly over its lifespan. For example, when you add or remove components, the archetype of the affected entity changes. An archetype object is not a container; rather it is an identifier to each unique combination of component types that an application has created at run time, either directly or implicitly. You can create archetypes directly using `ECS.Archetype.Of(Components[])`. You also implicitly create archetypes whenever you add or remove a component from an entity. An Archetype object is an immutable singleton; creating an archetype with the same set of components, either directly or implicitly, results in the same archetype. The ECS framework uses archetypes to group entities that have the same structure together. The ECS framework stores component data in blocks of memory called chunks. A given chunk stores only entities having the same archetype. You can get the Archetype object for a chunk from its Archetype property. Use `ECS.Archetype.Of(Components[])` to get a Archetype reference. - `Archetype.EMPTY` - Generic archetype, for entities that do not have components - _@type_ `Archetype` - `Archetype.Of(componentClasses)` - Gets the reference to an archetype from the informed components - _@param_ `componentClasses` `ComponentClass[]` Component that define this archetype - _@return_ `Archetype` - `Archetype.Version()` - Get the version of archetype definitions - _@return_ `number` - `Archetype:Has(componentClass)` - Checks whether this archetype has the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `bool` - `Archetype:With(componentClass)` - Gets the reference to an archetype that has the current components `+` the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `Archetype` - `Archetype:WithAll(componentClasses)` - Gets the reference to an archetype that has the current components `+` the informed components - _@param_ `componentClass` `ComponentClass[]` - _@return_ `Archetype` - `Archetype:Without(componentClass)` - Gets the reference to an archetype that has the current components `-` the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `Archetype` - `Archetype:WithoutAll(componentClasses)` - Gets the reference to an archetype that has the current components `-` the informed components - _@param_ `componentClass` `ComponentClass[]` - _@return_ `Archetype` # Component - `ComponentClass.Id` - Identifier of this component - _@type_ `number` - `ComponentClass.IsCType` - Indicates that this class is a Component - _@type_ `true` - `ComponentClass.SuperClass` - Used internally for Qualifiers, it indicates the base class of this Component (or primary component). - _@type_ `ComponentClass` - `ComponentClass.HasQualifier` - Indicates that this Component has qualifiers - _@type_ `bool` - `ComponentClass.IsQualifier` - Indicates that this specific class is a Component qualifier. - _@type_ `bool` - `ComponentClass.IsFSM` - Indicates that this Component is a [FSM - Finite State Machine][fsm] - _@type_ `bool` - `ComponentClass.States` - When set, this Component becomes a [FSM - Finite State Machine][fsm] ```lua local Movement = ECS.Component({ speed = 0 }) Movement.States = { Standing = {"Walking"}, Walking = "*", Running = {"Walking"} } ``` - _@type_ `table` _optional_ - `ComponentClass.StateInitial` - When the component is [FSM][fsm], it allows defining the initial state for new instances. - _@type_ `string` _optional_ - `ComponentClass.Case` - When the component is [FSM][fsm], it allows the component to handle transactions between states. ```lua Movement.Case = { Standing = function(self, previous) self.speed = 0 end, Walking = function(self, previous) self.speed = 5 end, Running = function(self, previous) self.speed = 10 end } ``` - _@type_ `table` _optional_ - `ComponentClass.Qualifier(qualifier)` - Gets a qualifier for this type of component. If the qualifier does not exist, a new class will be created, otherwise it brings the already registered class qualifier reference with the same name. - _@param_ `qualifier` `string|ComponentClass` - _@return_ `ComponentClass` - `ComponentClass.Qualifiers(...)` - Get all qualified class - _@param_ `...` `string|ComponentClass` _optional_ Allows to filter the specific qualifiers - _@return_ `ComponentClass[]` - `ComponentClass(value)` | `ComponentClass.New(value)` - Builder, instantiate a new component of this type - _@param_ `value` `any` _optional_ If the value is not a table, it will be converted to the format `{ value = value}` - _@return_ `Component` - `ComponentClass:GetType()` - Get this component's class - _@return_ `ComponentClass` - `ComponentClass:Is(componentClass)` - Check if this component is of the type informed - _@param_ `componentClass` `ComponentClass|ComponentSuperClass` - _@return_ `bool` - `ComponentClass:Primary()` - Get the instance for the primary qualifier of this class - _@return_ `Component|nil` - `ComponentClass:Qualified(qualifier)` - Get the instance for the given qualifier of this class - _@param_ `qualifier` `string|ComponentClass` - _@return_ `Component|nil` - `ComponentClass:QualifiedAll()` - Get all instances for all qualifiers of that class - _@return_ `Component[]` - `ComponentClass:Merge(other)` - Merges data from the other component into the current component. **IMPORTANT!** This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. - _@param_ `other` `Component` - `ComponentClass:Detach()` - Unlink this component with the other qualifiers. **IMPORTANT!** This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. - `ComponentClass.In(...)` - When the component is [FSM][fsm], creates a clause used to filter repository entities in a Query or QueryResult. ```lua ECS.Query.All(Movement.In("Walking", "Running")) ``` - _@param_ `...` `string[]` - _@return_ `Clause` - `ComponentClass:SetState(newState)` - When the component is [FSM][fsm], defines the current state - _@param_ `newState` `string` - `ComponentClass:GetState()` - When the component is [FSM][fsm], get the current state - _@return_ `string` - `ComponentClass:GetPrevState()` - When the component is [FSM][fsm], get the previous state - _@return_ `string|nil` - `ComponentClass:GetStateTime()` - When the component is [FSM][fsm], gets the time it changed to the current state. Whenever the state is changed, the instant is persisted internally using `os.clock()` - _@return_ `number` # Entity - `Entity.id` - Identifier of this entity - _@type_ `number` - `Entity.isAlive` - The entity is created in _DEAD_ state (`entity.isAlive == false`) and will only be visible for queries after the cleaning step _(`OnRemove`,`OnEnter`,`OnExit`)_ by the world - _@type_ `bool` - `Entity.archetype` - The entity archetype - _@type_ `Archetype` - `Entity.New(onChange, components)` - Creates an entity having components of the specified types. - _@param_ `onChange` `Event` - _@param_ `components` `Component[]` _optional_ - _@return_ `Entity` - `Entity:Get(componentClass)` | `entity[componentClass]` - Gets an entity component - _@param_ `componentClass` `ComponentClass` - _@return_ `Component` - `Entity:Get(...)` - Get multiple entity components at once ```lua local comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ``` - _@param_ `..` `ComponentClass[]` - _@return_ `Component ...` - `Entity:Set(componentClass, value)` | `entity[componentClass] = value` - Sets the value of a component - _@param_ `componentClass` `ComponentClass` - _@param_ `value` `any|nil` When nil, unset the component. - `Entity:Set(...)` - Arrow one or more instances of a component - _@param_ `...` `Component` - `Entity:Unset(componentClass|Component, ...)` | `entity[componentClass] = nil` - Remove one or more components from the entity - _@param_ `...` `componentClass|Component` # LoopManager In order for the world's systems to receive an update, the `World:Update(step, now)` method must be invoked on each frame. To automate this process, **ECS Lua** provides a functionality so that, at the time of instantiation of a new world, it registers to receive the update automatically. ```lua local MyLoopManager = { Register = function(world) local beforePhysics = MyGameEngine.BeforePhysics(function() world:Update("process", os.clock()) end) local afterPhysics = MyGameEngine.AfterPhysics(function() world:Update("transform", os.clock()) end) local beforeRender if (not MyGameEngine.IsServer()) then beforeRender = MyGameEngine.BeforeRender(function() world:Update("render", os.clock()) end) end return function() beforePhysics:Disconnect() afterPhysics:Disconnect() if beforeRender then beforeRender:Disconnect() end end end } ECS.SetLoopManager(MyLoopManager) ``` - `LoopManager.Register(world)` - Allows the world to register to be updated. - _@param_ `world` `World` - _@return_ `function` The world will invoke when destroyed # Query - `Query(all, any, none)` | `Query.New(all, any, none)` - Create a new Query used to filter entities in the world. It makes use of local and global cache in order to decrease the validation time (avoids looping in runtime of systems) - _@param_ `all` `Array` _optional_ - _@param_ `any` `Array` _optional_ - _@param_ `none` `Array` _optional_ - _@return_ `Query` - `Query.All(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.Any(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.None(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.Filter(filter)` - Create custom filters that can be used in Queries. Its execution is delayed, invoked only in `QueryResult` methods. The result of executing the clause depends on how it was used in the query. Ex. If used in `Query.All()` the result is the inverse of using the same clause in `Query.None()` ```lua local Player = ECS.Component({ health = 100 }) local HealthPlayerFilter = ECS.Query.Filter(function(entity, config) local player = entity[Player] return player.health >= config.minHealth and player.health <= config.maxHealth end) local healthyClause = HealthPlayerFilter({ minHealth = 80, maxHealth = 100, }) local healthyQuery = ECS.Query.All(Player, healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is very healthy end) local notHealthyQuery = ECS.Query.All(Player).None(healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is NOT very healthy end) local dyingClause = HealthPlayerClause({ minHealth = 1, maxHealth = 20, }) local dyingQuery = ECS.Query.All(Player, dyingClause) world:Exec(dyingQuery):ForEach(function(entity) -- this player is about to die end) local notDyingQuery = ECS.Query.All(Player).None(dyingClause) world:Exec(notDyingQuery):ForEach(function(entity) -- this player is NOT about to die end) ``` - _@param_ `filter` `function(entity, config) -> bool` - _@return_ `function(config) -> Clause` - `Query:Result(chunks)` - Generate a `QueryResult` with the chunks entered and the clauses of the current query - _@param_ `chunks` `Array<{ [Entity] = true }>` - _@return_ `QueryResult` - `Query:Match(archetype)` - Checks if the entered archetype is valid by the query definition - _@param_ `archetype` `Archetype` - _@return_ `bool` # QueryBuilder - `QueryBuilder.isQueryBuilder` - Indicates that this is an instance of a QueryBuilder - _@type_ `true` - `QueryBuilder.All(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.Any(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.None(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.Build()` - _@return_ `Query` # QueryResult The result of a Query that was executed on an EntityStorage. QueryResult provides several methods to facilitate the filtering of entities resulting from the execution of the query. - **Intermediate Operations** - Intermediate operations return a new QueryResult. They are always lazy; executing an intermediate operation such as `QueryResult:Filter()` does not actually perform any filtering, but instead creates a new QueryResult that, when traversed, contains the elements of the initial QueryResult that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed. - **Terminal Operations** - Terminal operations, such as `QueryResult:ForEach` or `QueryResult.AllMatch`, may traverse the QueryResult to produce a result or a side-effect. - `QueryResult.New(chunks, clauses)` - Build a new QueryResult - _@param_ `chunks` `Array<{ [Entity] = true }>` - _@param_ `clauses` `Clause[]` _optional_ - _@return_ `QueryResult` - `QueryResult:With(operation, param)` - Returns a QueryResult consisting of the elements of this QueryResult with a new pipeline operation - _@param_ `operation` `function(param, value, count) -> newValue, acceptItem, continuesLoop` - _@param_ `param` `any` - _@return_ `QueryResult` the new QueryResult - `QueryResult:Filter(predicate)` - Returns a QueryResult consisting of the elements of this QueryResult that match the given predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to each element to determine if it should be included - _@return_ `QueryResult` the new QueryResult - `QueryResult:Map(mapper)` - Returns a QueryResult consisting of the results of applying the given function to the elements of this QueryResult. - _@param_ `mapper` `function(value) -> newValue` a function to apply to each element - _@return_ `QueryResult` the new QueryResult - `QueryResult:Limit(maxSize)` - Returns a QueryResult consisting of the elements of this QueryResult, truncated to be no longer than maxSize in length. - _@param_ `maxSize` `number` - _@return_ `QueryResult` the new QueryResult - `QueryResult:AnyMatch(predicate)` - Returns whether any elements of this result match the provided predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to elements of this result - _@return_ `true` if any elements of the result match the provided predicate, otherwise `false` - `QueryResult:AllMatch(predicate)` - Returns whether all elements of this result match the provided predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to elements of this result - _@return_ `true` if either all elements of the result match the provided predicate or the result is empty, otherwise `false` - `QueryResult:FindAny()` - Returns some element of the result, or nil if the result is empty. This is a short-circuiting terminal operation. The behavior of this operation is explicitly nondeterministic; it is free to select any element in the result. Multiple invocations on the same result may not return the same value. - _@return_ `any` - `QueryResult:ForEach(action)` - Performs an action for each element of this QueryResult. This is a terminal operation. The behavior of this operation is explicitly nondeterministic. This operation does not guarantee to respect the encounter order of the QueryResult. - _@param_ `action` `function(value, count) -> bool` A action to perform on the elements, breaks execution case returns true - `QueryResult:ToArray()` - Returns an array containing the elements of this QueryResult. - _@return_ `Array` - `QueryResult:Iterator()` - Returns an Iterator, to use in for loop ```lua for count, entity in result:Iterator() do print(entity.id) break end ``` - _@return_ `Iterator` # System - `System.Step` - Step that this system will run - _@type_ `string` `process|transform|render|task` - `System.Order` - For systems that are not `task`, execution order - _@type_ `number` - `System.Query` - Filters the entities that will be processed by this system - _@type_ `number` - `System.After` - When the system is a task, it allows you to define that this system should run AFTER other specific systems. ```lua local log = {} local Task_A = System.Create('task', function() -- In this example, TASK_A takes time to execute, delaying its execution local i = 0 while i <= 4000 do i = i + 1 if i%1000 == 0 then coroutine.yield() end end table.insert(log, 'A') end) local Task_B = System.Create('task', function() table.insert(log, 'B') end) local Task_C = System.Create('task', function() table.insert(log, 'C') end) local Task_D = System.Create('task', function() table.insert(log, 'D') end) local Task_E = System.Create('task', function() table.insert(log, 'E') end) local Task_F = System.Create('task', function() table.insert(log, 'F') end) local Task_G = System.Create('task', function() table.insert(log, 'G') end) local Task_H = System.Create('task', function(self) table.insert(log, 'H') end) --[[ A<-------C<---+-----F<----+ | | | | +----+ E<----+ H | | | B<--+----D<---+------G<---+ A - has no dependency B - has no dependency C - Depends on A,B D - Depends on B E - Depends on A,B,C,D F - Depends on A,B,C,D,E G - Depends on B,D H - Depends on A,B,C,D,E,F,G Completion order will be B,D,G,A,C,E,F,H > In this example, TASK_A takes time to execute, delaying its execution ]] Task_A.Before = {Task_C} Task_B.Before = {Task_D} Task_C.After = {Task_B} Task_D.Before = {Task_G} Task_F.After = {Task_E} Task_E.After = {Task_D, Task_C} Task_C.Before = {Task_F} Task_H.After = {Task_F, Task_G} ``` - _@type_ `SystemClass[]` - `System.Before` - When the system is a task, it allows you to define that this system should run BEFORE other specific systems. - _@see_ `System.After` - _@type_ `SystemClass[]` - `System.version` - System Version (GSV). - _@see_ `World.version` - _@type_ `Number` - `System._world` - _@type_ `World` - `System._config` - _@type_ `table` - `System.New(world, config)` - Create an instance of this system - _@param_ `world` `World` - _@param_ `config` `table` - _@return_ `System` - `System:GetType()` - Get this system class - _@return_ `SystemClass` - `System:Result(query)` - Run a query in the world. A shortcut to `self._world:Exec(query)` - _@param_ `query` `Query|QueryBuilder` _optional_ If nil, use default query - _@return_ `QueryResult` - `System:Destroy()` - Destroy this instance - `System:OnDestroy()` - Allows you to perform some processing or cleaning when the instance is being destroyed - `System:ShouldUpdate(Time)` - Invoked before 'Update', allows you to control the execution of the update - _@param_ `Time` `Time` - _@return_ `bool` If true, the Update method will be invoked. - `System:Update(Time)` - Run the system's main method - _@param_ `Time` `Time` - `System:OnRemove(Time, entity)` - When it is a `QuerySystem`, it allows to be informed when an entity with the characteristics of the query is removed from the world. This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` - `System:OnExit(Time, entity)` - When it is a `QuerySystem`, it allows to be informed when an entity has lost the characteristics of that query (has suffered an archetype change and the current query no longer applies). This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` - `System:OnEnter(Time, entity)` - When it is a QuerySystem, it allows to be informed when an entity received the characteristics expected by this query (it suffered an archetype change and the current query now applies). This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` # Time Singleton, reference to the world's global processing time. - `Time.Now` - World Runtime - _@type_ `number` - `Time.NowReal` - Real time, received in `World:Update(step, now)` method - _@type_ `number` - `Time.Frame` - The time at the beginning of this frame (process). The world receives the current time at the beginning of each frame, with the value increasing per frame. - _@type_ `number` - `Time.FrameReal` - The REAL time at the beginning of this frame (`World:Update(step, now)`). - _@type_ `number` - `Time.Process` - The time the latest process step has started. - _@type_ `number` - `Time.Delta` - The completion time in seconds since the last frame. - _@type_ `number` - `Time.DeltaFixed` - Based on world update frequency (`process` step). ```lua DeltaFixed = 1000/frequency/1000 ``` - _@see_ `World:SetFrequency()` - _@type_ `number` - `Time.Interpolation` - The proportion of time since the previous transform relative to processDeltaTime. Used to do interpolation during the rendering step. Allows the `process` step to run at low frequency _(Ex. 30hz)_ and `render` at the maximum rate of the player's device _(Ex. 60hz)_ - _@type_ `number` # World - `World.version` - Global System Version (GSV). Before executing the `Update()` method of each system, the world version is incremented, so at this point, the world version will always be higher than the running system version. Whenever an entity archetype is changed (received or lost component) the entity's version is updated to the current version of the world. After executing the System Update method, the version of this system is updated to the current world version. This mechanism allows a system to know if an entity has been modified after the last execution of this same system, as the entity's version is superior to the version of the last system execution. Thus, a system can contain logic if it only operates on "dirty" entities, which have undergone changes. The code for this validation on a system is: - `local isDirty = entity.version > self.version` - _@type_ `number` - `World.maxTasksExecTime` - Allows you to define the maximum time that the `JobSystem` can operate in each frame. The default value is `0.011666666666666665` = `((1000/60/1000)*0.7)` - A game that runs at 30fps has 0.0333 seconds to do all the processing for each frame, including rendering - 30FPS = `(1000/30/1000)` = `0.03333333333333333` - A game that runs at 60fps has 0.0166 seconds to do all the processing for each frame, including rendering - 60FPS = `(1000/60/1000)` = `0.016666666666666666` - _@type_ `number` Default `0.011666666666666665` - `World:SetFrequency(frequency)` - Define the frequency that the `process` step will be executed - _@param_ `frequency` `number` _optional_ Default 30 - `World:GetFrequency()` - Get the frequency of execution of the `process` step - _@return_ `number` - `World:AddSystem(systemClass, config)` - Add a new system to the world. Only one instance per type is accepted. If there is already another instance of this system in the world, any new invocation of this method will be ignored. - _@param_ `systemClass` `SystemClass` The system to be added in the world - _@param_ `config` `table` _optional_ System instance configuration - `World:Entity(...)` - Create a new entity. The entity is created in _DEAD_ state (`entity.isAlive == false`) and will only be visible for queries after the cleaning step _(`OnRemove`,`OnEnter`,`OnExit`)_ of the current step - _@param_ `...` `Component[]` _optional_ Instance of the components that this entity will have - _@return_ `Entity` - `World:Remove(entity)` - Performs immediate removal of an entity. If the entity was created in this step and the cleanup process has not happened yet (therefore the entity is inactive, `entity.isAlive == false`), the `OnRemove` event will never be fired. If the entity is alive (`entity.isAlive == true`), even though it is removed immediately, the `OnRemove` event will be fired at the end of the current step. - _@param_ `entity` `Entity` - `World:Exec(query)` - Run a query in this world - _@param_ `query` `Query|QueryBuilder` - _@return_ `QueryResult` - `World:Update(step, now)` - Perform world update. When registered, the `LoopManager` will invoke World Update for each step in the sequence. - `process` At the beginning of each frame - `transform` After the game engine's physics engine runs - `render` Before rendering the current frame - _@param_ `step` `"process"|"transform"|"render"` - _@param_ `now` `number` Usually os.clock() - `World:Destroy()` - Destroy this instance, removing all entities, systems and events
[fsm]:https://en.wikipedia.org/wiki/Finite-state_machine ================================================ FILE: docs/architecture.md ================================================ # Architecture In Software Engineering, ECS is the acronym for Entity Component System, is a software architecture pattern used primarily in video game development. An ECS follows the principle of "composition rather than inheritance" that allows greater flexibility in defining entities, where each object in a game scene is an entity (eg enemies, projectiles, vehicles, etc.). Each entity consists of of one or more components that add behavior or functionality. Therefore, the behavior of an entity can be changed at runtime by simply adding or removing components. This eliminates problems of The ambiguity with which deep and vast inheritance hierarchies, which are difficult to understand, maintain and extend. For more details: - [Frequently Asked Questions about ECS](https://github.com/SanderMertens/ecs-faq) - [Entity Systems Wiki](http://entity-systems.wikidot.com/) - [Evolve your hierarchy](http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/) - [ECS on Wikipedia](https://en.wikipedia.org/wiki/Entity_component_system) - [ECS with Elixir](https://yos.io/2016/09/17/entity-component-systems/) - [2017 GDC - Overwatch Gameplay Architecture e Netcode](https://www.youtube.com/watch?v=W3aieHjyNvw&ab_channel=GDC) ## Component They represent the different characteristics of an entity, such as position, speed, geometry, physics, and hit points. Components only store raw data for an aspect of the object and how it interacts with the world. In others words, the component labels the entity as having this particular aspect. In **ECS Lua**, the creation of a component is done through the `ECS.Component(template)` method. The `template` parameter can be of any type, where: - When `table`, this template will be used for creating component instances ```lua local Component = ECS.Component({ x = 0, y = 0, z = 0 }) local comp = Component({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 0, 80 -- it is the same as local comp = Component.New({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 0, 80 ``` - When it's a `function`, it will be invoked when a new component is instantiated. The creation parameter of the component is passed to template function ```lua local Component = ECS.Component(function(param) return { x = param.x or 1, y = param.y or 1, z = param.z or 1 } end) local comp = Component({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 1, 80 ``` - If the template type is different from `table` and `function`, **ECS Lua** will generate a template in the format `{ value = template }`. ```lua local Component = ECS.Component(55) local comp1 = Component() print(comp1.value) -- > 55 local comp2 = Component({ value = 80 }) print(comp2.value) -- > 80 local comp3 = Component("XPTO") print(comp3.value) -- > "XPTO" ``` ### Methods In **ECS Lua**, components are classes and can therefore have auxiliary methods. > IMPORTANT! Avoid creating methods that modify component instance data directly, the ideal is that these logics stay within the systems, which are, by definition, responsible for changing the data of the entities and their components. ```lua local Person = ECS.Component({ name = "", surname = "", birth = 0 }) function Person:FullName() return self.name.." "..self.surname end function Person:Age() return tonumber(os.date("%Y", os.time())) - self.birth end local person = Person({ name = "John", surname = "Doe", birth = 2000 }) print(person:FullName()) -- John Doe print(person:Age()) -- 21 ``` ### Qualifiers In "pure ECS" implementations there is a premise that a component can only be added once to an entity. In the vast majority of scenarios, this is true. For example, you don't want your entity to have two positions, it makes no sense! Therefore, your entity will only have one component of type Position. But sometimes you will build some functionality that needs your entity to have this behavior, to have more than one component of the same **TYPE**. When the framework doesn't support this kind of implementation, you end up with code full of hacks to [work around](https://en.wikipedia.org/wiki/Workaround) the problem. **ECS Lua** implements the **Qualifiers** mechanism so that you can represent a category of components. To illustrate the usage, let's think about the following scenario: I want to add a [Buff](https://en.wikipedia.org/wiki/Game_balance#Buff) system to my game so that my character can receive extra life points in specific situations. We want to have the freedom to increase or decrease the amount of buff according to the region of the map where the player is. Looking at the scenario above, we could create, at first, the solution below. A `HealthBuff` component to record the amount of extra life and two systems. The first `MapRegionSystem` decides how many additional health points the player will have for each region, while the second `HealthSystem` represents some system functionality that need to get the player's life total at a certain time. ```lua -- components local Player = ECS.Component({ health = 100, region = "easy", healthTotal = 100 }) local HealthBuff = ECS.Component({ value = 10 }) -- systems local MapRegionSystem = System("process", 1, Query.All(Player)) function MapRegionSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] if player.region == "easy" then entity[HealthBuff] = nil -- remove buff else local buff = entity[HealthBuff] if buff == nil then buff = HealthBuff(0) entity:Set(buff) end if player.region == "hard" then buff.value = 15 elseif player.region == "hell" then buff.value = 40 end end end end local HealthSystem = System("process", 2, Query.All(Player).Any(HealthBuff)) function HealthSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] local buff = entity[HealthBuff] if buff then player.healthTotal = player.health + buff.value else player.healthTotal = player.health end end end ``` So far quiet. However, imagine now that my player can receive **SEVERAL** buffers. - He can receive a buffer for the character he is using; - another buff when unlocking an item and; - you can also buy buffs from the in-game shop. In this new scenario our solution does not meet, because the `MapRegionSystem` system code does not have the information about the other factors, and to attend to it, it will have to know or manage several possible states to decide which is the amount of health the player will receive for being in a specific region. The other systems in the game too needed to know the region to decide how much buffer to add. In a "pure ECS" solution, we're going to start: 1. share state between systems 1. create "Component TAGs" to facilitate the management of this distributed state, 1. inflate components with an attribute for each system type. At first this doesn't seem to be a problem, but over time, multiple systems will be called unnecessarily (just to do an if and not process that entity). These systems now have extra responsibilities, increasing the complexity of the code, making maintenance difficult and facilitating the appearance of bugs. In **ECS Lua** we solve this kind of problem by creating qualifiers, through the static method `ComponentClass.Qualifier(qualifier)`. It accepts a string as a parameter and returns a reference to a specialized class of our component. This generated class maintains a strong link with the base class, allowing more complex queries. Let's change our example using qualifiers. ```lua -- components local Player = ECS.Component({ health = 100, region = "easy", healthTotal = 100 }) local HealthBuff = ECS.Component({ value = 10 }) local HealthBuffItem = HealthBuff.Qualifier("Item") local HealthBuffMapRegion = HealthBuff.Qualifier("Region") local Item = ECS.Component({ rarity = 0 }) -- systems local PlayerItemSystem = System("process", 1, Query.All(Player, Item)) function PlayerItemSystem:Update(Time) for i, entity in self:Result():Iterator() do local item = entity[Item] local player = entity[Player] if item.rarity == "legendary" then entity[HealthBuffItem] = 15 -- same as entity:Set(HealthBuffItem.New(15)) else entity[HealthBuffItem] = nil end end end local MapRegionSystem = System("process", 1, Query.All(Player)) function MapRegionSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] if player.region == "easy" then entity[HealthBuffMapRegion] = nil else local buff = entity[HealthBuffMapRegion] if buff == nil then buff = HealthBuffMapRegion(0) entity:Set(buff) end if player.region == "hard" then buff.value = 15 elseif player.region == "hell" then buff.value = 40 end end end end local HealthSystem = System("process", 2, Query.All(Player).Any(HealthBuff)) function HealthSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] local healthTotal = player.health local buffers = entity:GetAll(HealthBuff) for i,buff in ipairs(buffers) do healthTotal = healthTotal + buff.value end player.healthTotal = player.health end end ``` Okay, in this new implementation, the `MapRegionSystem` system only cares about the `HealthBuffMapRegion` qualifier, while the `PlayerItemSystem` system only manages the `HealthBuffItem` qualifier. We can now create systems that specialize in qualifiers and manage only this attribute of the entity. The `HealthSystem` gets and processes all entities that have any qualifier from the `HealthBuff` component. [Check the API](/api?id=component) other methods that can be useful when working with qualifiers. ### FSM - Finite State Machines __UNDER_CONSTRUCTION__ ```lua local Movement = Component.Create({ Speed = 0 }) -- [Standing] <--> [Walking] <--> [Running] Movement.States = { Standing = {"Walking"}, Walking = "*", Running = {"Walking"} } Movement.StateInitial = "Standing" Movement.Case = { Standing = function(self, previous) print("Transition from "..previous.." to Standing") end, Walking = function(self, previous) print("Transition from "..previous.." to Walking") end, Running = function(self, previous) print("Transition from "..previous.." to Running") end } local movement = Movement() movement:SetState("Walking") movement:SetState("Running") print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState("Standing") -- invalid, Running -> Walking|Running print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState(nil) print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState("INVALID_STATE") print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking -- query local queryStanding = Query.All(Movement.In("Standing")) local queryInMovement = Query.Any(Movement.In("Walking", "Running")) -- qualifier local MovementB = Movement.Qualifier("Sub") -- ignored, "States", "StateInitial" and "Case" only work in primary class MovementB.States = { Standing = {"Walking"} } ``` ## Entity __UNDER_CONSTRUCTION__ ```lua --[[ [GET] 01) comp1 = entity[CompType1] 02) comp1 = entity:Get(CompType1) 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ]] --[[ [SET] 01) entity[CompType1] = nil 02) entity[CompType1] = value 03) entity:Set(CompType1, nil) 04) entity:Set(CompType1, value) 05) entity:Set(comp1) 06) entity:Set(comp1, comp2, ...) ]] --[[ [UNSET] 01) enity:Unset(comp1) 02) entity[CompType1] = nil 03) enity:Unset(CompType1) 04) enity:Unset(comp1, comp1, ...) 05) enity:Unset(CompType1, CompType2, ...) ]] --[[ [Utils] 01) comps = entity:GetAll() 01) qualifiers = entity:GetAll(PrimaryClass) ]] ``` ## Query __UNDER_CONSTRUCTION__ ## System __UNDER_CONSTRUCTION__ ## Task __UNDER_CONSTRUCTION__ ```lua local log = {} local Task_A = System.Create('task', function() -- In this example, TASK_A takes time to execute, delaying its execution local i = 0 while i <= 4000 do i = i + 1 if i%1000 == 0 then -- Processing is parallel, any time-consuming task must invoke coroutine.yield() after a period of time to -- not block processing coroutine.yield() end end table.insert(log, 'A') end) local Task_B = System.Create('task', function() table.insert(log, 'B') end) local Task_C = System.Create('task', function() table.insert(log, 'C') end) local Task_D = System.Create('task', function() table.insert(log, 'D') end) local Task_E = System.Create('task', function() table.insert(log, 'E') end) local Task_F = System.Create('task', function() table.insert(log, 'F') end) local Task_G = System.Create('task', function() table.insert(log, 'G') end) local Task_H = System.Create('task', function(self) table.insert(log, 'H') end) --[[ A<-------C<---+-----F<----+ | | | | +----+ E<----+ H | | | B<--+----D<---+------G<---+ A - has no dependency B - has no dependency C - Depends on A,B D - Depends on B E - Depends on A,B,C,D F - Depends on A,B,C,D,E G - Depends on B,D H - Depends on A,B,C,D,E,F,G Completion order will be B,D,G,A,C,E,F,H > In this example, TASK_A takes time to execute, delaying its execution ]] Task_A.Before = {Task_C} Task_B.Before = {Task_D} Task_C.After = {Task_B} Task_D.Before = {Task_G} Task_F.After = {Task_E} Task_E.After = {Task_D, Task_C} Task_C.Before = {Task_F} Task_H.After = {Task_F, Task_G} ``` ## World __UNDER_CONSTRUCTION__ ================================================ FILE: docs/faq.md ================================================ # FAQ __UNDER_CONSTRUCTION__ ================================================ FILE: docs/favicon/browserconfig.xml ================================================ #ffffff ================================================ FILE: docs/favicon/manifest.json ================================================ { "name": "App", "icons": [ { "src": "\/android-icon-36x36.png", "sizes": "36x36", "type": "image\/png", "density": "0.75" }, { "src": "\/android-icon-48x48.png", "sizes": "48x48", "type": "image\/png", "density": "1.0" }, { "src": "\/android-icon-72x72.png", "sizes": "72x72", "type": "image\/png", "density": "1.5" }, { "src": "\/android-icon-96x96.png", "sizes": "96x96", "type": "image\/png", "density": "2.0" }, { "src": "\/android-icon-144x144.png", "sizes": "144x144", "type": "image\/png", "density": "3.0" }, { "src": "\/android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", "density": "4.0" } ] } ================================================ FILE: docs/getting-started.md ================================================ # Installation **ECS Lua** has no external dependencies, so just download the latest version available on [releases page](https://github.com/nidorx/ecs-lua/releases) of the project. There are 3 options to use **ECS Lua** 1. **ECS.lua** Minified version in a single file 1. **ECS_concat.lua** Version concatenated with the original comments, which can be used for debugging during the development 1. **ECS.zip** Version with files from the `src` directory. > Important! All files do the `require` for dependencies that are in the same directory, if it is using in a Lua project, register in `package.path`. > These `require` do not work in Roblox Luau, due to the import format that Roblox uses. After importing **ECS Lua**, it is ready to be used. **ECS Lua** registers the global variable `_G.ECS` to make it easy to use, so you can use the engine in both ways `local ECS = require("ECS")` (in Roblox `local ECS = require(game.ReplicatedStorage:WaitForChild("ECS"))`) or simply `_G.ECS`. ## LoopManager In order for the world's systems to receive an update, the `World:Update(step, now)` method must be invoked on each frame. To automate this process, **ECS Lua** provides a functionality so that, at the time of instantiation of a new world, it registers to receive the update automatically. The implementation of this method is very simple and more details can be seen in the section [Architecture - World](/architecture?id=world). > If you use Roblox you don't need to worry, **ECS Lua** already has a default implementation when it runs on Roblox, more details below. ## Roblox You can install directly from Roblox Studio by searching the toolbox for `ECS-lua`, this is the [minified engine version](https://www.roblox.com/library/5887881675). When using **ECS Lua** in Roblox, the engine already automatically identifies and registers a `LoopManager`, so no additional steps are needed. # General Concepts Some common terms in ECS engines are: - [Entities](/architecture?id=entity): An object with a unique ID that can have multiple components attached to it. - [Components](/architecture?id=component): Different characteristics of an entity. eg geometry, physics, hit points. Data is only stored in components. - [Systems](/architecture?id=system): It does the real work, applying the rules of the game, processing entities and modifying their components. - [Queries](/architecture?id=query): Used by systems to determine which entities they are interested in, based on the components the entities have. - [World](/architecture?id=world): A container for entities, components, systems and queries. ![General Concepts](assets/diagram-1.png) The normal workflow when building an ECS-based program: - Create the `Components` that shape the data you need to use in your game/application. - Create the `Systems` that will use these `Components` to read and transform the entity data. - Create `Entities` and attach `Components` to them. - Run all systems at each frame, perform `Query` in `World` to decide which entities will be modified. ## Component Components are objects that contain data. In **ECS Lua**, just call the `ECS.Component(template)` method to define a `Class` of a component. The `template` parameter can be of any type, where: - When `table`, this template will be used for creating component instances; - When it's a `function`, it will be invoked on instantiation. - If the template type is different, **ECS Lua** will generate a template in the format `{ value = template }`, this is the format used in the `Acceleration` component below. ```lua local Position = ECS.Component({ x = 0, y = 0, z = 0 }) -- the same as: -- ECS.Component({ value = 0.1 }) local Acceleration = ECS.Component(0.1) ``` [More information on creating components](/architecture?id=component). ## Systems and Queries Now let's define a [system](/architecture?id=system) to process the components we just created. One system can implement several methods, in this exercise we will just use the `Update(Time)` method. This method will be invoked on every frame, in the world's `process` step. To create a system, we use the `ECS.System(step, order, query, updateFn)` method. This method receives the following parameters: - **`step`** String, accepts the values `process`, `transform`, `render` or `task` - **`order`** Number (Optional) Allows you to set an execution order (for systems that are not `task`). Default 50 - **`query`** Query (Optional) Filters the entities that will be processed by this system - **`updateFn`** Function (Optional) A shortcut for creating systems that only have the Update method We're also going to define a `Query`, which is the query we'll use to get just the entities we're interested. To create the query, we can use the `Query.All(Component)`, `Query.Any(Component)` or `Query.None(Component)`. When invoking any of these methods a `QueryBuilder` is returned, so you can invoke the other methods in sequence Ex. `Query.All(ComponentA).Any(ComponentB).None(ComponentC).Build()`. ![Pipeline](assets/pipeline.png) Let's start by creating a system that will loop through all entities that have a `Position` component and record their positions. ```lua -- a shortcut to methods local System, Query = ECS.System, ECS.Query local PositionLogSystem = System("process", 2, Query.All(Position), function(self, Time) -- Iterate through all entities in the query self:Result():ForEach(function(entity) -- Access the `Position` component in the current entity local pos = entity[Position] local msg = "Entity with ID: %d has Position = {x: %0.2f, y: %0.2f, z: %0.2f}" print(msg:format(entity.id, pos.x, pos.y, pos.z)) end) end) ``` The next system moves each entity that has a position and an acceleration. ```lua local MovableSystem = System("process", 1, Query.All(Acceleration, Position)) -- This method will be called on all frames by default. function MovableSystem:Update(Time) local delta = Time.DeltaFixed -- Iterate through all entities in the query for i, entity in self:Result():Iterator() do local acceleration = entity:Get(Acceleration).value local position = entity[Position] position.x = position.x + acceleration * delta position.y = position.y + acceleration * delta position.z = position.z + acceleration * delta end end ``` > Note that we are accessing components in an entity in two different ways: `entity:Get(Acceleration)` has the same result as `entity[Acceleration]` The system query `MovableSystem` filters the entities that have the components `Acceleration` and `Position`. Note that, if necessary, we can create as many queries as we like and process them in the `Update` method, eg: ```lua local SystemDemo = System("process", 1) function SystemDemo:Initialize(config) self.queryBoxes = Query.All(Box).Build() self.queryBalls = Query.All(Ball).Build() self.queryGeometries = Query.Any(Box, Ball).Build() end function SystemDemo:Update(Time) local boxes = self:Result(self.queryBoxes):ToArray() local balls = self.World:Exec(self.queryBalls):ToArray() for i, entity in self:Result(self.queryGeometries):Iterator() do print(entity[Box], entity[Ball]) end end ``` For more information, check the architecture documentation: [Components](/architecture?id=component), [Entities](/architecture?id=entity) and [Queries](/architecture?id=query) ## World A world is a container for `entities`, `components` and `systems`. Most games have only one `world`, however, you can have multiple worlds running at the same time and enable or disable them as needed. Creating a world is done by the `ECS.World(systemClasses, frequency, disableAutoUpdate)` method, where: - **systemClasses** (optional) Array of system classes - **frequency** Number (optional) Define the frequency that the `process` step will be executed. Default 30 - **disableAutoUpdate** Bool (optional) When `~= false`, the world automatically registers in the `LoopManager`, receiving the `World:Update()` method from it. Default false Let's start creating our world: ```lua local world = ECS.World(); ``` Now let's register our systems in the world so that they start up and run on every frame. ```lua world:AddSystem(PositionLogSystem) world:AddSystem(MovableSystem) ``` ## Entity Having our world, some components and systems already defined, let's create [entities](/architecture?id=entity) and attach these components to them: ```lua local entity1 = world:Entity(Position()) local entity2 = world:Entity( Position({x = 5}), Acceleration(1) ) local entity3 = world:Entity( Position.New({x = 5}), Acceleration.New(1) ) local entity4 = world:Entity( Position({x = 5}), Acceleration({value = 1}) ) local entity5 = world:Entity() entity5[Position] = { y = 5 } entity5:Set(Acceleration()) local entity6 = world:Entity() entity6[Position] = Position() entity6:Set(Acceleration()) ``` With that, we have just created 6 entities. 5 with the `Acceleration` and `Position` components, and one with just the `Position` component. Note that there are several ways to instantiate and assign components to the entity, choose the one that best matches with your programming style, the end result is the same. Also note that component classes can be used [as functions](http://lua-users.org/wiki/FuncTables), for example `Position()`. This format has the same effect than `Position.New()`. ## Putting everything together Now the world just needs to be updated (`world.Update(step, now)`) for everything to work. If you use roblox, just create a Local Script that the world will automatically update. ```lua -- Roblox: local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) -- a shortcut to methods local Component, System, Query = ECS.Component, ECS.System, ECS.Query -- Components local Position = Component({ x = 0, y = 0, z = 0 }) local Acceleration = Component(0.1) -- Sistems local PositionLogSystem = System("process", 2, Query.All(Position), function(self, Time) -- Iterate through all entities in the query self:Result():ForEach(function(entity) -- Access the `Position` component in the current entity local pos = entity[Position] local msg = "Entity with ID: %d has Position = {x: %0.2f, y: %0.2f, z: %0.2f}" print(msg:format(entity.id, pos.x, pos.y, pos.z)) end) end) local MovableSystem = System("process", 1, Query.All(Acceleration, Position)) -- This method will be called on all frames by default. function MovableSystem:Update(Time) local delta = Time.DeltaFixed -- Iterate through all entities in the query for i, entity in self:Result():Iterator() do local acceleration = entity:Get(Acceleration).value local position = entity[Position] position.x = position.x + acceleration * delta position.y = position.y + acceleration * delta position.z = position.z + acceleration * delta end end -- World local world = ECS.World(); world:AddSystem(PositionLogSystem) world:AddSystem(MovableSystem) -- Entities local entity1 = world:Entity(Position()) local entity2 = world:Entity(Position({x = 5}), Acceleration(1)) local entity3 = world:Entity(Position.New({x = 5}), Acceleration.New(1)) local entity4 = world:Entity(Position({x = 5}), Acceleration({value = 1})) local entity5 = world:Entity() entity5[Position] = { y = 5 } entity5:Set(Acceleration()) local entity6 = world:Entity() entity6[Position] = Position() entity6:Set(Acceleration()) ``` Result of the above code in Roblox Studio ![Result of the above code in Roblox Studio](assets/get-started-output.gif) ## Next steps This was a quick overview of how things are structured using **ECS Lua**, [read the architecture documentation](/architecture) for more detailed information. ================================================ FILE: docs/index.html ================================================ ECS-lua - Entity Component System in Lua
================================================ FILE: docs/pt-br/README.md ================================================ # O que é? **ECS Lua** é um motor ECS (Entity Component System) rápido e fácil de usar para o desenvolvimento de jogos. ![](../assets/diagram-1-pt-br.png) A ideia básica desse padrão é deixar de fazer a definição de entidades usando uma [hierarquia](https://pt.wikipedia.org/wiki/Heran%C3%A7a_(programa%C3%A7%C3%A3o_orientada_a_objetos)) de classes e passar a fazer uso de [composição](https://pt.wikipedia.org/wiki/Composi%C3%A7%C3%A3o_de_objetos) em um paradigma de Programação Orientada a Dados. ([Mais informações na Wikipedia](https://en.wikipedia.org/wiki/Entity_component_system)). A programação com um ECS pode resultar em um código mais eficiente e fácil de estender no longo do tempo. # Como ele funciona? ![ECS Lua pipeline](../assets/pipeline.png) # Falar é fácil. Me mostre o código! ```lua local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component local Health = Component(100) local Position = Component({ x = 0, y = 0}) local isInAcid = Query.Filter(function() return true -- it's wet season end) local InAcidSystem = System("process", Query.All( Health, Position, isInAcid() )) function InAcidSystem:Update() for i, entity in self:Result():Iterator() do local health = entity[Health] health.value = health.value - 0.01 end end local world = World({ InAcidSystem }) world.Entity(Position({ x: 5.0 }), Health()) ``` # Recursos O **ECS Lua** nao possui dependencias externas e é compativel e testada com [Lua 5.1], [Lua 5.2], [Lua 5.3], [Lua 5.4], [LuaJit] e [Roblox Luau](https://luau-lang.org/) - **Agnóstico de engine de jogo**: Pode ser usado em qualquer motor que tenha a linguagem de script Lua - **Ergonômico**: Focado em fornecer uma API simples, mas eficiente - **FSM**: Máquinas de Estados Finitos de maneira fácil e intuitiva - **JobSystem**: Para a execucao de sistemas em paralelo (por meio de [coroutines](http://www.lua.org/pil/9.1.html)) - **Reativo**: Os sistemas podem ser informado quando uma entidade sofrer alteracao - **Previsível**: - Os sistemas funcionarão na ordem em que foram registrados ou com base na prioridade definida ao registrá-los - Os eventos reativos não geram um retorno de chamada aleatório quando emitidos, sao executados em um passo pre-definido # Objetivo Ser uma biblioteca ECS leve, simples, ergonomica e de alto desempenho que pode ser facilmente estendida. O **ECS Lua** não segue estritamente o "design ECS puro". # Próximos passos Você pode navegar ou buscar assuntos específicos no menu lateral. A seguir, alguns links relevantes:

[![Instalação](../assets/icon-download.png ":no-zoom")](/pt-br/getting-started?id=instalação)
[Instalação](/pt-br/getting-started?id=instalação)
[![Conceitos Gerais](../assets/icon-parts.png ":no-zoom")](/pt-br/getting-started?id=conceitos-gerais)
[Conceitos Gerais](/pt-br/getting-started?id=conceitos-gerais)
[![Arquitetura](../assets/icon-advanced.png ":no-zoom")](/pt-br/architecture)
[Arquitetura](/pt-br/architecture)
[![Tutoriais](../assets/icon-tutorial.png ":no-zoom")](/pt-br/tutorial)
[Tutoriais](/pt-br/tutorial)
[Lua 5.1]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.2]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.3]:https://app.travis-ci.com/github/nidorx/ecs-lua [Lua 5.4]:https://app.travis-ci.com/github/nidorx/ecs-lua [LuaJit]:https://app.travis-ci.com/github/nidorx/ecs-lua ================================================ FILE: docs/pt-br/_coverpage.md ================================================
# ECS Lua [![Build Status](https://app.travis-ci.com/nidorx/ecs-lua.svg?branch=master)](https://app.travis-ci.com/nidorx/ecs-lua) [GitHub](https://github.com/nidorx/ecs-lua) [Começar](/pt-br/?id=o-que-é) ![color](#ffffff) ================================================ FILE: docs/pt-br/_navbar.md ================================================ - [Início](/pt-br/)     - Idiomas - [English](/) - [Português do Brasil](/pt-br/) ================================================ FILE: docs/pt-br/_sidebar.md ================================================ - [Início](/pt-br/) - [Instalação](/pt-br/getting-started?id=instalação) - [Conceitos Gerais](/pt-br/getting-started?id=conceitos-gerais) - [Componente](/pt-br/getting-started?id=componente) - [Sistemas e Consultas](/pt-br/getting-started?id=sistemas-e-consultas) - [Mundo](/pt-br/getting-started?id=mundo) - [Entidade](/pt-br/getting-started?id=entidade) - [Exemplo](/pt-br/getting-started?id=juntando-tudo) - [Arquitetura](/pt-br/architecture) - [Componente](/pt-br/architecture?id=componente) - [Qualificadores](/pt-br/architecture?id=qualificadores) - [FSM - Maquinas de Estado Finito](/pt-br/architecture?id=fsm-máquinas-de-estado-finito) - [Entidade](/pt-br/architecture?id=entidade) - [Consulta](/pt-br/architecture?id=consulta) - [Sistema](/pt-br/architecture?id=sistema) - [Tarefas](/pt-br/architecture?id=tarefas) - [Mundo](/pt-br/architecture?id=mundo) - [Tutoriais](/pt-br/tutorial) - [Shoot](/pt-br/tutorial-shoot) - [Pacman](/pt-br/tutorial-pacman) - [Boids](/pt-br/tutorial-boids) - [API](/pt-br/api) - [Archetype](/pt-br/api?id=archetype) - [Component](/pt-br/api?id=component) - [Entity](/pt-br/api?id=entity) - [ECS](/pt-br/api?id=ecs) - [Event](/pt-br/api?id=event) - [LoopManager](/pt-br/api?id=loopmanager) - [Query](/pt-br/api?id=query) - [QueryResult](/pt-br/api?id=queryresult) - [System](/pt-br/api?id=system) - [Time](/pt-br/api?id=time) - [World](/pt-br/api?id=world) ================================================ FILE: docs/pt-br/api.md ================================================ # API
# ECS - `ECS.Query` - _@type_ `QueryClass` - `ECS.Archetype` - _@type_ `ArchetypeClass` - `ECS.World(systemClasses, frequency, disableAutoUpdate)` - Create a new world instance - _@param_ `systemClasses` `SystemClass[]` _optional_ Array of system classes - _@param_ `frequency` `number` _optional_ Define the frequency that the `process` step will be executed. Default 30 - _@param_ `disableAutoUpdate` `bool` _optional_ When `~= false`, the world automatically registers in the `LoopManager`, receiving the `World:Update()` method from it. Default false - _@return_ `World` - `ECS.System(step, order, query, updateFn)` - Create new System Class - _@param_ `step` `process|transform|render|task` - _@param_ `order` `number` _optional_ Allows you to set an execution order (for systems that are not `task`). Default 50 - _@param_ `query` `Query|QueryBuilder` _optional_ Filters the entities that will be processed by this system - _@param_ `updateFn` `Function(self, Time)` _optional_ A shortcut for creating systems that only have the Update method - _@return_ `SystemClass` - `ECS.Component(template)` - Register a new ComponentClass - _@param_ `template` `table|function(table):table|any` - When `table`, this template will be used for creating component instances - When it's a `function`, it will be invoked when a new component is instantiated. The creation parameter of the component is passed to template function - If the template type is different from `table` and `function`, **ECS Lua** will generate a template in the format `{ value = template }`. - _@return_ `ComponentClass` - `ECS.SetLoopManager(manager)` - Defines the LoopManager that will be used by the worlds to receive the automatic update - _@param_ `manager` `LoopManager` # Archetype An Archetype is a unique combination of component types. The EntityRepository uses the archetype to group all entities that have the same sets of components. An entity can change archetype fluidly over its lifespan. For example, when you add or remove components, the archetype of the affected entity changes. An archetype object is not a container; rather it is an identifier to each unique combination of component types that an application has created at run time, either directly or implicitly. You can create archetypes directly using `ECS.Archetype.Of(Components[])`. You also implicitly create archetypes whenever you add or remove a component from an entity. An Archetype object is an immutable singleton; creating an archetype with the same set of components, either directly or implicitly, results in the same archetype. The ECS framework uses archetypes to group entities that have the same structure together. The ECS framework stores component data in blocks of memory called chunks. A given chunk stores only entities having the same archetype. You can get the Archetype object for a chunk from its Archetype property. Use `ECS.Archetype.Of(Components[])` to get a Archetype reference. - `Archetype.EMPTY` - Generic archetype, for entities that do not have components - _@type_ `Archetype` - `Archetype.Of(componentClasses)` - Gets the reference to an archetype from the informed components - _@param_ `componentClasses` `ComponentClass[]` Component that define this archetype - _@return_ `Archetype` - `Archetype.Version()` - Get the version of archetype definitions - _@return_ `number` - `Archetype:Has(componentClass)` - Checks whether this archetype has the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `bool` - `Archetype:With(componentClass)` - Gets the reference to an archetype that has the current components `+` the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `Archetype` - `Archetype:WithAll(componentClasses)` - Gets the reference to an archetype that has the current components `+` the informed components - _@param_ `componentClass` `ComponentClass[]` - _@return_ `Archetype` - `Archetype:Without(componentClass)` - Gets the reference to an archetype that has the current components `-` the informed component - _@param_ `componentClass` `ComponentClass` - _@return_ `Archetype` - `Archetype:WithoutAll(componentClasses)` - Gets the reference to an archetype that has the current components `-` the informed components - _@param_ `componentClass` `ComponentClass[]` - _@return_ `Archetype` # Component - `ComponentClass.Id` - Identifier of this component - _@type_ `number` - `ComponentClass.IsCType` - Indicates that this class is a Component - _@type_ `true` - `ComponentClass.SuperClass` - Used internally for Qualifiers, it indicates the base class of this Component (or primary component). - _@type_ `ComponentClass` - `ComponentClass.HasQualifier` - Indicates that this Component has qualifiers - _@type_ `bool` - `ComponentClass.IsQualifier` - Indicates that this specific class is a Component qualifier. - _@type_ `bool` - `ComponentClass.IsFSM` - Indicates that this Component is a [FSM - Finite State Machine][fsm] - _@type_ `bool` - `ComponentClass.States` - When set, this Component becomes a [FSM - Finite State Machine][fsm] ```lua local Movement = ECS.Component({ speed = 0 }) Movement.States = { Standing = {"Walking"}, Walking = "*", Running = {"Walking"} } ``` - _@type_ `table` _optional_ - `ComponentClass.StateInitial` - When the component is [FSM][fsm], it allows defining the initial state for new instances. - _@type_ `string` _optional_ - `ComponentClass.Case` - When the component is [FSM][fsm], it allows the component to handle transactions between states. ```lua Movement.Case = { Standing = function(self, previous) self.speed = 0 end, Walking = function(self, previous) self.speed = 5 end, Running = function(self, previous) self.speed = 10 end } ``` - _@type_ `table` _optional_ - `ComponentClass.Qualifier(qualifier)` - Gets a qualifier for this type of component. If the qualifier does not exist, a new class will be created, otherwise it brings the already registered class qualifier reference with the same name. - _@param_ `qualifier` `string|ComponentClass` - _@return_ `ComponentClass` - `ComponentClass.Qualifiers(...)` - Get all qualified class - _@param_ `...` `string|ComponentClass` _optional_ Allows to filter the specific qualifiers - _@return_ `ComponentClass[]` - `ComponentClass(value)` | `ComponentClass.New(value)` - Builder, instantiate a new component of this type - _@param_ `value` `any` _optional_ If the value is not a table, it will be converted to the format `{ value = value}` - _@return_ `Component` - `ComponentClass:GetType()` - Get this component's class - _@return_ `ComponentClass` - `ComponentClass:Is(componentClass)` - Check if this component is of the type informed - _@param_ `componentClass` `ComponentClass|ComponentSuperClass` - _@return_ `bool` - `ComponentClass:Primary()` - Get the instance for the primary qualifier of this class - _@return_ `Component|nil` - `ComponentClass:Qualified(qualifier)` - Get the instance for the given qualifier of this class - _@param_ `qualifier` `string|ComponentClass` - _@return_ `Component|nil` - `ComponentClass:QualifiedAll()` - Get all instances for all qualifiers of that class - _@return_ `Component[]` - `ComponentClass:Merge(other)` - Merges data from the other component into the current component. **IMPORTANT!** This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. - _@param_ `other` `Component` - `ComponentClass:Detach()` - Unlink this component with the other qualifiers. **IMPORTANT!** This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. - `ComponentClass.In(...)` - When the component is [FSM][fsm], creates a clause used to filter repository entities in a Query or QueryResult. ```lua ECS.Query.All(Movement.In("Walking", "Running")) ``` - _@param_ `...` `string[]` - _@return_ `Clause` - `ComponentClass:SetState(newState)` - When the component is [FSM][fsm], defines the current state - _@param_ `newState` `string` - `ComponentClass:GetState()` - When the component is [FSM][fsm], get the current state - _@return_ `string` - `ComponentClass:GetPrevState()` - When the component is [FSM][fsm], get the previous state - _@return_ `string|nil` - `ComponentClass:GetStateTime()` - When the component is [FSM][fsm], gets the time it changed to the current state. Whenever the state is changed, the instant is persisted internally using `os.clock()` - _@return_ `number` # Entity - `Entity.id` - Identifier of this entity - _@type_ `number` - `Entity.isAlive` - The entity is created in _DEAD_ state (`entity.isAlive == false`) and will only be visible for queries after the cleaning step _(`OnRemove`,`OnEnter`,`OnExit`)_ by the world - _@type_ `bool` - `Entity.archetype` - The entity archetype - _@type_ `Archetype` - `Entity.New(onChange, components)` - Creates an entity having components of the specified types. - _@param_ `onChange` `Event` - _@param_ `components` `Component[]` _optional_ - _@return_ `Entity` - `Entity:Get(componentClass)` | `entity[componentClass]` - Gets an entity component - _@param_ `componentClass` `ComponentClass` - _@return_ `Component` - `Entity:Get(...)` - Get multiple entity components at once ```lua local comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ``` - _@param_ `..` `ComponentClass[]` - _@return_ `Component ...` - `Entity:Set(componentClass, value)` | `entity[componentClass] = value` - Sets the value of a component - _@param_ `componentClass` `ComponentClass` - _@param_ `value` `any|nil` When nil, unset the component. - `Entity:Set(...)` - Arrow one or more instances of a component - _@param_ `...` `Component` - `Entity:Unset(componentClass|Component, ...)` | `entity[componentClass] = nil` - Remove one or more components from the entity - _@param_ `...` `componentClass|Component` # LoopManager In order for the world's systems to receive an update, the `World:Update(step, now)` method must be invoked on each frame. To automate this process, **ECS Lua** provides a functionality so that, at the time of instantiation of a new world, it registers to receive the update automatically. ```lua local MyLoopManager = { Register = function(world) local beforePhysics = MyGameEngine.BeforePhysics(function() world:Update("process", os.clock()) end) local afterPhysics = MyGameEngine.AfterPhysics(function() world:Update("transform", os.clock()) end) local beforeRender if (not MyGameEngine.IsServer()) then beforeRender = MyGameEngine.BeforeRender(function() world:Update("render", os.clock()) end) end return function() beforePhysics:Disconnect() afterPhysics:Disconnect() if beforeRender then beforeRender:Disconnect() end end end } ECS.SetLoopManager(MyLoopManager) ``` - `LoopManager.Register(world)` - Allows the world to register to be updated. - _@param_ `world` `World` - _@return_ `function` The world will invoke when destroyed # Query - `Query(all, any, none)` | `Query.New(all, any, none)` - Create a new Query used to filter entities in the world. It makes use of local and global cache in order to decrease the validation time (avoids looping in runtime of systems) - _@param_ `all` `Array` _optional_ - _@param_ `any` `Array` _optional_ - _@param_ `none` `Array` _optional_ - _@return_ `Query` - `Query.All(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.Any(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.None(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `Query.Filter(filter)` - Create custom filters that can be used in Queries. Its execution is delayed, invoked only in `QueryResult` methods. The result of executing the clause depends on how it was used in the query. Ex. If used in `Query.All()` the result is the inverse of using the same clause in `Query.None()` ```lua local Player = ECS.Component({ health = 100 }) local HealthPlayerFilter = ECS.Query.Filter(function(entity, config) local player = entity[Player] return player.health >= config.minHealth and player.health <= config.maxHealth end) local healthyClause = HealthPlayerFilter({ minHealth = 80, maxHealth = 100, }) local healthyQuery = ECS.Query.All(Player, healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is very healthy end) local notHealthyQuery = ECS.Query.All(Player).None(healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is NOT very healthy end) local dyingClause = HealthPlayerClause({ minHealth = 1, maxHealth = 20, }) local dyingQuery = ECS.Query.All(Player, dyingClause) world:Exec(dyingQuery):ForEach(function(entity) -- this player is about to die end) local notDyingQuery = ECS.Query.All(Player).None(dyingClause) world:Exec(notDyingQuery):ForEach(function(entity) -- this player is NOT about to die end) ``` - _@param_ `filter` `function(entity, config) -> bool` - _@return_ `function(config) -> Clause` - `Query:Result(chunks)` - Generate a `QueryResult` with the chunks entered and the clauses of the current query - _@param_ `chunks` `Array<{ [Entity] = true }>` - _@return_ `QueryResult` - `Query:Match(archetype)` - Checks if the entered archetype is valid by the query definition - _@param_ `archetype` `Archetype` - _@return_ `bool` # QueryBuilder - `QueryBuilder.isQueryBuilder` - Indicates that this is an instance of a QueryBuilder - _@type_ `true` - `QueryBuilder.All(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.Any(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.None(...)` - _@param_ `...` `Array` - _@return_ `QueryBuilder` - `QueryBuilder.Build()` - _@return_ `Query` # QueryResult The result of a Query that was executed on an EntityStorage. QueryResult provides several methods to facilitate the filtering of entities resulting from the execution of the query. - **Intermediate Operations** - Intermediate operations return a new QueryResult. They are always lazy; executing an intermediate operation such as `QueryResult:Filter()` does not actually perform any filtering, but instead creates a new QueryResult that, when traversed, contains the elements of the initial QueryResult that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed. - **Terminal Operations** - Terminal operations, such as `QueryResult:ForEach` or `QueryResult.AllMatch`, may traverse the QueryResult to produce a result or a side-effect. - `QueryResult.New(chunks, clauses)` - Build a new QueryResult - _@param_ `chunks` `Array<{ [Entity] = true }>` - _@param_ `clauses` `Clause[]` _optional_ - _@return_ `QueryResult` - `QueryResult:With(operation, param)` - Returns a QueryResult consisting of the elements of this QueryResult with a new pipeline operation - _@param_ `operation` `function(param, value, count) -> newValue, acceptItem, continuesLoop` - _@param_ `param` `any` - _@return_ `QueryResult` the new QueryResult - `QueryResult:Filter(predicate)` - Returns a QueryResult consisting of the elements of this QueryResult that match the given predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to each element to determine if it should be included - _@return_ `QueryResult` the new QueryResult - `QueryResult:Map(mapper)` - Returns a QueryResult consisting of the results of applying the given function to the elements of this QueryResult. - _@param_ `mapper` `function(value) -> newValue` a function to apply to each element - _@return_ `QueryResult` the new QueryResult - `QueryResult:Limit(maxSize)` - Returns a QueryResult consisting of the elements of this QueryResult, truncated to be no longer than maxSize in length. - _@param_ `maxSize` `number` - _@return_ `QueryResult` the new QueryResult - `QueryResult:AnyMatch(predicate)` - Returns whether any elements of this result match the provided predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to elements of this result - _@return_ `true` if any elements of the result match the provided predicate, otherwise `false` - `QueryResult:AllMatch(predicate)` - Returns whether all elements of this result match the provided predicate. - _@param_ `predicate` `function(value) -> bool` a predicate to apply to elements of this result - _@return_ `true` if either all elements of the result match the provided predicate or the result is empty, otherwise `false` - `QueryResult:FindAny()` - Returns some element of the result, or nil if the result is empty. This is a short-circuiting terminal operation. The behavior of this operation is explicitly nondeterministic; it is free to select any element in the result. Multiple invocations on the same result may not return the same value. - _@return_ `any` - `QueryResult:ForEach(action)` - Performs an action for each element of this QueryResult. This is a terminal operation. The behavior of this operation is explicitly nondeterministic. This operation does not guarantee to respect the encounter order of the QueryResult. - _@param_ `action` `function(value, count) -> bool` A action to perform on the elements, breaks execution case returns true - `QueryResult:ToArray()` - Returns an array containing the elements of this QueryResult. - _@return_ `Array` - `QueryResult:Iterator()` - Returns an Iterator, to use in for loop ```lua for count, entity in result:Iterator() do print(entity.id) break end ``` - _@return_ `Iterator` # System - `System.Step` - Step that this system will run - _@type_ `string` `process|transform|render|task` - `System.Order` - For systems that are not `task`, execution order - _@type_ `number` - `System.Query` - Filters the entities that will be processed by this system - _@type_ `number` - `System.After` - When the system is a task, it allows you to define that this system should run AFTER other specific systems. ```lua local log = {} local Task_A = System.Create('task', function() -- In this example, TASK_A takes time to execute, delaying its execution local i = 0 while i <= 4000 do i = i + 1 if i%1000 == 0 then coroutine.yield() end end table.insert(log, 'A') end) local Task_B = System.Create('task', function() table.insert(log, 'B') end) local Task_C = System.Create('task', function() table.insert(log, 'C') end) local Task_D = System.Create('task', function() table.insert(log, 'D') end) local Task_E = System.Create('task', function() table.insert(log, 'E') end) local Task_F = System.Create('task', function() table.insert(log, 'F') end) local Task_G = System.Create('task', function() table.insert(log, 'G') end) local Task_H = System.Create('task', function(self) table.insert(log, 'H') end) --[[ A<-------C<---+-----F<----+ | | | | +----+ E<----+ H | | | B<--+----D<---+------G<---+ A - has no dependency B - has no dependency C - Depends on A,B D - Depends on B E - Depends on A,B,C,D F - Depends on A,B,C,D,E G - Depends on B,D H - Depends on A,B,C,D,E,F,G Completion order will be B,D,G,A,C,E,F,H > In this example, TASK_A takes time to execute, delaying its execution ]] Task_A.Before = {Task_C} Task_B.Before = {Task_D} Task_C.After = {Task_B} Task_D.Before = {Task_G} Task_F.After = {Task_E} Task_E.After = {Task_D, Task_C} Task_C.Before = {Task_F} Task_H.After = {Task_F, Task_G} ``` - _@type_ `SystemClass[]` - `System.Before` - When the system is a task, it allows you to define that this system should run BEFORE other specific systems. - _@see_ `System.After` - _@type_ `SystemClass[]` - `System.version` - System Version (GSV). - _@see_ `World.version` - _@type_ `Number` - `System._world` - _@type_ `World` - `System._config` - _@type_ `table` - `System.New(world, config)` - Create an instance of this system - _@param_ `world` `World` - _@param_ `config` `table` - _@return_ `System` - `System:GetType()` - Get this system class - _@return_ `SystemClass` - `System:Result(query)` - Run a query in the world. A shortcut to `self._world:Exec(query)` - _@param_ `query` `Query|QueryBuilder` _optional_ If nil, use default query - _@return_ `QueryResult` - `System:Destroy()` - Destroy this instance - `System:OnDestroy()` - Allows you to perform some processing or cleaning when the instance is being destroyed - `System:ShouldUpdate(Time)` - Invoked before 'Update', allows you to control the execution of the update - _@param_ `Time` `Time` - _@return_ `bool` If true, the Update method will be invoked. - `System:Update(Time)` - Run the system's main method - _@param_ `Time` `Time` - `System:OnRemove(Time, entity)` - When it is a `QuerySystem`, it allows to be informed when an entity with the characteristics of the query is removed from the world. This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` - `System:OnExit(Time, entity)` - When it is a `QuerySystem`, it allows to be informed when an entity has lost the characteristics of that query (has suffered an archetype change and the current query no longer applies). This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` - `System:OnEnter(Time, entity)` - When it is a QuerySystem, it allows to be informed when an entity received the characteristics expected by this query (it suffered an archetype change and the current query now applies). This method is performed in the step cleanup process. - _@param_ `Time` `Time` - _@param_ `entity` `Entity` # Time Singleton, reference to the world's global processing time. - `Time.Now` - World Runtime - _@type_ `number` - `Time.NowReal` - Real time, received in `World:Update(step, now)` method - _@type_ `number` - `Time.Frame` - The time at the beginning of this frame (process). The world receives the current time at the beginning of each frame, with the value increasing per frame. - _@type_ `number` - `Time.FrameReal` - The REAL time at the beginning of this frame (`World:Update(step, now)`). - _@type_ `number` - `Time.Process` - The time the latest process step has started. - _@type_ `number` - `Time.Delta` - The completion time in seconds since the last frame. - _@type_ `number` - `Time.DeltaFixed` - Based on world update frequency (`process` step). ```lua DeltaFixed = 1000/frequency/1000 ``` - _@see_ `World:SetFrequency()` - _@type_ `number` - `Time.Interpolation` - The proportion of time since the previous transform relative to processDeltaTime. Used to do interpolation during the rendering step. Allows the `process` step to run at low frequency _(Ex. 30hz)_ and `render` at the maximum rate of the player's device _(Ex. 60hz)_ - _@type_ `number` # World - `World.version` - Global System Version (GSV). Before executing the `Update()` method of each system, the world version is incremented, so at this point, the world version will always be higher than the running system version. Whenever an entity archetype is changed (received or lost component) the entity's version is updated to the current version of the world. After executing the System Update method, the version of this system is updated to the current world version. This mechanism allows a system to know if an entity has been modified after the last execution of this same system, as the entity's version is superior to the version of the last system execution. Thus, a system can contain logic if it only operates on "dirty" entities, which have undergone changes. The code for this validation on a system is: - `local isDirty = entity.version > self.version` - _@type_ `number` - `World.maxTasksExecTime` - Allows you to define the maximum time that the `JobSystem` can operate in each frame. The default value is `0.011666666666666665` = `((1000/60/1000)*0.7)` - A game that runs at 30fps has 0.0333 seconds to do all the processing for each frame, including rendering - 30FPS = `(1000/30/1000)` = `0.03333333333333333` - A game that runs at 60fps has 0.0166 seconds to do all the processing for each frame, including rendering - 60FPS = `(1000/60/1000)` = `0.016666666666666666` - _@type_ `number` Default `0.011666666666666665` - `World:SetFrequency(frequency)` - Define the frequency that the `process` step will be executed - _@param_ `frequency` `number` _optional_ Default 30 - `World:GetFrequency()` - Get the frequency of execution of the `process` step - _@return_ `number` - `World:AddSystem(systemClass, config)` - Add a new system to the world. Only one instance per type is accepted. If there is already another instance of this system in the world, any new invocation of this method will be ignored. - _@param_ `systemClass` `SystemClass` The system to be added in the world - _@param_ `config` `table` _optional_ System instance configuration - `World:Entity(...)` - Create a new entity. The entity is created in _DEAD_ state (`entity.isAlive == false`) and will only be visible for queries after the cleaning step _(`OnRemove`,`OnEnter`,`OnExit`)_ of the current step - _@param_ `...` `Component[]` _optional_ Instance of the components that this entity will have - _@return_ `Entity` - `World:Remove(entity)` - Performs immediate removal of an entity. If the entity was created in this step and the cleanup process has not happened yet (therefore the entity is inactive, `entity.isAlive == false`), the `OnRemove` event will never be fired. If the entity is alive (`entity.isAlive == true`), even though it is removed immediately, the `OnRemove` event will be fired at the end of the current step. - _@param_ `entity` `Entity` - `World:Exec(query)` - Run a query in this world - _@param_ `query` `Query|QueryBuilder` - _@return_ `QueryResult` - `World:Update(step, now)` - Perform world update. When registered, the `LoopManager` will invoke World Update for each step in the sequence. - `process` At the beginning of each frame - `transform` After the game engine's physics engine runs - `render` Before rendering the current frame - _@param_ `step` `"process"|"transform"|"render"` - _@param_ `now` `number` Usually os.clock() - `World:Destroy()` - Destroy this instance, removing all entities, systems and events
[fsm]:https://en.wikipedia.org/wiki/Finite-state_machine ================================================ FILE: docs/pt-br/architecture.md ================================================ # Arquitetura Em Engenharia de Software, ECS é o acrônimo de Entity Component System (em português: Sistema de Componente e Entidade), é um padrão de arquitetura de software usado principalmente no desenvolvimento de jogos eletrônicos. Um ECS segue o princípio da "composição ao invés de herança" que permite uma flexibilidade maior na definição de entidades, onde cada objeto em uma cena de um jogo é uma entidade (por exemplo inimigos, projéteis, veículos, etc.). Cada entidade consiste de um ou mais componentes que adicionam comportamento ou funcionalidade. Portanto, o comportamento de uma entidade pode ser alterado durante o tempo de execução simplesmente adicionando ou removendo componentes. Isso elimina problemas de ambiguidade com que sofrem as hierarquias de herança profunda e vasta, que são difíceis de entender, manter e estender. Para mais detalhes: - [Perguntas Freqüentes sobre ECS](https://github.com/SanderMertens/ecs-faq) - [Entity Systems Wiki](http://entity-systems.wikidot.com/) - [Evolua sua hierarquia](http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/) - [ECS na Wikipedia](https://en.wikipedia.org/wiki/Entity_component_system) - [ECS no Elixir](https://yos.io/2016/09/17/entity-component-systems/) - [2017 GDC - Overwatch Gameplay Architecture e Netcode](https://www.youtube.com/watch?v=W3aieHjyNvw&ab_channel=GDC) ## Componente Representam as diferentes características de uma entidade, como posição, velocidade, geometria, física e pontos de vida. Os componentes armazenam apenas dados brutos para um aspecto do objeto e como ele interage com o mundo. Em outras palavras, o componente rotula a entidade como tendo este aspecto particular. No **ECS Lua**, a criacao de um componente é feito por meio do método `ECS.Component(template)`. O parametro `template` pode ser de qualquer tipo, onde: - Quando `table`, este template sera usado para a criacao de instancias de componentes ```lua local Componente = ECS.Component({ x = 0, y = 0, z = 0 }) local comp = Componente({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 0, 80 -- é o mesmo que local comp = Componente.New({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 0, 80 ``` - Quando for uma `function`, essa sera invocada na instanciacao de um novo componente. O parametro de criacao do componente é passado para a funcao de template ```lua local Componente = ECS.Component(function(param) return { x = param.x or 1, y = param.y or 1, z = param.z or 1 } end) local comp = Componente({ x = 33, z = 80 }) print(comp.x, comp.y, comp.z) -- > 33, 1, 80 ``` - Caso o tipo do template seja diferente de `table` e `function`, o **ECS Lua** ira gerar um template no formato `{ value = template }`. ```lua local Componente = ECS.Component(55) local comp1 = Componente() print(comp1.value) -- > 55 local comp2 = Componente({ value = 80 }) print(comp2.value) -- > 80 local comp3 = Componente("XPTO") print(comp3.value) -- > "XPTO" ``` ### Métodos No **ECS Lua**, os componentes são classes e podem portanto, possuir métodos auxiliares. > IMPORTANTE! Evite criar métodos que modifiquem os dados da instancia do componente diretamente ou que possuam regras de negocio, o ideal é que essas lógicas fiquem dentro dos sistemas, que são, por definição, os responsáveis por alterar os dados das entidades e seus componentes. ```lua local Pessoa = ECS.Component({ nome = "", sobrenome = "", nascimento = 0 }) function Pessoa:NomeCompleto() return self.nome.." "..self.sobrenome end function Pessoa:Idade() return tonumber(os.date("%Y", os.time())) - self.nascimento end local pessoa = Componente({ nome = "Joao", sobrenome = "Silva", nascimento = 2000 }) print(pessoa:NomeCompleto()) -- Joao Silva print(pessoa:Idade()) -- 21 ``` ### Qualificadores Nas implementações "ECS puras" existe a premissa de que um componente só pode ser adicionado uma única vez em uma entidade. Na grande maioria dos cenários, isso é verdade. Como por exemplo, você náo deseja que a sua entidade possua duas posições, não faz sentido! Portanto, sua entidade só irá possuir um componente do tipo Posição. Mas, as vezes voce irá construi alguma funcionalidade que precisa que sua entidade possua esse comportamento, possuir mais de um componente do mesmo **TIPO**. Quando o framework não dá suporte a esse tipo de implementação, voce acaba fazendo várias [gambiarras](https://pt.wikipedia.org/wiki/Gambiarra) para contornar o problema. O **ECS Lua** implementa o mecanismo de **Qualificadores** para que voce possa representar uma categoria de componentes. Para ilustrar o uso, vamos pensar no seguinte cenário: Eu quero adicionar no meu jogo um sistema de [Buff](https://en.wikipedia.org/wiki/Game_balance#Buff) para que meu personagem possa receber pontos extras de vida em situações específicas. Nós queremos ter a liberdade de aumentar ou diminuir a quantidade de buff de acordo com a regiao do mapa em que o jogador está. Olhando para o cenario acima, poderiamos criar, em um primeiro momento, a solucao abaixo. Um componente `HealthBuff` para registrar a quantidade de vida extra e dois sistemas. O primeiro `MapRegionSystem` decide quantos pontos de vida adicional o jogador tera para cada regiao, já o segundo `HealthSystem` representa alguma funcionalidade do sistema que precisa obter o total de vida do jogador em certo momento. ```lua -- components local Player = ECS.Component({ health = 100, region = "easy", healthTotal = 100 }) local HealthBuff = ECS.Component({ value = 10 }) -- systems local MapRegionSystem = System("process", 1, Query.All(Player)) function MapRegionSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] if player.region == "easy" then entity[HealthBuff] = nil -- remove o buff else local buff = entity[HealthBuff] if buff == nil then buff = HealthBuff(0) entity:Set(buff) end if player.region == "hard" then buff.value = 15 elseif player.region == "hell" then buff.value = 40 end end end end local HealthSystem = System("process", 2, Query.All(Player).Any(HealthBuff)) function HealthSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] local buff = entity[HealthBuff] if buff then player.healthTotal = player.health + buff.value else player.healthTotal = player.health end end end ``` Até aqui tranquilo. Porém, imagine agora que meu jogador possa receber **VÁRIOS** buffers. - Ele pode receber um buffer pelo personagem que esta usando; - mais um buff quando desbloquear um item e; - pode também comprar buff na loja do jogo. Neste novo cenário a nossa solução nao atende, pois o código do sistema `MapRegionSystem` não tem a informacao sobre os outros fatores, e para atender, deverá passar a conhecer ou gerenciar vários estados possíveis para decidir qual é a quantidade de vida que o jogador irá receber por estar em uma regiao específica. Os outros sistemas do jogo também precisaram conhecer a regiao para decir quanto de buffer pode somar. Em uma solução "ECS pura", nós vamos comecar a: 1. compartilhar estado entre sistemas 1. criar "Componentes TAGs" para facilitar o gerenciamento desse estado distribuido, 1. inflar os componentes com um atributo para cada tipo de sistema. Em um primeiro momento isso nao parece ser problema, mas com o tempo, vários sistemas serão executados de forma desnecessária (apenas para fazer um if e nao processar aquela entidade). Estes sistemas passaram a ter responsabilidades extras, aumentando a complexidade do codigo, dificultando a manutencao e facilitando o aparecimento de bugs. No **ECS Lua** nós resolvemos este tipo de problema criando qualificadores, por meio do método estático `ComponentClass.Qualifier(qualifier)`. Ele aceita uma string como parametro e retorna a referencia para uma classe especializada do nosso componente. Essa classe gerada mantém uma ligação forte com a classe base, permitindo a aplicacao de filtros de consulta mais complexas. Vamos alterar o nosso exemplo fazendo uso de qualificadores. ```lua -- components local Player = ECS.Component({ health = 100, region = "easy", healthTotal = 100 }) local HealthBuff = ECS.Component({ value = 10 }) local HealthBuffItem = HealthBuff.Qualifier("Item") local HealthBuffMapRegion = HealthBuff.Qualifier("Region") local Item = ECS.Component({ rarity = 0 }) -- systems local PlayerItemSystem = System("process", 1, Query.All(Player, Item)) function PlayerItemSystem:Update(Time) for i, entity in self:Result():Iterator() do local item = entity[Item] local player = entity[Player] if item.rarity == "legendary" then entity[HealthBuffItem] = 15 -- o mesmo que entity:Set(HealthBuffItem.New(15)) else entity[HealthBuffItem] = nil end end end local MapRegionSystem = System("process", 1, Query.All(Player)) function MapRegionSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] if player.region == "easy" then entity[HealthBuffMapRegion] = nil else local buff = entity[HealthBuffMapRegion] if buff == nil then buff = HealthBuffMapRegion(0) entity:Set(buff) end if player.region == "hard" then buff.value = 15 elseif player.Region == "hell" then buff.value = 40 end end end end local HealthSystem = System("process", 2, Query.All(Player).Any(HealthBuff)) function HealthSystem:Update(Time) for i, entity in self:Result():Iterator() do local player = entity[Player] local healthTotal = player.health local buffers = entity:GetAll(HealthBuff) for i,buff in ipairs(buffers) do healthTotal = healthTotal + buff.value end player.healthTotal = player.health end end ``` Pronto, nessa nova implementacao, o sistema `MapRegionSystem` só preocupa-se com o qualificador `HealthBuffMapRegion`, enquanto que o sistema `PlayerItemSystem` gerencia apenas o qualificador `HealthBuffItem`. Nos podemos agora criar sistemas especializados em qualificadores e gerenciar apenas este atributo da entidade. Já o `HealthSystem` obtém e processa todas as entidades que possuam qualquer qualificador do componente `HealthBuff`. [Verifique na API](/pt-br/api?id=component) outros métodos que podem ser úteis ao trabalhar com qualificadores. ### FSM - Máquinas de Estado Finito __UNDER_CONSTRUCTION__ ```lua local Movement = Component.Create({ Speed = 0 }) -- [Standing] <--> [Walking] <--> [Running] Movement.States = { Standing = {"Walking"}, Walking = "*", Running = {"Walking"} } Movement.StateInitial = "Standing" Movement.Case = { Standing = function(self, previous) print("Transition from "..previous.." to Standing") end, Walking = function(self, previous) print("Transition from "..previous.." to Walking") end, Running = function(self, previous) print("Transition from "..previous.." to Running") end } local movement = Movement() movement:SetState("Walking") movement:SetState("Running") print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState("Standing") -- invalid, Running -> Walking|Running print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState(nil) print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking movement:SetState("INVALID_STATE") print(movement:GetState()) -- Running print(movement:GetPrevState()) -- Walking -- query local queryStanding = Query.All(Movement.In("Standing")) local queryInMovement = Query.Any(Movement.In("Walking", "Running")) -- qualifier local MovementB = Movement.Qualifier("Sub") -- ignored, "States", "StateInitial" and "Case" only work in primary class MovementB.States = { Standing = {"Walking"} } ``` ## Entidade __UNDER_CONSTRUCTION__ ```lua --[[ [GET] 01) comp1 = entity[CompType1] 02) comp1 = entity:Get(CompType1) 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ]] --[[ [SET] 01) entity[CompType1] = nil 02) entity[CompType1] = value 03) entity:Set(CompType1, nil) 04) entity:Set(CompType1, value) 05) entity:Set(comp1) 06) entity:Set(comp1, comp2, ...) ]] --[[ [UNSET] 01) enity:Unset(comp1) 02) entity[CompType1] = nil 03) enity:Unset(CompType1) 04) enity:Unset(comp1, comp1, ...) 05) enity:Unset(CompType1, CompType2, ...) ]] --[[ [Utils] 01) comps = entity:GetAll() 01) qualifiers = entity:GetAll(PrimaryClass) ]] ``` ## Consulta __UNDER_CONSTRUCTION__ ## Sistema __UNDER_CONSTRUCTION__ ## Tarefas __UNDER_CONSTRUCTION__ ## Mundo __UNDER_CONSTRUCTION__ ================================================ FILE: docs/pt-br/faq.md ================================================ # Perguntas Frequentes __UNDER_CONSTRUCTION__ ================================================ FILE: docs/pt-br/getting-started.md ================================================ # Instalação O **ECS Lua** nao possui dependencias externas, portanto, basta fazer o download da ultima versao disponível na [página de releases](https://github.com/nidorx/ecs-lua/releases) do projeto. Existem 3 opcoes de uso do **ECS Lua** 1. **ECS.lua** Versao minificada em um único arquivo 1. **ECS_concat.lua** Versao concatenada com os comentarios originais, que pode ser usada para depuracao durante o desenvolvimento 1. **ECS.zip** Versao com os arquivos do diretorio `src`. > Importante! Todos os arquivos fazem o `require` para as dependencias que estão no mesmo diretorio, caso esteja usando em um projeto Lua, registrar no `package.path`. > Estes `require` nao funcionam no Roblox Luau, devido ao formato de importacao que o Roblox usa. Após importar o **ECS Lua**, ele está pronto para ser usado. O **ECS Lua** registra a variavel global `_G.ECS` para facilitar o uso, portanto, voce pode usar o motor nas duas formas `local ECS = require("ECS")` (no Roblox `local ECS = require(game.ReplicatedStorage:WaitForChild("ECS"))`) ou simplesmente `_G.ECS`. ## LoopManager Para que os sistemas do mundo recebam atualizacao, é necessário que o método `World:Update(step, now)` seja invocado em cada frame. Para automatizar este processo, o **ECS** disponibiliza uma funcionalidade para que, no momento da instanciacao de um novo mundo, este possa registrar-se para receber o update de forma automatica. A implementacao desse método é muito simples e mais detalhes pode ser visto na seção [Arquitetura - Ciclo De Vida](/pt-br/architecture?id=world). > Se você utiliza Roblox nao precisa preocupar-se, o **ECS Lua** já tem uma implementacao padrão quando é executado no Roblox, mais detalhes abaixo. ## Roblox Você pode fazer a instalação diretamente do Roblox Studio, através da busca na caixa de ferramentas por `ECS-lua`, esta é a versão [minificada do motor](https://www.roblox.com/library/5887881675/ecs-lua). Ao usar o **ECS Lua** no Roblox, a engine já identifica e registra automaticamente um `LoopManager`, nao sendo portanto necessario nenhum passo adicional. # Conceitos Gerais Alguns termos comuns nos motores ECS são: - [Entidades](/pt-br/architecture?id=entidade): Um objeto com um ID exclusivo que pode ter vários componentes anexados a ele. - [Componentes](/pt-br/architecture?id=componente): Diferentes caracteristicas de uma entidade. ex: geometria, física, pontos de vida. Os dados são armazenados apenas em componentes. - [Sistemas](/pt-br/architecture?id=sistema): Faz o trabalho real, aplica as regras do jogo, processando entidades e modificando seus componentes. - [Consultas](/pt-br/architecture?id=consulta): Usado pelos sistemas para determinar em quais entidades eles estão interessados, com base nos componentes que as entidades possuem. - [Mundo](/pt-br/architecture?id=mundo): Um contêiner para entidades, componentes, sistemas e consultas.
![Arquitetura](../assets/diagram-1-pt-br.png)
O fluxo de trabalho normal ao construir um programa baseado em ECS: - Crie os `Componentes` que moldam os dados que você precisa usar em seu jogo/aplicativo. - Crie os `Sistemas` que usarão esses `Componentes` para ler e transformar os dados das entidades. - Crie `Entidades` e anexe `Componentes` a elas. - Execute todos os sistemas a cada frame, realize `Consultas` no `Mundo` para decidir quais entidades serão modificadas. ## Componente Os componentes são objetos que contêm dados. No **ECS Lua**, basta invocar o método `ECS.Component(template)` para definir uma `Classe` de um componente. O parametro `template` pode ser de qualquer tipo, onde: - Quando `table`, este template sera usado para a criacao de instancias de componentes; - Quando for uma `function`, essa sera invocada na instanciacao. - Caso o tipo do template seja diferente, o **ECS Lua** ira gerar um template no formato `{ value = template }`, este é o formato usado no componente `Acceleration` abaixo. ```lua local Position = ECS.Component({ x = 0, y = 0, z = 0 }) -- o mesmo que: -- ECS.Component({ value = 0.1 }) local Acceleration = ECS.Component(0.1) ``` [Mais informações sobre como criar componentes](/pt-br/architecture?id=componentes). ## Sistemas e Consultas Agora vamos definir um [sistema](/pt-br/architecture?id=systems) para processar os componentes que acabamos de criar. Um sistema pode implementar diversos métodos, neste exercício vamos usar apenas o método `Update(Time)`. Este método será invocado em todo frame, no passo `process` do mundo. Para criar um sistema, usamos o método `ECS.System(step, order, query, updateFn)`. Este método recebe os seguintes parametros: - **`step`** String, aceita os valores `process`, `transform`, `render` ou `task` - **`order`** Number (Opcional, padrão 50), permite definir uma ordem de execucao para sistemas que não são do tipo `task` - **`query`** Query (Opcional), filtra as entidades que serão processadas por este sistema - **`updateFn`** Function (Opcional), um atalho para criacao de sistemas que so possuam o método Update Nós também vamos definir uma `Query`, que é a consulta que usaremos para obter apenas as entidades que estamos interessados. Pra criar a query, nós podems usar os métodos `Query.All(Component)`, `Query.Any(Component)` e `Query.None(Component)`. Ao invocar qualquer um destes métodos um `QueryBuilder` é retornado, desse modo voce pode invocar os outros métodos na sequencia Ex. `Query.All(ComponentA).Any(ComponentB).None(ComponentC).Build()`. ![Pipeline](../assets/pipeline.png) Vamos começar criando um sistema que fará um loop por todas as entidades que possuem um componente `Position` e registrar suas posições. ```lua -- um atalho para os métodos local System, Query = ECS.System, ECS.Query local PositionLogSystem = System("process", 2, Query.All(Position), function(self, Time) -- Iterar por todas as entidades na consulta self:Result():ForEach(function(entity) -- Acessa o componente `Position` na entidade atual local pos = entity[Position] local msg = "Entidade com ID: %d tem o componente Position = {x: %0.2f, y: %0.2f, z: %0.2f}" print(msg:format(entity.id, pos.x, pos.y, pos.z)) end) end) ``` O próximo sistema move cada entidade que tem uma posição e uma aceleração. ```lua local MovableSystem = System("process", 1, Query.All(Acceleration, Position)) -- Este método será chamado em todos os quadros por padrão function MovableSystem:Update(Time) local delta = Time.DeltaFixed -- Iterar por todas as entidades na consulta for i, entity in self:Result():Iterator() do local acceleration = entity:Get(Acceleration).value local position = entity[Position] position.x = position.x + acceleration * delta position.y = position.y + acceleration * delta position.z = position.z + acceleration * delta end end ``` > Observe que estamos acessando componentes em uma entidade de duas formas diferentes: `entity:Get(Acceleration)` tem o mesmo resultado que `entity[Acceleration]` A consulta do sistema sistema `MovableSystem` filtra as entidades que possuem os componentes `Acceleration` e `Position`. Observe que, se necessário, podemos criar quantas consultas desejarmos e processá-las no método `Update`, ex: ```lua local SystemDemo = System("process", 1) function SystemDemo:Initialize(config) self.queryBoxes = Query.All(Box).Build() self.queryBalls = Query.All(Ball).Build() self.queryGeometries = Query.Any(Box, Ball).Build() end function SystemDemo:Update(Time) local boxes = self:Result(self.queryBoxes):ToArray() local balls = self.World:Exec(self.queryBalls):ToArray() for i, entity in self:Result(self.queryGeometries):Iterator() do print(entity[Box], entity[Ball]) end end ``` Para obter mais informações, verifique a documentação de arquitetura: [Acessando e modificando componentes](/manual/Architecture?id=accessing-components-and-modify-components) e [Queries](/manual/Architecture?id=reactive-queries) ## Mundo Um mundo é um contêiner para `entidades`, `componentes` e `sistemas`. A maioria dos jogos tem apenas um `mundo`, entretanto, você pode ter vários mundos funcionando ao mesmo tempo e habilitá-los ou desabilitá-los conforme necessário. A criacao de um mundo é feita pelo método `ECS.World(systemClasses, frequency, disableAutoUpdate)`, onde: - **systemClasses** Array de classes de sistemas, opcional - **frequency** Number (Opcional, padrao 30), permite definir a frequencia que o passo `process` será executado no mundo. - **disableAutoUpdate** Bool (Opcional, padrao false), quando diferente de `false`, o mundo registra-se automaticamente no `LoopManager`, recebendo deste a invocacao do método `World:Update()` Vamos começar a criar nosso primeiro mundo: ```lua local world = ECS.World(); ``` Agora vamos registrar nossos sistemas no mundo para que sejam inicializados e executados em cada quadro. ```lua world:AddSystem(PositionLogSystem) world:AddSystem(MovableSystem) ``` ## Entidade Tendo nosso mundo, alguns componentes e sistemas já definidos, vamos criar [entidades](/pt-br/architecture?id=entidades) e anexar estes componentes a eles: ```lua local entity1 = world:Entity(Position()) local entity2 = world:Entity( Position({x = 5}), Acceleration(1) ) local entity3 = world:Entity( Position.New({x = 5}), Acceleration.New(1) ) local entity4 = world:Entity( Position({x = 5}), Acceleration({value = 1}) ) local entity5 = world:Entity() entity5[Position] = { y = 5 } entity5:Set(Acceleration()) local entity6 = world:Entity() entity6[Position] = Position() entity6:Set(Acceleration()) ``` Com isso, acabamos de criar 6 entidades. 5 com os componentes `Acceleration` e `Position`, e um apenas com o componente `Position`. Observe que existem varias formas de instanciar e atribuir os componentes para a entidade, escolha a que mais combina com o seu estilo de programção, o resultado final é o mesmo. Perceba também que as classes dos componentes podem ser usadas [como funcoes](http://lua-users.org/wiki/FuncTables), por exemplo `Position()`. Este formato tem o mesmo efeito que `Position.New()`. ## Juntando tudo Agora o mundo só precisa ser atualizado (`world.Update(step, now)`) para que tudo funcione. Se voce usa roblox, basta criar um Local Script que o mundo já sera atualizado automaticamente. ```lua -- No Roblox: local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) -- um atalho para os métodos local Component, System, Query = ECS.Component, ECS.System, ECS.Query --[[ Componentes ]] local Position = Component({ x = 0, y = 0, z = 0 }) local Acceleration = Component(0.1) --[[ Sistemas ]] local PositionLogSystem = System("process", 2, Query.All(Position), function(self, Time) -- Iterar por todas as entidades na consulta self:Result():ForEach(function(entity) -- Acessa o componente `Position` na entidade atual local pos = entity[Position] local msg = "Entidade com ID: %d tem o componente Position = {x: %0.2f, y: %0.2f, z: %0.2f}" print(msg:format(entity.id, pos.x, pos.y, pos.z)) end) end) local MovableSystem = System("process", 1, Query.All(Acceleration, Position)) -- Este método será chamado em todos os quadros por padrão function MovableSystem:Update(Time) local delta = Time.DeltaFixed -- Iterar por todas as entidades na consulta for i, entity in self:Result():Iterator() do local acceleration = entity:Get(Acceleration).value local position = entity[Position] position.x = position.x + acceleration * delta position.y = position.y + acceleration * delta position.z = position.z + acceleration * delta end end --[[ Mundo ]] local world = ECS.World(); world:AddSystem(PositionLogSystem) world:AddSystem(MovableSystem) --[[ Entidades ]] local entity1 = world:Entity(Position()) local entity2 = world:Entity(Position({x = 5}), Acceleration(1)) local entity3 = world:Entity(Position.New({x = 5}), Acceleration.New(1)) local entity4 = world:Entity(Position({x = 5}), Acceleration({value = 1})) local entity5 = world:Entity() entity5[Position] = { y = 5 } entity5:Set(Acceleration()) local entity6 = world:Entity() entity6[Position] = Position() entity6:Set(Acceleration()) ``` Resultado do código acima no Roblox Studio ![Resultado do código acima no Roblox Studio](../assets/get-started-output-pt-br.gif) ## Próximos passos Esta foi uma visão geral rápida sobre como as coisas são estruturadas usando **ECS Lua**, [leia a documentação da arquitetura](/pt-br/architecture) para informações mais detalhadas. ================================================ FILE: docs/pt-br/tutorial-boids.md ================================================ # Tutorial - Boids __UNDER_CONSTRUCTION__ > OBJETIVO: Aplicar o uso do Job System para atualizar algumas centenas (ou milhares) de entidades por meio de processamento paralelo ================================================ FILE: docs/pt-br/tutorial-pacman.md ================================================ # Tutorial - Pacman __UNDER_CONSTRUCTION__ > OBJETIVO: Demonstrar o uso de maquinas de estado para criar a "inteligencia" dos fantasmas ================================================ FILE: docs/pt-br/tutorial-shoot.md ================================================ # Tutorial - Jogo de Tiro __UNDER_CONSTRUCTION__ > OBJETIVO: Demonstrar o uso das principais funcionalidades do ECS Lua e interpolacao na renderizacao ================================================ FILE: docs/pt-br/tutorial.md ================================================ # Tutoriais __UNDER_CONSTRUCTION__ ================================================ FILE: docs/style.css ================================================ .meta-container { text-align: right; } .meta-container>.edit-button { text-decoration: none; font-size : 12px; } .meta-container>.edit-button svg { top : 2px; width : 14px; margin-right: .5rem; position : relative; } .api-docs>ul>li { list-style : circle; margin-bottom: 1.2em; } .api-docs>ul>li p { margin: 0; } .api-docs>ul>li>ul>li { list-style: none; } .cover-show { z-index: 0; } main { z-index: 1; } .github-corner { z-index: 2; } /* Interactive Logo */ /* Source: http://codepen.io/elrumordelaluz/pen/aWavEG */ .logo-container { position : relative; height : 35vh; max-height: 400px; display : block; margin : 0 auto; } .ghost, .heats { position: absolute; width : 100%; height : 100%; } .heats { display : grid; grid-template-columns: repeat(5, 1fr); } .ghost { z-index : 1; animation: floating 6s ease-in-out infinite; } .eye { transition : 0.3s; transform-origin: center center; } .eyelid { transform: scaleY(0.0); animation: blink 1.5s infinite alternate; } .eye--left .eyelid { transform-origin: 40px 19px; } .eye--right .eyelid { transform-origin: 40px 19px; } .eye .pupil, .eye .pupil .inner, .eye .eyelid { transition: 0.3s; } .pupil { transform-origin: center center; } .h { z-index : 2; /* debug */ /* outline: 1px solid red; */ } .h.r1:hover~.ghost .pupil { transform: translateY(-7px); } .h.r2:hover~.ghost .pupil { transform: translateY(-3px); } .h.r3:hover~.ghost .pupil { transform: translateY(1px); } .h.r4:hover~.ghost .pupil { transform: translateY(2px); } .h.r5:hover~.ghost .pupil { transform: translateY(3px); } .h.c1:hover~.ghost .pupil .inner { transform: translateX(-8px); } .h.c2:hover~.ghost .pupil .inner { transform: translateX(-6px); } .h.c3:hover~.ghost .pupil .inner { transform: translateX(-4px); } .h.c4:hover~.ghost .pupil .inner { transform: translateX(0px); } .h.c5:hover~.ghost .pupil .inner { transform: translateX(1px); } .h.r3.c3:hover~.ghost .body, .h.r2.c3:hover~.ghost .body { animation: crazy-body 0.1s infinite alternate; } .h.r3.c3:hover~.ghost .eye--left .pupil .inner, .h.r2.c3:hover~.ghost .eye--left .pupil .inner { animation: crazy-l 0.1s infinite alternate; } .h.r3.c3:hover~.ghost .eye--right .pupil .inner, .h.r2.c3:hover~.ghost .eye--right .pupil .inner { animation: crazy-r 0.1s infinite alternate; } @keyframes crazy-body { 0%, 50% { fill: #9cb1ff; } 100% { fill: #fb860c; } } @keyframes crazy-l { 0%, 50% { transform: translateX(0px); } 100% { transform: translateX(-5px); } } @keyframes crazy-r { 0%, 50% { transform: translateX(-7px); } 100% { transform: translateX(-2px); } } @keyframes floating { 0% { transform: translateY(0px); } 50% { transform: translateY(-20px); } 100% { transform: translateY(0px); } } @keyframes blink { 0%, 95% { opacity : 0; transform: scaleY(0.0); } 100% { opacity : 1; transform: scaleY(1.0); } } section.cover.show { /* background: radial-gradient(circle farthest-side, #fff 20%, #9cb1ffcc 60%, #f69d3c 130%) !important; */ background: radial-gradient(circle farthest-side, #fff 20%, rgb(156, 177, 255, 0.4) 60%, #f69d3c 130%) !important; } section.cover .cover-main { margin: 0px 16px 0; } aside.sidebar { width : 240px; box-shadow: 0 0 10px 0 rgba(131, 142, 162, 0.22); } aside.sidebar>h1 { height : 102px; visibility: hidden; } aside.sidebar .logo { width : 80px; margin : 40px auto 10px auto; text-align: center; } aside.sidebar .logo>p { margin : 0; color : #0f49b3; font-size: 18px; } aside.sidebar .search { margin : 0 2.2em 1em !important; padding: 0 !important; } aside.sidebar .search input { font-size: 12px; } aside.sidebar .sidebar-nav { margin-top: 38px; } aside.sidebar ul { margin: 0; } aside.sidebar ul li { margin : 0; position: relative; } aside.sidebar ul li a { height : 42px; line-height: 36px; font-size : 14px; color : #7285a1; padding : 3px 0 3px 40px; } aside.sidebar ul li ul li a { padding-left: 60px; } aside.sidebar ul li ul li ul li a { padding-left: 80px; } aside.sidebar ul li.active>a { border : 0; background-color: rgba(61, 120, 232, 0.05); } aside.sidebar ul li.active>a:before { content : ''; position : absolute; margin : 0; width : 10px; height : 10px; top : 15px; left : 20px; border-radius : 50%; background-color: #9cb1ff; box-shadow : 0 0 0 3px rgb(156, 177, 255, 0.4); } aside.sidebar ul li.active>a:after { content : ''; position : absolute; left : 0; width : 4px; height : 36px; border-radius : 4px; background-color: #9cb1ff; } section.content .vuep { height : auto; border : 1px solid #ebebeb; border-radius: 4px; } section.content .vuep:hover { box-shadow: 0 0 10px 0 rgba(232, 237, 250, .6), 0 2px 4px 0 rgba(232, 237, 250, .6); } /* section.content .vuep-editor, section.content .vuep-preview { position: relative; width : 50%; height : 432px; } section.content .vuep-editor { margin-right: 0; border-right: 1px dashed #ebebeb; } section.content .vuep-preview { border : 0; padding: 15px 25px; } */ section.content .markdown-section { max-width : 1200px; /* min-width: 960px; */ /* padding : 0 60px 30px 0; */ } .markdown-section h1 { font-size: 1.5rem; } .markdown-section h2 { font-size: 1.25rem; margin : 2rem 0 1.5rem; } .markdown-section h3, .markdown-section h4 { font-size: 1rem; margin : 1.5rem 0 .75rem; } .markdown-section table { border-radius: 4px; } .markdown-section code { padding: 3px 3px; } .markdown-section pre>code { padding: 10px 5px; } @media screen and (max-width: 768px) { .markdown-section pre { padding: 0; } } .markdown-section p.tip, .markdown-section tr:nth-child(2n) { background-color: #fafcff; } .markdown-section iframe { margin : 0; border : 1px solid #ebebeb; border-radius: 4px; } .markdown-section iframe:hover { border : 1px solid #ebebeb; box-shadow: 0 0 10px 0 rgba(232, 237, 250, .6), 0 2px 4px 0 rgba(232, 237, 250, .6); } /* HOME PANEL */ @keyframes box-enter { from { opacity: 0; } to { opacity: 1; } } .home-row { display : flex; flex-wrap: wrap; } .home-col { width : 25%; flex-basis : 25%; padding-left : 12px; padding-right : 12px; animation-name : box-enter; animation-duration: 2s; } @media screen and (max-width: 1160px) { .home-col { width : 50%; flex-basis: 50%; } } @media screen and (max-width: 768px) { .home-col { width : 100%; flex-basis: 100%; } } .home-panel { box-shadow : none; border : none; border-radius : 0; margin-bottom : 30px; background-color: #fff; } .home-row .panel-heading { border : none; background : #f69d3c !important; color : black !important; font-weight: bold; font-size : 1.125rem; padding : 10px 13px; } .home-panel .panel-heading a, .home-panel .panel-heading a:hover { color : black; text-decoration: none; } .home-panel .panel-body { padding : 24px; border : none; background: #ffd6a8; color : white; font-size : 1.875rem; height : 138px; } .home-panel p { margin: 0 !important; } .home-panel .panel-body pre { background: none; border : none; color : white; font-size : 2.125rem; text-align: center; padding : 0; } .home-panel .panel-body pre .fa { margin-left: calc(-50% + 14px); } .home-panel .list-group { border : none; background: #f2f2f2; padding : 10px !important; box-shadow: none; margin : 0 !important; } .home-panel .list-group .list-group-item { border : none; background : transparent; text-align : left; font-size : 0.75rem; font-weight: 600; padding : 2px 15px; } .home-panel .list-group .list-group-item::before { display: none; } .home-panel .list-group .list-group-item p { margin: 0; } .home-panel .list-group .list-group-item a { text-decoration: none; } ================================================ FILE: docs/tutorial-boids.md ================================================ # Tutorial - Boids __UNDER_CONSTRUCTION__ > OBJECTIVE: To apply the use of the Job System to update a few hundred (or thousands) of entities through parallel processing ================================================ FILE: docs/tutorial-pacman.md ================================================ # Tutorial - Pacman __UNDER_CONSTRUCTION__ > OBJECTIVE: Demonstrate the use of state machines to create the "intelligence" of ghosts ================================================ FILE: docs/tutorial-shoot.md ================================================ # Tutorial - Shooting Game __UNDER_CONSTRUCTION__ > OBJECTIVE: Demonstrate the use of the main features of ECS Lua and interpolation in rendering ================================================ FILE: docs/tutorial.md ================================================ # Tutorials __UNDER_CONSTRUCTION__ ================================================ FILE: docs/z_old_TECH_DETAILS.md ================================================ # Roblox-ECS - Technical implementation details ## Roblox Pipeline Before going into the details, let's review some important concepts about how the Roblox game engine works. Most likely you have seen the illustration below, made by zeuxcg and enriched by Fractality_alt. It describes the roblox rendering pipeline. Let's redraw it so that it is clearer what happens in each frame of a game in roblox [![](docs/pipeline_old.png)](https://devforum.roblox.com/t/runservice-heartbeat-switching-to-variable-frequency/23509/7) Ready: In the new image, we have a clear separation (gap between CPU1 and CPU2) of the roblox rendering process, which occurs in parallel with the simulation and processing (game logic) of the next screen. The green arrows indicate the start of processing of the new frame and the return of execution after the completion of the two processes that are being executed in parallel (rendering of the previous screen and processing of the current frame). The complete information on the order of execution can be seen at https://developer.roblox.com/en-us/articles/task-scheduler [![](docs/pipeline.png)](https://developer.roblox.com/en-us/articles/task-scheduler) Based on this model, roblox-ecs-lib organizes the execution of the systems in the following events. We call them steps ![](docs/pipeline_ecs_resume.png) ![](docs/pipeline_ecs_steps.png) ### processIn Executed once per frame. This is the first step to be executed in a frame. Use this step to run systems that translate the user's input or the current state of the workspace to entity components, which can be processed by specialized systems in the next steps Eg. Use the UserInputService to register the player's inputs in the current frame in a pool of inputs, and, in the PROCESS_IN step, translate these commands to the player's components. Realize that the same logic can be used to receive entries from the server and update local entities that represent other players ```lua -- InputHandlerUtils.lua local UserInputService = game:GetService("UserInputService") local pool = { FIRE = false } -- clear frame inputs function pool.clear() pool = { FIRE = false } end UserInputService.InputBegan:Connect(function(input, gameProcessed) if input.UserInputType == Enum.UserInputType.MouseButton1 then pool.FIRE = true end end) return pool --------------------------------------------------------------------------------------- -- InputMapSystem.lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local FiringComponent = require(game.ReplicatedStorage:WaitForChild("FiringComponent")) local pool = require(game.ReplicatedStorage:WaitForChild("InputHandlerUtils")) return ECS.RegisterSystem({ name = 'InputMap', step = 'process', order = 5, requireAll = { PlayerComponent }, update = function (time, world, dirty, entity, index, players) local changed = false if pool.FIRE then world.Set(entity, FiringComponent, { FiredAt = time.frame }) changed = true end pool.clear() return changed end }) ``` ### process Executed 0 or more times per frame This step allows the execution of systems for game logic independent of Frame-Rate, obtaining determinism in the simulation of the rules of the game Independent Frame-Rate games are games that run at the same speed, no matter the frame rate. For example, a game can run at 30 FPS (frames per second) on a slow computer and 60 FPS on a fast one. A game independent of the frame rate progresses at the same speed on both computers (the objects appear to move at the same speed). On the other hand, a frame rate-dependent game advances at half the speed of the slow computer, in a kind of slow motion effect (read more at https://gafferongames.com/post/fix_your_timestep/). Making frame rate independent games is important to ensure that your game is enjoyable and playable for everyone, no matter what type of computer they have. Games that slow down when the frame rate drops can seriously affect gameplay, making players frustrated and giving up! In addition, some systems have screens with different refresh rates, such as 120 Hz, so independence of the frame rate is important to ensure that the game does not accelerate and is impossibly fast on these devices. This step can also be used to perform some physical simulations that are not met (or should not be performed) by the roblox internal physics engine. The standard frequency for executing this step in a world is 30Hz, which can be configured when creating a world. In the tutorial topic there is a demonstration of the use of interpolation for smooth rendering display even when updating the simulation in just 10Hz ### processOut Executed once for the frame Use this step when your systems make changes to the components and these changes imply the behavior of the roblox internal physics simulations, therefore, the workspace needs to receive the update for the correct physics engine simulation ### transform Executed once per frame. Use this step for systems that react to changes made by the roblox physics engine or to perform transformations on game objects based on entity components (ECS to Workspace). Ex. In a soccer game, after running the physics engine, check if the ball touched the net, scoring a point Ex2. In a game that is not based on the roblox physics engine, perform the interpolation of objects based on the positions calculated by the specialized systems that were executed in the PROCESS step ### render Executed once per frame. Use this step to run systems that perform updates on things related to the camera and user interface. IMPORTANT! Only run light systems here, as the screen design and the processing of the next frame will only happen after the completion of this step. If it is necessary to make transformations on world objects (interpolations, complex calculations), use the TRANSFORM step ## Architectural decisions, Performance and Benchmarks Taking into account that on the Roblox platform the development of the logic of our game is done in an interpreted language, roblox-ecs-lib seeks to guarantee the maximum performance of its systems. Without reinventing the wheel, roblox-ecs-lib mirrors the implementation of the Unity Engine in the following ways: 1. Data oriented design 2. Chunk data storage 3. Efficient systems using version numbers On the other hand, roblox-ecs-lib does not bring to its implementation all that complexity that exists in the Unity ECS Engine for creating Components or Systems. The first reason that makes implementation simpler is that roblox-ecs-lib hides the complexities related to Archetypes, Chunks management and EntityManager from the developer. The second reason for simplification is the fact that Roblox's script execution is Single Thread (there are promises for parallel execution for 2021, let's wait). Although the Lua language allows the use of coroutines, these are only performaticas for processes with high Input/Output consumption, (such as http calls, disk access, etc.), for heavy processing there is no advantage in using coroutines (see https://realpython.com/python-concurrency/). A third reason is the difference in the execution flow, which in Roblox is already predefined and roblox-ecs-lib makes use of these events, in its own way, as steps for running systems. ### Data Oriented Design Data-oriented design is an approach to optimising programs by carefully considering the memory layout of data structures, and their implications for auto-vectorisation and use of the CPU cache (see [An introduction to Data Oriented Design with Rust](http://jamesmcm.github.io/blog/2020/07/25/intro-dod/)). Roblox-ecs-lib takes into account the organization of data and good practices in order to achieve the maximum performance possible in the execution of the systems. Despite running in interpreted language, many of the performance rules applicable to a C program also have an effect on a Lua program (even if in a smaller proportion). For more details see the scripts available in the `benchmark` directory **Benchmark: Struct of Arrays vs. Array of Structs** ![](docs/bench_soa.png) @TODO: more ### Chunk data storage @TODO see [The Chunk data structure in Unity](https://gametorrahod.com/the-chunk-data-structure/) ### Efficient systems using version numbers @TODO ![](docs/version.png) see [Designing an efficient system with version numbers])(https://gametorrahod.com/designing-an-efficient-system-with-version-numbers/) - https://www.youtube.com/watch?v=W3aieHjyNvw&ab_channel=GDC - https://developer.roblox.com/en-us/articles/task-scheduler - https://medium.com/@timonpost/game-networking-1-interval-and-ticks-b39bb51ccca9 - http://clintonbrennan.com/2013/12/lockstep-implementation-in-unity3d/ - https://www.youtube.com/watch?v=W5lUCeAu_2k&feature=emb_logo&ab_channel=Battle%28non%29sense - https://www.raywenderlich.com/7630142-entity-component-system-for-unity-getting-started - https://levelup.gitconnected.com/a-simple-guide-to-get-started-with-unity-ecs-b0e6a036e707 - https://www.raywenderlich.com/7630142-entity-component-system-for-unity-getting-started ================================================ FILE: docs/z_old_TUTORIAL.md ================================================ ## Roblox-ECS Tutorial - Shooting Game In this topic, we will see how to implement a simple shooting game, inspired by the [Unity ECS Tutorial - Player Shooting](https://www.youtube.com/watch?v=OQgmIHKXAdg&ab_channel=InfallibleCode) The first step in using Roblox-ECS is to install the script. In roblox studio, in the Toolbox search field, type "Roblox-ECS". Install the script in `ReplicatedStorage> ECS`. Now, let's give our character a gun, let's do it via code. Create a `LocalScript` named `tutorial` in `StarterPlayer > StarterCharacterScripts` and add the code below. ```lua repeat wait() until game.Players.LocalPlayer.Character local Players = game:GetService("Players") local Player = Players.LocalPlayer local Character = Player.Character local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local ECSUtil = require(game.ReplicatedStorage:WaitForChild("ECSUtil")) -- Our weapon local rightHand = Character:WaitForChild("RightHand") local weapon = Instance.new("Part", Character) weapon.CanCollide = false weapon.CastShadow = false weapon.Size = Vector3.new(0.2, 0.2, 2) weapon.CFrame = rightHand.CFrame + Vector3.new(0, 0, -1) weapon.Color = Color3.fromRGB(255, 0, 255) local weldWeapon = Instance.new("WeldConstraint", weapon) weldWeapon.Part0 = weapon weldWeapon.Part1 = rightHand -- weapon bullet spawn local BulletSpawnPart = Instance.new("Part", weapon) BulletSpawnPart.CanCollide = false BulletSpawnPart.CastShadow = false BulletSpawnPart.Color = Color3.fromRGB(255, 255, 0) BulletSpawnPart.Size = Vector3.new(0.6, 0.6, 0.6) BulletSpawnPart.Shape = Enum.PartType.Ball BulletSpawnPart.CFrame = weapon.CFrame + Vector3.new(0, 0, -1) local weldBulletSpawn = Instance.new("WeldConstraint", BulletSpawnPart) weldBulletSpawn.Part0 = BulletSpawnPart weldBulletSpawn.Part1 = weapon ``` In the code above we are just adding a weapon _(a cube)_ in the character's hands. We make the connection using a `WeldConstraint`, we also add a reference point to use as the initial position of the projectiles (`BulletSpawnPart`) and adjust the CFrame of the same to be on the correct side of the weapon (front). If you run the code now you will see something like the image below. ![](docs/tut_01.gif) All ok, now, to have access to the position of `BulletSpawnPart` within an ECS world, we need to obtain the Position and Rotation of the object from the Workspace and save it as a component of an entity in the ECS world Roblox-ECS already offers a generic method, some components and systems that already do this synchronization, so let's use it to create our `bulletEntity` In the script above, before the creation of our weapon, we will define our ECS world, and below, at the end of the script, we will use the Roblox-ECS utility components to synchronize the `BulletSpawnPart` position and rotation ```lua local world = ECS.CreateWorld() ECSUtil.AddDefaultSystems(world) local bulletSpawnEntity = ECSUtil.NewBasePartEntity(world, BulletSpawnPart, true, false) ``` The `ECSUtil.NewBasePartEntity` method is a facilitator that adds the `ECSUtil.BasePartComponent`, `ECSUtil.PositionComponent`, `ECSUtil.RotationComponent` components and can also add interpolation and sync tags, it has the following signature: `function ECSUtil.NewBasePartEntity(world, part, syncBasePartToEntity, syncEntityToBasePart, interpolate)`. In our case, we only want it to sync the data from `BasePart` _(workspace)_ to our `Entity` _(ECS)_. If you run the project now, you won't see any visual changes, because the systems that are running in this instance of the world don't have any logic that changes the behavior of our game yet. Now let's create our first component. Thinking about a solution that can be used both on the client and on the server, we will create our components and systems in the `ReplicatedStorage > tutorial` directory. Within this directory we can create two folders, `component` and` system`. In `ReplicatedStorage > tutorial > component`, create a `ModuleScript` with the name `WeaponComponent` and the contents below ```lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) return ECS.Component('Weapon') ``` That’s it, there’s no logic, no data typing > **Note** Roblox-ECS does not validate the data handled by the systems, it is the responsibility of the developer to pay attention to the validations Now, in our `tutorial` script, we will add this feature to our entity. Change the script by adding the code snippets below. ```lua local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local WeaponComponent = require(Components:WaitForChild("WeaponComponent")) world.Set(bulletSpawnEntity, WeaponComponent) ``` Ok. We created the world, we created an entity, we added features but nothing happened on the screen yet. This is because we only add features (components) to our entity, we have not yet defined any behavior that must be performed for those features With our components and entity defined, it's time to create our first system, let's call it `PlayerShootingSystem` For a better separation of responsibilities, we will divide our weapon system into two distinct systems, the first, `FiringSystem` will be responsible only for creating new projectiles in the workpace whenever necessary. The `PlayerShootingSystem`, which we are creating now, will be the responsible for notifying the `FiringSystem` when it is time to create new projectiles. It does this by monitoring user input and whenever the mouse button is clicked, it adds a tag component to our entity, indicating that a projectile must be created Before moving on, let's create this component now. Create a `ModuleScript` in` ReplicatedStorage > tutorial > component` with the name `FiringComponent` and add the content below ```lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) return ECS.Component('Firing', nil, true) ``` Now, going back to our system, create a `ModuleScript` in `ReplicatedStorage > tutorial > system` with the name `PlayerShootingSystem` and the content below. This system is responsible for adding the `FiringComponent` tag to the entity that has the `WeaponComponent` component whenever the mouse button is pressed. Realize that when we make changes to the data currently being processed (entity or data array), it is necessary that our `update` method returns `true`, so that Roblox-ECS can inform other systems that this chunk has been changed, using dirty parameter ```lua local UserInputService = game:GetService("UserInputService") local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) local WeaponComponent = require(Components:WaitForChild("WeaponComponent")) return ECS.RegisterSystem({ name = 'PlayerShooting', step = 'process', order = 1, requireAll = { WeaponComponent }, update = function (time, world, dirty, entity, index, weapons) local isFiring = UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) if isFiring then world.Set(entity, FiringComponent) return true end return false end }) ``` Continuing, we will now create the system responsible for creating the projectiles whenever our entity receives the tag `FiringComponent`, this will be the `FiringSystem` Create a `ModuleScript` in `ReplicatedStorage > tutorial > system` with the name `FiringSystem` and the contents below. This system is responsible only for creating 3D objects in the scene that represent our projectiles. Realize that this system does not have the `update` method, as it is only interested in knowing when an entity with the expected characteristics appears in the world. To correctly position our projectiles, this system uses data from the `ECSUtil.PositionComponent` and `ECSUtil.RotationComponent` components, which were added up there by the `ECSUtil.NewBasePartEntity` method during the creation of our entity. In order for our projectile to move, we added the `ECSUtil.MoveForwardComponent` and `ECSUtil.MoveSpeedComponent` components that are used by the `ECSUtil.MoveForwardSystem` system (Automatically added when creating the world) Also note that our system has not made any changes to the current `chunk` or even the entity, so it always returns `false` ```lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) return ECS.RegisterSystem({ name = 'Firing', step = 'process', requireAll = { ECSUtil.PositionComponent, ECSUtil.RotationComponent, FiringComponent }, onEnter = function(time, world, entity, index, positions, rotations, firings) local position = positions[index] local rotation = rotations[index] if position ~= nil and rotation ~= nil then -- can be made in a utility script, or clone a preexistece model local bulletPart = Instance.new("Part") bulletPart.Anchored = true bulletPart.CanCollide = false bulletPart.Position = position bulletPart.CastShadow = false bulletPart.Shape = Enum.PartType.Ball bulletPart.Size = Vector3.new(0.6, 0.6, 0.6) bulletPart.CFrame = CFrame.fromMatrix(position, rotation[1], rotation[2], rotation[3] * -1) bulletPart.Parent = game.Workspace local bulletEntity = ECSUtil.NewBasePartEntity(world, bulletPart, false, true) world.Set(bulletEntity, ECSUtil.MoveForwardComponent) world.Set(bulletEntity, ECSUtil.MoveSpeedComponent, 0.1) end return false end }) ``` Now, let's add our systems to the world. Change the `tutorial` script by adding the codes below. ```lua local Systems = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("system") local FiringSystem = require(Systems:WaitForChild("FiringSystem")) local PlayerShootingSystem = require(Systems:WaitForChild("PlayerShootingSystem")) world.AddSystem(FiringSystem) world.AddSystem(PlayerShootingSystem) ``` Okay, let's test our game. ![](docs/tut_01a.gif) Perfect, everything went completely well, except for one thing. We can only shoot once. Let's understand what's wrong: Our `FiringSystem` is carrying out the expected behavior, creating projectiles whenever an entity with those characteristics appears in the world, `PlayerShootingSystem` is also carrying out what we expect, whenever we use the mouse click it defines that our entity has the `FiringComponent`, however, this `FiringComponent` feature never ceases to exist, it is being added only once, so the `onEnter` method of `FiringSystem` is only invoked once. Therefore, we need to remove the entity's `FiringComponent` after some time so that the `onEnter` method can be triggered more often. To do this we will create a new system, its name will be `CleanupFiringSystem`, it will be responsible for removing the `FiringComponent` component from our entity after a period of time. In order for `CleanupFiringSystem` to do its job we need to change `FiringComponent`. It will stop being a component tag and start saving the moment of its creation, so that `CleanupFiringSystem` can validate this time and decide if it will remove it from the entity or not Let's change the `ReplicatedStorage > tutorial > component > FiringComponent.lua` script to the content below. Our component now has a constructor, used to validate the input data and is no longer a tag component ```lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) return ECS.Component('Firing', function(firedAt) if firedAt == nil then error("firedAt is required") end return firedAt end) ``` If you run the code now and try to shoot, you will see the following error in Roblox Studio's output: ```log 21:21:39.043 - ReplicatedStorage.tutorial.component.FiringComponent:5: firedAt is required 21:21:39.044 - Stack Begin 21:21:39.044 - Script 'ReplicatedStorage.tutorial.component.FiringComponent', Line 5 21:21:39.045 - Script 'ReplicatedStorage.ECS', Line 1349 21:21:39.045 - Script 'ReplicatedStorage.tutorial.system.PlayerShootingSystem', Line 22 21:21:39.045 - Script 'ReplicatedStorage.ECS', Line 1096 21:21:39.046 - Script 'ReplicatedStorage.ECS', Line 1635 21:21:39.047 - Script 'ReplicatedStorage.ECS', Line 1787 21:21:39.047 - Stack End ``` Note that `PlayerShootingSystem` is trying to add a `FiringComponent` to our entity, but the constructor method performed the validation and prevented the creation of the entity We will update the `ReplicatedStorage > tutorial > system > PlayerShootingSystem.lua` script with the change below, when adding the component, we will pass to the constructor the current frame instant (`time.frame`) ```lua if isFiring then world.Set(entity, FiringComponent, time.frame) return true end ``` Ok, now that we are correctly starting `FiringComponent` with a moment for validation, we will create `CleanupFiringSystem` Create a `ModuleScript` in `ReplicatedStorage > tutorial > system` with the name `CleanupFiringSystem` and the contents below. This system is responsible for removing the `FiringComponent` component after some time. This will allow the `FiringSystem` `onEnter` method to be invoked more often. In our implementation, we define that after `0.5` seconds the information that the shot was taken is removed from our entity, allowing it to be fired again in the sequence ```lua local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) return ECS.RegisterSystem({ name = 'CleanupFiring', step = 'process', requireAll = { FiringComponent }, update = function (time, world, dirty, entity, index, firings) local firedAt = firings[index] if firedAt ~= nil then if time.frame - firedAt < 0.5 then return false end world.Remove(entity, FiringComponent) return true end return false end }) ``` We will also change the `tutorial` script to add the new system to the world ```lua local CleanupFiringSystem = require(Systems:WaitForChild("CleanupFiringSystem")) world.AddSystem(CleanupFiringSystem) ``` OK, now we can shoot more than once, however, we still have another problem. Realize that by pressing and holding the mouse button, our weapon does not fire anymore, it is only firing if I click, wait `0.5` seconds and click again This is happening because the `update` method of `PlayerShootingSystem` is being invoked with each new frame, updating the time of the `FiringComponent` of our entity in each update (`world.Set(entity, FiringComponent, time.frame)`) , this means that the logic of `CleanupFiringSystem` is not validated, since the elapsed time (`firedAt`) never exceeds 0.5 seconds. We need to filter this behavior. Let's change the `PlayerShootingSystem` to obtain the desired behavior. We want him to add the `FiringComponent` to any entity that does not yet have this component, so he will never make changes to the data for that component. Let's change the script `ReplicatedStorage > tutorial > system > PlayerShootingSystem.lua` with the code snippet below, applying a component filter, which at the moment only has `requireAll`, we will also add the `rejectAny` field, so that the method `Update` ignore entities that already have this component. ```lua requireAll = { WeaponComponent }, rejectAny = { FiringComponent } ``` Okay, now we have the expected behavior, when pressing and holding the left mouse button, our weapon fires several projectiles respecting the interval defined in `CleanupFiringSystem` ![](docs/tut_02.gif) However, you noticed one thing: The animation of our projectile is terrible, the projectiles are teleporting from one point to another, the animation of the movement is not smooth as expected This happens due to **Fixed Timestep Jitter**, we will understand in the next topic ### Fixed Timestep Jitter In our project, the system responsible for the movement of our projectiles is `ECSUtil.MoveForwardSystem`. The `update` method of this system is invoked 30 times per second, which is the standard update frequency for the `process` step of Roblox-ECS. Therefore, even though our game is being rendered at more than 60FPS, the simulation performed by this system is limited, causing this unwanted effect in the animation To work around the problem we have two solutions: **1 - Increase the frequency of our simulation** At first glance, this seems to be the most suitable solution, just increase the frequency of our simulation to 60, 90 or 120Hz and our animation will be smooth From a technical point of view this is true, our animation will run smoothly, but in return we will be spending a lot more computational resources to run all the systems that are programmed to update in the `process` step, and that is not a good thing In addition to spending unnecessary processing resources, this will increase the battery consumption of mobile devices and, if the player's device (whether computer or cell phone) does not have enough processing power the heavy simulation will cause the FPS to drop in rendering Another problem is if you increase the frequency of the simulation on your server, which in addition to having limited processing power needs to process data from all players simultaneously, decreasing the overall quality of your game Just for the sake of experimentation, we will increase the frequency of execution of our world. Change the `tutorial` script to the following world boot configuration: ```lua local world = ECS.CreateWorld(nil, { frequency = 60 }) ``` ![](docs/tut_03.gif) Okay, you already noticed that the animation of our projectiles were smooth, but this at an expensive computational cost (and unnecessary in our case). This change causes the `process` step of the world to be performed at a frequency of 60Hz (60 times per second) This is not the best solution, let's use something more efficient **2 - Do Interpolation** Interpolation is a technique that allows, from two values ​​(A and B), to calculate a third value (C) that represents a ratio between A and B. Example: - If `A = 0` and `B = 10`, for the ratio of `0.5` the value of `C = 5` _(C is between A and B exactly 0.5)_ - If our ratio were `0.95`, the `C` value would be `9.5` In game development, we use interpolation to calculate a spatial position _(`Vector3`)_, or a rotation that is between two previously calculated values ​​_(position of the previous frame and position of the last simulation)_ using the elapsed time as a factor _(if the simulation takes 0.24 seconds and 0.12 seconds has passed since the last simulation, the factor is ~ 0.5)_ With that, we can reduce the frequency of the simulation _(heavy calculation)_, save the last two positions/rotations and apply the interpolation as we render the screen, in our case, doing this in the `transform` step _(which is running at a higher frequency, 60FPS for example)_ Roblox-ECS already offers the interpolation factor _(interpolationAlpha)_ to be used in systems that wish to apply the interpolation. It also already provides a data synchronization system between the position and rotation of the entity to update the `BasePart` through this interpolation. We will then make the changes to verify the use of interpolation and decrease the cost of processing our game. In the `tutorial` script, we will decrease the execution frequency of the world, say for 10Hz ```lua local world = ECS.CreateWorld(nil, { frequency = 10 }) ``` ![](docs/tut_04.gif) If you run the game now you will see that the animation is horrible, we will now inform you that we want to use interpolation in the entities of our projectiles. Change the `ReplicatedStorage > tutorial > system > FiringSystem.lua` script, in the line where our bulletEntity is initializing, using the utility method, modify it ```lua local bulletEntity = ECSUtil.NewBasePartEntity(world, bulletPart, false, true) ``` to ```lua local bulletEntity = ECSUtil.NewBasePartEntity(world, bulletPart, false, true, true) ``` Informing that we want an entity that receives the tags and components used by the system if interpolated synchronization. The result, as expected, is a totally smooth animation and using minimal CPU resources in the `process` step (only 10 times per second) ![](docs/tut_05.gif) And we come to the end of the tutorial, for more information on these concepts, see - [Game Loop by Robert Nystrom](http://gameprogrammingpatterns.com/game-loop.html) - [Fix Your Timestep! by Glenn Fiedler](https://gafferongames.com/post/fix_your_timestep/) - [The Game Loop By Gilles Bellot](https://bell0bytes.eu/the-game-loop/) ================================================ FILE: examples/pong/.editorconfig ================================================ root = true # Unix-style newlines with a newline ending every file [*] end_of_line = lf insert_final_newline = true [*.{lua,js,json,html,ts}] charset = utf-8 indent_size = 3 indent_style = space ================================================ FILE: examples/pong/.gitignore ================================================ # IDE .idea *.iml .vscode # Roblox Studio # place-dev.rbxl **/*.rbxl.lock **/*.rbxlx.lock **/*.blend1 # misc build ================================================ FILE: examples/pong/default.project.json ================================================ { "name": "ecs-lua-pong", "tree": { "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", "$ignoreUnknownInstances": true, "$path": "src/shared" }, "ServerScriptService": { "$className": "ServerScriptService", "$ignoreUnknownInstances": true, "$path": "src/server" }, "StarterPlayer": { "$className": "StarterPlayer", "StarterPlayerScripts": { "$className": "StarterPlayerScripts", "$ignoreUnknownInstances": true, "$path": "src/client" } } } } ================================================ FILE: examples/pong/pong.rbxlx ================================================ null nil false 0 0 Default^0^-1 RBX49A913261C9245E695B612B56BEBFFA5 0 true -500 196.199997 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 1 yuZpQdnvvUBOTYh1jqZ2cA== 0 0 0 Workspace false 0 null 0 0 -1 0 false 64 0 1024 true false 0cf7e2b366cacefc0180a4af00017037 0 0 0 1 0 0 0 1 0 0 0 1 39.099472 31.9098797 49.9157181 0.902323961 -0.183218867 0.390182436 -0 0.905172706 0.425044 -0.431058586 -0.383527398 0.81675899 null 0 70 0 38.3191071 31.0597916 48.2821999 1 0 0 0 1 0 0 0 1 true 1 Camera -1 0cf7e2b366cacefc0180a4af0001851d true -0.5 0.5 0 0 -0.5 0.5 0 0 0 -2 0 1 0 0 0 1 0 0 0 1 true true true true 0 4294506744 false -0.5 0.5 0 0 -0.5 0.5 0 0 true false 848 Ground 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 -1 -0.5 0.5 0 0 0 0cf7e2b366cacefc0180a4af00018523 0 0 0 0 1 100 4 32 1 1 1 1 Decal -1 rbxassetid://7796413907 0 0cf7e2b366cacefc0180a4af00036a2f 1 0 true -0.5 0.5 0 0 -0.5 0.5 4 0 0 0 0 1 0 0 0 1 0 0 0 1 true true true true 0 4288914085 false true -0.5 0.5 0 0 -0.5 0.5 0 0 true false 256 Terrain AgMAAAAAAAAAAAAAAAA= 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 AQU= -1 -0.5 0.5 3 0 0 0cf7e2b366cacefc0180a4af0001852b 0 0 0 0.0470588282 0.329411775 0.360784322 1 0.300000012 0.150000006 10 2044 252 2044 0 0 0 0 1 0 0 0 1 0 0 0 1 yuZpQdnvvUBOTYh1jqZ2cA== 0 0 0 Wall false null -1 0cf7e2b366cacefc0180a4af0009e4e8 0.00240516663 0.52401495 26.5799332 1 0 0 0 1 0 0 0 1 true -0.5 0.5 0 0 -0.5 0.5 0 0 0 0.5 15.5 1 0 0 0 1 0 0 0 1 true true true true 0 4294506744 false -0.5 0.5 0 0 -0.5 0.5 0 0 false false 848 back 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 -1 -0.5 0.5 0 0 0 0cf7e2b366cacefc0180a4af0009c720 0 0 0 1 1 100 1 1 true -0.5 0.5 0 0 -0.5 0.5 0 0 0 0.5 -15.5 1 0 0 0 1 0 0 0 1 true true true true 0 4294506744 false -0.5 0.5 0 0 -0.5 0.5 0 0 false false 848 front 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 -1 -0.5 0.5 0 0 0 0cf7e2b366cacefc0180a4af0009e7e9 0 0 0 1 1 100 1 1 true -0.5 0.5 0 0 -0.5 0.5 0 0 -42 3 0 -4.37113883e-08 0 1 0 1 0 -1 0 -4.37113883e-08 true true true false 0 4280374457 false -0.5 0.5 0 0 -0.5 0.5 0 0 false false 288 goal_left 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 -1 -0.5 0.5 0 0 0.5 0cf7e2b366cacefc0180a4af000ba532 0 0 0 1 30 6 12 true null false true 1 false 0 0 -1 true -1 0.5 0 0 0 0 0 INF BillboardGui null true null 0 200 0 50 0 0 -1 0 0 0 0 0 0 0241ee8883f291ef0181ac6c00d5bb66 1 false 0 0 false 0 1 1 1 1 0.105882362 0.164705887 0.207843155 2 0 false false 13 0 1 -1 TextLabel null null null null 0 0 0 0 false null 0 false null 0 200 0 50 0 -1 0 1 1 1 false 100 0.129411772 0.329411775 0.725490212 0 0 0 false 2 1 0241ee8883f291ef0181ac6c00d5bd30 true 1 true -0.5 0.5 0 0 -0.5 0.5 0 0 42 3 0 0 0 -0.99999994 0 1 0 1 0 0 true true true false 0 4294924633 false -0.5 0.5 0 0 -0.5 0.5 0 0 false false 288 goal_right 0 0 0 1 0 0 0 1 0 0 0 1 0 -0.5 0.5 0 0 0 0 0 0 -1 -0.5 0.5 0 0 0.5 0cf7e2b366cacefc0180a4af000bc996 0 0 0 1 30 6 12 true null false true 1 false 0 0 -1 true 1 0.5 0 0 0 0 0 INF BillboardGui null true null 0 200 0 50 0 0 -1 0 0 0 0 0 0 0241ee8883f291ef0181ac6c00d84e6e 1 false 0 0 false 0 1 1 1 1 0.105882362 0.164705887 0.207843155 2 0 false false 13 0 1 -1 TextLabel null null null null 0 0 0 0 false null 0 false null 0 200 0 50 0 -1 0 1 1 1 false 100 1 0.349019617 0.349019617 0 0 0 false 2 1 0241ee8883f291ef0181ac6c00d84e6f true 1 0.300000012 0 true ColorCorrection 0 5446998963 1 1 1 0241ee8883f291ef0181ac6c00b1e4f3 0 3.32999992 1 SoundService true 1 -1 0cf7e2b366cacefc0180a4af00018287 NonReplicatedCSGDictionaryService -1 0cf7e2b366cacefc0180a4af00018293 CSGDictionaryService -1 0cf7e2b366cacefc0180a4af00018294 true true Chat -1 0cf7e2b366cacefc0180a4af00018298 Instance -1 0cf7e2b366cacefc0180a4af00018299 true 30 Players 30 3 -1 0cf7e2b366cacefc0180a4af0001829b ReplicatedFirst -1 0cf7e2b366cacefc0180a4af0001829f TweenService -1 0cf7e2b366cacefc0180a4af000182a1 PermissionsService -1 0cf7e2b366cacefc0180a4af000182a5 false PlayerEmulatorService false -1 0cf7e2b366cacefc0180a4af000182a7 0 0 true false StudioData -1 7782218223 3016370887 0cf7e2b366cacefc0180a4af000182a9 true true 128 0.5 0 7.19999981 50 89 false 16 0 0 0 0 0 true 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0.95 1 0.9 1.05 0 1 0.7 1 100 true 0 StarterPlayer 100 -1 0cf7e2b366cacefc0180a4af000182aa true StarterPlayerScripts -1 0cf7e2b366cacefc0180a4af00018527 systems -1 0cf7e2b366cacefc0180a4af00020f85 CameraSystem {08DEC31C-22BE-4373-9BCD-ACD671DAEB63} -1 0cf7e2b366cacefc0180a4af00020f87 PaddleSystem {DBA66A71-31B9-4248-83DF-62C288362DAD} -1 0cf7e2b366cacefc0180a4af00020f88 BallSystem {946069F3-7725-4355-8D98-33EE940D19E7} ballMaxZ or posValue.Z < -ballMaxZ then -- Reverse z velocity if ball hits a vertical wall local v = velocity.value velocity.value = Vector3.new(v.X, v.Y, v.Z*-1) if posValue.Z > ballMaxZ then posValue = Vector3.new(posValue.X, posValue.Y, ballMaxZ) else posValue = Vector3.new(posValue.X, posValue.Y, -ballMaxZ ) end position.value = posValue -- sound effect self._world:Entity( Position(posValue), AudioSource({ clip = "rbxassetid://4458219865" }) ) end end) end function BallSystem:OnEnter(Time, entity) local radius = Constants.BALL_RADIUS local size = radius*2 local part = Instance.new("Part") part.Name = "Ball" part.Anchored = true part.Size = Vector3.new(size, size, size) part.Shape = Enum.PartType.Ball part.Color = Color3.fromRGB(255, 255, 255) part.Material = Enum.Material.Neon part.Parent = game.Workspace entity[BasePart] = BasePart(part) local speed = Constants.BALL_SERVE_SPEED local position = Vector3.new(0, radius, 0) local ball = entity[Ball] if ball.initialDirection then position = Vector3.new(-ballMaxX, radius, 0) if ball.initialDirection == "left" then speed = speed * -1 position = Vector3.new(ballMaxX, radius, 0) end else if math.random() > 0.5 then speed = speed * -1 end end entity[Position] = Position(position) entity[Velocity] = Velocity(Vector3.new(speed, 0, 0)) -- sound effect self._world:Entity( Position(entity[Position].value), AudioSource({ clip = "rbxassetid://1837831535" }) ) end function BallSystem:OnRemove(Time, entity) local part = entity[BasePart].value part.Parent = nil entity[BasePart] = nil end return BallSystem ]]> -1 0cf7e2b366cacefc0180a4af00020f89 RenderSystem {B233C6E5-CCF2-40CD-BBBF-D2D51360A2CF} -1 0cf7e2b366cacefc0180a4af004bf7ed MoveSystem {636336A1-7EF8-466C-92B0-AA4F4DEDC877} -1 0cf7e2b366cacefc0180a4af004bf7ef PlayerAiThinkSystem {69DFADC4-95AD-4491-B9D0-3F56B7267695} 0 else ballTowardsAI = ballVel.X < 0 end if ballTowardsAI then if tgBallPos == nil then tgBallPos = ballPos else -- o alvo é a bola que está mais proxima da raquete if paddle.side == "right" then if ballPos.X > tgBallPos.X then tgBallPos = ballPos end else if ballPos.X < tgBallPos.X then tgBallPos = ballPos end end end end end) if tgBallPos then paddle.target = Utility.map(tgBallPos.Z, -ballMaxZ, ballMaxZ, -1, 1) end end return PlayerAiThinkSystem ]]> -1 0241ee8883f291ef0181ac6c00b861f3 PlayerHumanInputSystem {9422572F-580D-465B-B4AC-A422E0CA0AC7} -1 0241ee8883f291ef0181ac6c00b9a278 PaddleHitSystem {866E63DF-FD3E-4F66-B58A-A6AE8447898B} 0.6) then local boost = (1 + angleAbs * Constants.BALL_BOOST) ballVelX = ballVelX * boost ballVelZ = ballVelZ * boost end -- Determine the direction in which the ball should go if paddle.side == "right" then ballVelX = ballVelX*-1 end ettBall[Velocity].value = Vector3.new(ballVelX, 0, ballVelZ) end local function intersects(ettBall, ettPaddle) local ball = ettBall[Ball] local ballPos = ettBall[Position].value local paddlePos = ettPaddle[Position].value -- circle local cx, cz, radius = ballPos.X, ballPos.Z, Constants.BALL_RADIUS -- rectangle local rw, rh = Constants.PADDLE_WIDTH, Constants.PADDLE_HEIGHT local rx, rz = paddlePos.X - rw/2, paddlePos.Z - rh/2 -- temporary variables to set edges for testing local testX = cx local testZ = cz local xEdge, zEdge -- which edge is closest? if cx < rx then testX = rx xEdge = "left" elseif cx > rx + rw then testX = rx+rw xEdge = "right" end if cz < rz then testZ = rz zEdge = "top" elseif cz > rz+rh then testZ = rz+rh zEdge = "bottom" end -- get distance from closest edges local distX = cx-testX local distY = cz-testZ local distance = math.sqrt( (distX*distX) + (distY*distY) ); -- if the distance is less than the radius, collision! if (distance <= radius) then local normal if distY < distX then normal = (zEdge == "top") and Vector3.new(0, 0, 1) or Vector3.new(0, 0, -1) else normal = (xEdge == "left") and Vector3.new(1, 0, 0) or Vector3.new(-1, 0, 0) end return { normal = normal, distance = distance } end return nil end local PaddleHitSystem = ECS.System("transform", 2, ECS.Query.All(Paddle, Position)) function PaddleHitSystem:Initialize(Time) self.queryBalls = ECS.Query.All(Ball, Position).Build() end function PaddleHitSystem:Update(Time) local ettsBall = self:Result(self.queryBalls):ToArray() local ballSpawned = false self:Result():ForEach(function(ettPaddle) local paddle = ettPaddle[Paddle] local pPosition = ettPaddle[Position] -- collision detection for i,ettBall in ipairs(ettsBall) do local collistion = intersects(ettBall, ettPaddle) if collistion then -- move the ball out of the paddle local ballPos = ettBall[Position].value ettBall[Position].value = ballPos - collistion.normal * collistion.distance computeBounce(ettBall, ettPaddle) if #ettsBall < 2 then paddle.hits = paddle.hits + 1 if paddle.hits == 5 then -- create new ball local inverseDirection = (paddle.side == "left") and "right" or "left" self._world:Entity(Ball({ initialDirection = inverseDirection, secondary = true })) ballSpawned = true end end -- sound effect self._world:Entity( Position(ballPos), AudioSource({ clip = "rbxassetid://4458219865" }) ) -- break return true end end end) if ballSpawned then self:Result():ForEach(function(ettPaddle) local paddle = ettPaddle[Paddle] paddle.hits = 0 end) end end return PaddleHitSystem ]]> -1 0241ee8883f291ef0181ac6c00bec1b2 ScoreSystem {2D906FD8-0371-482B-AEE6-C5E1345EC563} ballMaxX ) then ettScored = entity end if ettScored then score.value = score.value + 1 score.TextLabel.Text = tostring(score.value) -- sound effect self._world:Entity( Position(ballPos), AudioSource({ clip = "rbxassetid://1843023345" }) ) -- break return true end end end) if ettScored then -- remove all balls for i,ettBall in ipairs(balls) do self._world:Remove(ettBall) end -- create new ball self._world:Entity(Ball()) self:Result(self.queryPlayers):ForEach(function(entity) local paddle = entity[Paddle] paddle.hits = 0 end) end end function ScoreSystem:OnEnter(Time, entity) local score = entity[Score] local paddle = entity[Paddle] score.goalPart = game.Workspace:FindFirstChild("goal_"..paddle.side) score.TextLabel = score.goalPart.BillboardGui.TextLabel end return ScoreSystem ]]> -1 0241ee8883f291ef0181ac6c00daebbb AudioSystem {A78EFF7B-3F47-4101-831B-675BD9A94285} -1 0241ee8883f291ef0181ac6c00e676fb false Main {2ACF1CF4-EF68-499C-AE31-8AD922804366} -1 0cf7e2b366cacefc0180a4af00020f8b Constants {04D89E38-9D37-4F2F-8910-F2E140D26783} -1 0cf7e2b366cacefc0180a4af00020f8d components -1 0cf7e2b366cacefc0180a4af00020f8f Paddle {DB5C058C-4CD6-47E1-9B65-9238527C3456} -1 0cf7e2b366cacefc0180a4af00020f90 Player {9628A125-E3F1-4B5A-88B3-651243F4A1D7} -1 0cf7e2b366cacefc0180a4af00020f91 Ball {C3E028B4-B3ED-4CA4-B0CF-3627BF8A161E} -1 0cf7e2b366cacefc0180a4af00020f92 Position {D1094A76-FF25-4254-AFF9-1BFDB04A9B53} -1 0cf7e2b366cacefc0180a4af0049760e Velocity {C97B20B5-D53E-4CAF-B3ED-563B8C17198F} -1 0cf7e2b366cacefc0180a4af00497610 BasePart {CD22ED03-3A99-400E-BC06-5E293A575342} -1 0cf7e2b366cacefc0180a4af004bf7ee Score {87C823D7-7403-4393-BD3C-70DBC899E138} -1 0241ee8883f291ef0181ac6c00dae7b8 AudioSource {3E5DF6F2-A6F1-452B-8EE0-B53709BB312E} -1 0241ee8883f291ef0181ac6c00e676fd Utility {37D1623F-A591-4656-A23A-20C1ADD07B85} -1 0cf7e2b366cacefc0180a4af00020f94 StarterCharacterScripts -1 0cf7e2b366cacefc0180a4af00018526 StarterPack -1 0cf7e2b366cacefc0180a4af000182ab StarterGui true 4 true -1 0cf7e2b366cacefc0180a4af000182ac 0 LocalizationService -1 0cf7e2b366cacefc0180a4af000182ae Teleport Service -1 0cf7e2b366cacefc0180a4af000182b2 CollectionService -1 0cf7e2b366cacefc0180a4af000182b4 PhysicsService -1 0cf7e2b366cacefc0180a4af000182b5 Geometry -1 0cf7e2b366cacefc0180a4af000182b7 false false InsertService -1 0cf7e2b366cacefc0180a4af000182b9 InsertionHash -1 0cf7e2b366cacefc0180a4af00018528 {C3FFDE2F-3CE5-41A4-B5E5-08BC30C81031} GamePassService -1 0cf7e2b366cacefc0180a4af000182ba 1000 Debris -1 0cf7e2b366cacefc0180a4af000182bb CookiesService -1 0cf7e2b366cacefc0180a4af000182bc VRService -1 0cf7e2b366cacefc0180a4af000182c6 ContextActionService -1 0cf7e2b366cacefc0180a4af000182c7 Instance -1 0cf7e2b366cacefc0180a4af000182c9 AssetService -1 0cf7e2b366cacefc0180a4af000182ca TouchInputService -1 0cf7e2b366cacefc0180a4af000182cb AnalyticsService -1 0cf7e2b366cacefc0180a4af000182ce Selection -1 0cf7e2b366cacefc0180a4af000182d1 false ServerScriptService -1 0cf7e2b366cacefc0180a4af000182da false Main {370D60B7-C5E9-4154-9170-329D73840A64} -1 0cf7e2b366cacefc0180a4af00020f95 ServerStorage -1 0cf7e2b366cacefc0180a4af000182db ReplicatedStorage -1 0cf7e2b366cacefc0180a4af000182dc ECS {DB0B7A5D-F8B8-4482-A6C9-2A8342B54807} 0)then local q={}for r,s in ipairs(n)do local t=s:GetType()table.insert(q,t)p[t]=s end o=d.Of(q)end e=e+1 return setmetatable({_data=p,_onChange=m,id=e,isAlive=false,archetype=o,Get=f,Set=h,Unset=i,GetAll=j,GetAny=k,},l)end return l end b["EntityRepository"]=function()local d=c("Event")local e={}e.__index=e function e.New()return setmetatable({_archetypes={},_entitiesArchetype={},},e)end function e:Insert(f)if(self._entitiesArchetype[f]==nil)then local g=f.archetype local h=self._archetypes[g]if(h==nil)then h={count=0,entities={}}self._archetypes[g]=h end h.entities[f]=true h.count=h.count+1 self._entitiesArchetype[f]=g else self:Update(f)end end function e:Remove(f)local g=self._entitiesArchetype[f]if g==nil then return end self._entitiesArchetype[f]=nil local h=self._archetypes[g]if(h~=nil and h.entities[f]==true)then h.entities[f]=nil h.count=h.count-1 if(h.count==0)then self._archetypes[g]=nil end end end function e:Update(f)local g=self._entitiesArchetype[f]if(g==nil or g==f.archetype)then return end self:Remove(f)self:Insert(f)end function e:Query(f)local g={}for h,i in pairs(self._archetypes)do if f:Match(h)then table.insert(g,i.entities)end end return f:Result(g),#g>0 end function e:FastCheck(f)for g,h in pairs(self._archetypes)do if f:Match(g)then return true end end return false end return e end b["Event"]=function()local d={}d.__index=d function d.New(f,g)return setmetatable({_event=f,_handler=g},d)end function d:Disconnect()local f=self._event if(f and not f.destroyed)then local g=table.find(f._handlers,self._handler)if g~=nil then table.remove(f._handlers,g)end end setmetatable(self,nil)end local e={}e.__index=e function e.New()return setmetatable({_handlers={}},e)end function e:Connect(f)if(type(f)=="function")then table.insert(self._handlers,f)return d.New(self,f)end error(("Event:Connect(%s)"):format(typeof(f)),2)end function e:Fire(...)if not self.destroyed then for f,g in ipairs(self._handlers)do g(table.unpack({...}))end end end function e:Destroy()setmetatable(self,nil)self._handlers=nil self.destroyed=true end return e end b["Query"]=function()local d=c("QueryResult")local e={}local f={}f.__index=f setmetatable(f,{__call=function(i,j,k,l)return f.New(j,k,l)end,})local function g(i,j,k)local l={}local m={}local n={}for o,p in ipairs(i)do if(l[p]==nil)then if(p.IsCType and not p.isComponent)then l[p]=true table.insert(m,p)table.insert(n,p.Id)else if p.Filter then l[p]=true p[j]=true table.insert(k,p)end end end end if#m>0 then table.sort(n)local o="_"..table.concat(n,"_")return m,o end end function f.New(i,j,k)local l={}local m,n,o if(j~=nil)then j,m=g(j,"IsAnyFilter",l)end if(i~=nil)then i,n=g(i,"IsAllFilter",l)end if(k~=nil)then k,o=g(k,"IsNoneFilter",l)end return setmetatable({isQuery=true,_any=j,_all=i,_none=k,_anyKey=m,_allKey=n,_noneKey=o,_cache={},_clauses=#l>0 and l or nil},f)end function f:Result(i)return d.New(i,self._clauses)end function f:Match(i)local j=self._cache local k=j[i]if k~=nil then return k else local l=e[i]if(l==nil)then l={Any={},All={},None={}}e[i]=l end local m=self._noneKey if m then local p=l.None[m]if(p==nil)then p=true for q,r in ipairs(self._none)do if i:Has(r)then p=false break end end l.None[m]=p end if(p==false)then j[i]=false return false end end local n=self._anyKey if n then local p=l.Any[n]if(p==nil)then p=false if(l.All[n]==true)then p=true else for q,r in ipairs(self._any)do if i:Has(r)then p=true break end end end l.Any[n]=p end if(p==false)then j[i]=false return false end end local o=self._allKey if o then local p=l.All[o]if(p==nil)then local q=true for r,s in ipairs(self._all)do if(not i:Has(s))then q=false break end end if q then p=true else p=false end l.All[o]=p end j[i]=p return p end j[i]=true return true end end local function h()local i={isQueryBuilder=true}local j function i.All(...)j=nil i._all={...}return i end function i.Any(...)j=nil i._any={...}return i end function i.None(...)j=nil i._none={...}return i end function i.Build()if j==nil then j=f.New(i._all,i._any,i._none)end return j end return i end function f.All(...)return h().All(...)end function f.Any(...)return h().Any(...)end function f.None(...)return h().None(...)end function f.Filter(i)return function(j)return{Filter=i,Config=j}end end return f end b["QueryResult"]=function()local function d(l,m,n)return m,(l(m)==true),true end local function e(l,m,n)return l(m),true,true end local function f(l,m,n)local o=(n<=l)return m,o,o end local function g(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=false break end end return m,o,true end local function h(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==false)then o=false break end end return m,o,true end local function i(l,m,n)local o=false for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=true break end end return m,o,true end local j={}local k={}k.__index=k function k.New(l,m)local n=j if(m and#m>0)then local o={}local p={}local q={}n={}for r,s in ipairs(m)do if s.IsNoneFilter then table.insert(q,s)elseif s.IsAnyFilter then table.insert(p,s)else table.insert(o,s)end end if(#q>0)then table.insert(n,{g,q})end if(#o>0)then table.insert(n,{h,o})end if(#p>0)then table.insert(n,{i,p})end end return setmetatable({chunks=l,_pipeline=n,},k)end function k:With(l,m)local n={}for o,p in ipairs(self._pipeline)do table.insert(n,p)end table.insert(n,{l,m})return setmetatable({chunks=self.chunks,_pipeline=n,},k)end function k:Filter(l)return self:With(d,l)end function k:Map(l)return self:With(e,l)end function k:Limit(l)return self:With(f,l)end function k:AnyMatch(l)local m=false self:ForEach(function(n)if l(n)then m=true end return m end)return m end function k:AllMatch(l)local m=true self:ForEach(function(n)if(not l(n))then m=false end return m==false end)return m end function k:FindAny()local l self:ForEach(function(m)l=m return true end)return l end function k:ToArray()local l={}self:ForEach(function(m)table.insert(l,m)end)return l end function k:Iterator()local l=coroutine.create(function()self:ForEach(function(m,n)coroutine.yield(m,n)end)end)return function()local m,n,o=coroutine.resume(l)return o,n end end function k:ForEach(l)local m=1 local n=self._pipeline local o=#n>0 if(not o)then for p,q in ipairs(self.chunks)do for r,s in pairs(q)do if(l(r,m)==true)then return end m=m+1 end end else for p,q in ipairs(self.chunks)do for r,s in pairs(q)do local t=false local u=true local v=r if(u and o)then for w,x in ipairs(n)do local y,z,A=x[1](x[2],v,m)if(not A)then t=true end if z then v=y else u=false break end end end if u then if(l(v,m)==true)then return end m=m+1 end if t then return end end end end end return k end b["RobloxLoopManager"]=function()local function d()local e=game:GetService("RunService")return{Register=function(f)local g=e.Stepped:Connect(function()f:Update("process",os.clock())end)local h=e.Heartbeat:Connect(function()f:Update("transform",os.clock())end)local i if(not e:IsServer())then i=e.RenderStepped:Connect(function()f:Update("render",os.clock())end)end return function()g:Disconnect()h:Disconnect()if i then i:Disconnect()end end end}end return d end b["System"]=function()local d={"task","render","process","transform"}local e={}function e.Create(f,g,h,i)if(f==nil or not table.find(d,f))then error("The step parameter must one of ",table.concat(d,", "))end if(g and type(g)=="function")then i=g g=nil elseif h and type(h)=="function"then i=h h=nil end if(g and type(g)=="table"and(g.isQuery or g.isQueryBuilder))then h=g g=nil end if(g==nil or g<0)then g=50 end if type(h)=="function"then i=h h=nil end if(h and h.isQueryBuilder)then h=h.Build()end local j={Step=f,Order=g,Query=h,}j.__index=j function j.New(k,l)local m=setmetatable({version=0,_world=k,_config=l,},j)if m.Initialize then m:Initialize(l)end return m end function j:GetType()return j end function j:Result(k)return self._world:Exec(k or j.Query)end function j:Publish(k,l)end function j:Receive(k)end function j:Destroy()if self.OnDestroy then self.OnDestroy()end setmetatable(self,nil)for k,l in pairs(self)do self[k]=nil end end if i and type(i)=="function"then j.Update=i end return j end return e end b["SystemExecutor"]=function()local function d(i)local j={}local k={}for l,m in ipairs(i)do local n=m:GetType()if(m._TaskState==nil)then m._TaskState="suspended"end if not k[n]then local o={Type=n,System=m,Depends={}}k[n]=o table.insert(j,o)end end for l,m in ipairs(j)do local n=m.Type.Before if(n~=nil and#n>0)then for p,q in ipairs(n)do local r=k[q]if r then r.Depends[m]=true end end end local o=m.Type.After if(o~=nil and#o>0)then for p,q in ipairs(o)do local r=k[q]if r then m.Depends[r]=true end end end end return j end local function e(i,j)return i.Order0 do local j=false local k,l=0,#self._schedulers-1 while k<=l do k=k+1 local m=self._schedulers[k]local n,o=m.Resume(i)if o then j=true end i=i-(n+0.00001)if(i<=0)then break end end if not j then return end end end local function h(i,j,k,l)local m=i.System m._TaskState="running"if(m.ShouldUpdate==nil or m.ShouldUpdate(j))then k.version=k.version+1 m:Update(j)m.version=k.version end m._TaskState="suspended"l(i)end function f:ScheduleTasks(i)local j=self._world local k={}local l={}local m={}local n={}local o={}local p,q=0,#self._task-1 while p<=q do p=p+1 local s=self._task[p]if(s.System._TaskState=="suspended")then s.System._TaskState="scheduled"local t=false for u,v in pairs(s.Depends)do t=true if o[u]==nil then o[u]={}end table.insert(o[u],s)end if(not t)then table.insert(k,s)end m[s]=true end end local function r(s)s.Thread=nil s.LastExecTime=nil n[s]=true if o[s]then local t=o[s]local u,v=0,#t-1 while u<=v do u=u+1 local w=t[u]if m[w]then local x=true for y,z in pairs(w.Depends)do if n[y]~=true then x=false break end end if x then m[w]=nil w.LastExecTime=0 w.Thread=coroutine.create(h)table.insert(l,w)end end end end end if#k>0 then local s,t=0,#k-1 while s<=t do s=s+1 local v=k[s]m[v]=nil v.LastExecTime=0 v.Thread=coroutine.create(h)table.insert(l,v)end local u u={Resume=function(v)table.sort(l,function(A,B)return A.LastExecTimev)then break end end end for A,B in ipairs(l)do if B.Thread==nil then local C=table.find(l,B)if C~=nil then table.remove(l,C)end end end local z=#l>0 if(not z)then local A=table.find(self._schedulers,u)if A~=nil then table.remove(self._schedulers,A)end end return w,z end}table.insert(self._schedulers,u)end end return f end b["Timer"]=function()local d=4 local function e(g)local h=0.0 local i=0.0 return function(j,k,l,m)local n=g.DeltaFixed local o=j-i if o>0.25 then o=0.25 end i=j g.Now=j h=h+o if k=="process"then if h>=n then g.Interpolation=1 l(g)local p=0 while(h>=n and p -1 0cf7e2b366cacefc0180a4af00020f8c Instance -1 0cf7e2b366cacefc0180a4af000182e4 ProcessInstancePhysicsService -1 0cf7e2b366cacefc0180a4af000182e8 0.274509817 0.274509817 0.274509817 3 0 0 0 0 0 0 1 1 0 0.752941251 0.752941251 0.752941251 100000 0 0 true Lighting 0.274509817 0.274509817 0.274509817 false 0.200000003 -1 3 14:30:00 0cf7e2b366cacefc0180a4af00018303 true 0 SunRays -1 0.100000001 0cf7e2b366cacefc0180a4af00018529 1 1 1 0 0.768627524 1 0 0 0 Atmosphere 0 -1 0cf7e2b366cacefc0180a4af0001851b true 1 Bloom 24 -1 2 0cf7e2b366cacefc0180a4af0001851c false 0.100000001 0.0500000007 30 DepthOfField 0.75 -1 0cf7e2b366cacefc0180a4af00018520 false 11 rbxasset://sky/moon.jpg Sky rbxassetid://1194117595 rbxassetid://724361046 rbxassetid://1194117232 rbxassetid://1194117595 rbxassetid://1194117232 rbxassetid://1194119030 5446998963 3000 21 rbxasset://sky/sun.jpg 0241ee8883f291ef0181ac6c00b1e4f4 true false DataStoreService -1 0cf7e2b366cacefc0180a4af0001851e false HttpService -1 0cf7e2b366cacefc0180a4af00018521 LanguageService -1 0cf7e2b366cacefc0180a4af00018522 Teams -1 0cf7e2b366cacefc0180a4af0001852a true false true true true TestService 0 0 -1 10 0cf7e2b366cacefc0180a4af0001852c VirtualInputManager -1 0cf7e2b366cacefc0180a4af0001852e true 16 ProximityPromptService -1 0cf7e2b366cacefc0180a4af00020ca6 ================================================ FILE: examples/pong/src/client/Constants.lua ================================================ local BALL_SPEED = 50 local constants = { BALL_BOOST = 0.3, BALL_RADIUS = 1, BALL_SPEED = BALL_SPEED, BALL_SERVE_SPEED = BALL_SPEED*0.7, BALL_SPEED_SECONDARY = BALL_SPEED*0.5, COURT_WIDTH = 72, COURT_HEIGHT = 30, PADDLE_WIDTH = 1, PADDLE_HEIGHT = 10, PLAYER_SPEED = 5, CAMERA_DISTANCE = 30 } return constants ================================================ FILE: examples/pong/src/client/Main.client.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local Constants = require(script.Parent:WaitForChild("Constants")) local Components = script.Parent:WaitForChild("components") local Ball = require(Components.Ball) local Score = require(Components.Score) local Paddle = require(Components.Paddle) local Player = require(Components.Player) local PlayerAI = Player.Qualifier("AI") local PlayerHuman = Player.Qualifier("Human") local Systems = script.Parent.systems local MoveSystem = require(Systems.MoveSystem) local BallSystem = require(Systems.BallSystem) local AudioSystem = require(Systems.AudioSystem) local ScoreSystem = require(Systems.ScoreSystem) local PaddleSystem = require(Systems.PaddleSystem) local PaddleHitSystem = require(Systems.PaddleHitSystem) local PlayerAiThinkSystem = require(Systems.PlayerAiThinkSystem) local PlayerHumanInputSystem = require(Systems.PlayerHumanInputSystem) local RenderSystem = require(Systems.RenderSystem) local CameraSystem = require(Systems.CameraSystem) local world = ECS.World({ MoveSystem, BallSystem, AudioSystem, ScoreSystem, PaddleSystem, PaddleHitSystem, PlayerAiThinkSystem, PlayerHumanInputSystem, CameraSystem, RenderSystem, }, 60) -- Ball world:Entity(Ball()) -- Player world:Entity( Score(), PlayerHuman(), Paddle({ side = "left" }) ) -- AI world:Entity( Score(), PlayerAI(), Paddle({ side = "right" }) ) ================================================ FILE: examples/pong/src/client/Utility.lua ================================================ local Utility = {} function Utility.map(x, inMin, inMax, outMin, outMax) return (x - inMin)*(outMax - outMin)/(inMax - inMin) + outMin end function Utility.lerp(v0, v1, t) return (1-t)*v0 + t*v1 end return Utility ================================================ FILE: examples/pong/src/client/components/AudioSource.lua ================================================ local AudioSource = _G.ECS.Component({ clip = "", -- sound asset volume = 10, -- playback volume between [0..10] loop = false, -- If true, the audio clip replays when it ends sound = nil }) AudioSource.States = { ["Playing"] = { "Stopped" }, ["Stopped"] = { "Playing" } } AudioSource.StateInitial = "Stopped" AudioSource.Case = { Playing = function(self, previous) if self.sound then self.sound:Play() end end, Stopped = function(self, previous) if self.sound then self.sound:Stop() end end } return AudioSource ================================================ FILE: examples/pong/src/client/components/Ball.lua ================================================ local Ball = _G.ECS.Component({ secondary = false, initialDirection = nil, }) return Ball ================================================ FILE: examples/pong/src/client/components/BasePart.lua ================================================ local BasePart = _G.ECS.Component() return BasePart ================================================ FILE: examples/pong/src/client/components/Paddle.lua ================================================ local Paddle = _G.ECS.Component({ side = "left", hits = 0, target = 0, -- -1 = bottom, 0 = middle, 1 = top position = 0 -- -1 = bottom, 0 = middle, 1 = top }) return Paddle ================================================ FILE: examples/pong/src/client/components/Player.lua ================================================ local Player = _G.ECS.Component() local PlayerAI = Player.Qualifier("AI") local PlayerHuman = Player.Qualifier("Human") return Player ================================================ FILE: examples/pong/src/client/components/Position.lua ================================================ local Position = _G.ECS.Component(Vector3.new(0, 0, 0)) return Position ================================================ FILE: examples/pong/src/client/components/Score.lua ================================================ local Score = _G.ECS.Component(0) return Score ================================================ FILE: examples/pong/src/client/components/Velocity.lua ================================================ local Velocity = _G.ECS.Component(Vector3.new(0, 0, 0)) return Velocity ================================================ FILE: examples/pong/src/client/systems/AudioSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Components = Client.components local BasePart = require(Components.BasePart) local Position = require(Components.Position) local AudioSource = require(Components.AudioSource) local AudioSystem = ECS.System("transform", 100, ECS.Query.All(AudioSource)) function AudioSystem:Initialize(Time) self.queryStopped = ECS.Query.All(AudioSource, AudioSource.In("Stopped")).Build() end function AudioSystem:Update(Time) self:Result(self.queryStopped):ForEach(function(entity) self._world:Remove(entity) end) end function AudioSystem:OnEnter(Time, entity) local source = entity[AudioSource] local position = entity[Position] -- create a sound local sound = Instance.new("Sound") sound.SoundId = source.clip sound.Looped = source.loop source.sound = sound if position then -- create a part local part = Instance.new("Part") part.Anchored = true part.CanCollide = false part.Transparency = 1 part.Position = position.value part.Name = "sound#"..source.clip sound.Parent = part part.Parent = game.Workspace entity[BasePart] = BasePart(part) end sound.Ended:Connect(function() source:SetState("Stopped") end) source:SetState("Playing") end function AudioSystem:OnExit(Time, entity) local part = entity[BasePart] if part then part.value.Parent = nil end end function AudioSystem:OnRemove(Time, entity) local part = entity[BasePart] if part then part.value.Parent = nil end end return AudioSystem ================================================ FILE: examples/pong/src/client/systems/BallSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Constants = require(Client.Constants) local Components = Client.components local Ball = require(Components.Ball) local Position = require(Components.Position) local Velocity = require(Components.Velocity) local BasePart = require(Components.BasePart) local AudioSource = require(Components.AudioSource) local ballMaxX = Constants.COURT_WIDTH/2 local ballMaxZ = Constants.COURT_HEIGHT/2 - Constants.BALL_RADIUS local BallSystem = ECS.System("transform", 1, ECS.Query.All(Ball)) function BallSystem:Update(Time) local scored = false local scoredSide self:Result():ForEach(function(entity) local ball = entity[Ball] local position = entity[Position] local velocity = entity[Velocity] local posValue = position.value if posValue.Z > ballMaxZ or posValue.Z < -ballMaxZ then -- Reverse z velocity if ball hits a vertical wall local v = velocity.value velocity.value = Vector3.new(v.X, v.Y, v.Z*-1) if posValue.Z > ballMaxZ then posValue = Vector3.new(posValue.X, posValue.Y, ballMaxZ) else posValue = Vector3.new(posValue.X, posValue.Y, -ballMaxZ ) end position.value = posValue -- sound effect self._world:Entity( Position(posValue), AudioSource({ clip = "rbxassetid://4458219865" }) ) end end) end function BallSystem:OnEnter(Time, entity) local radius = Constants.BALL_RADIUS local size = radius*2 local part = Instance.new("Part") part.Name = "Ball" part.Anchored = true part.Size = Vector3.new(size, size, size) part.Shape = Enum.PartType.Ball part.Color = Color3.fromRGB(255, 255, 255) part.Material = Enum.Material.Neon part.Parent = game.Workspace entity[BasePart] = BasePart(part) local speed = Constants.BALL_SERVE_SPEED local position = Vector3.new(0, radius, 0) local ball = entity[Ball] if ball.initialDirection then position = Vector3.new(-ballMaxX, radius, 0) if ball.initialDirection == "left" then speed = speed * -1 position = Vector3.new(ballMaxX, radius, 0) end else if math.random() > 0.5 then speed = speed * -1 end end entity[Position] = Position(position) entity[Velocity] = Velocity(Vector3.new(speed, 0, 0)) -- sound effect self._world:Entity( Position(entity[Position].value), AudioSource({ clip = "rbxassetid://1837831535" }) ) end function BallSystem:OnRemove(Time, entity) local part = entity[BasePart].value part.Parent = nil entity[BasePart] = nil end return BallSystem ================================================ FILE: examples/pong/src/client/systems/CameraSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Constants = require(Client.Constants) local CFRAME = CFrame.new(Vector3.new(0, Constants.CAMERA_DISTANCE, 30), Vector3.new(0, 0, 0)) local CameraSystem = ECS.System("render", 1, function() game.Workspace.CurrentCamera.CFrame = CFRAME end) return CameraSystem ================================================ FILE: examples/pong/src/client/systems/MoveSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Components = Client.components local Velocity = require(Components.Velocity) local Position = require(Components.Position) local MoveSystem = ECS.System("process", 10, ECS.Query.All(Position, Velocity)) function MoveSystem:Update(Time) self:Result(self.queryBalls):ForEach(function(entity) local position = entity[Position] local velocity = entity[Velocity] -- interpolation -- position.valueOld = position.value position.value = position.value + velocity.value * Time.DeltaFixed end) end return MoveSystem ================================================ FILE: examples/pong/src/client/systems/PaddleHitSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Utility = require(Client.Utility) local Constants = require(Client.Constants) local Components = Client.components local Ball = require(Components.Ball) local Paddle = require(Components.Paddle) local Position = require(Components.Position) local Velocity = require(Components.Velocity) local AudioSource = require(Components.AudioSource) local PADDLE_AIM_C = 1.5 -- compute outgoing angle depending on which point the ball hits the paddle local function computeBounce(ettBall, ettPaddle) local ball = ettBall[Ball] local ballPos = ettBall[Position].value local ballVel = ettBall[Velocity].value local paddle = ettPaddle[Paddle] local paddlePos = ettPaddle[Position].value -- The sharpness of the angle is determined by where the ball hits the paddle local angle = PADDLE_AIM_C * (ballPos.Z - paddlePos.Z)/Constants.PADDLE_HEIGHT local spped = ball.secondary and Constants.BALL_SPEED_SECONDARY or Constants.BALL_SPEED local ballVelZ = math.sin(angle) * spped local ballVelX = math.cos(angle) * spped -- if the angle exceeds a magic value, the ball gets an extra speed boost local angleAbs = math.abs(angle) if (angleAbs > 0.6) then local boost = (1 + angleAbs * Constants.BALL_BOOST) ballVelX = ballVelX * boost ballVelZ = ballVelZ * boost end -- Determine the direction in which the ball should go if paddle.side == "right" then ballVelX = ballVelX*-1 end ettBall[Velocity].value = Vector3.new(ballVelX, 0, ballVelZ) end local function intersects(ettBall, ettPaddle) local ball = ettBall[Ball] local ballPos = ettBall[Position].value local paddlePos = ettPaddle[Position].value -- circle local cx, cz, radius = ballPos.X, ballPos.Z, Constants.BALL_RADIUS -- rectangle local rw, rh = Constants.PADDLE_WIDTH, Constants.PADDLE_HEIGHT local rx, rz = paddlePos.X - rw/2, paddlePos.Z - rh/2 -- temporary variables to set edges for testing local testX = cx local testZ = cz local xEdge, zEdge -- which edge is closest? if cx < rx then testX = rx xEdge = "left" elseif cx > rx + rw then testX = rx+rw xEdge = "right" end if cz < rz then testZ = rz zEdge = "top" elseif cz > rz+rh then testZ = rz+rh zEdge = "bottom" end -- get distance from closest edges local distX = cx-testX local distY = cz-testZ local distance = math.sqrt( (distX*distX) + (distY*distY) ); -- if the distance is less than the radius, collision! if (distance <= radius) then local normal if distY < distX then normal = (zEdge == "top") and Vector3.new(0, 0, 1) or Vector3.new(0, 0, -1) else normal = (xEdge == "left") and Vector3.new(1, 0, 0) or Vector3.new(-1, 0, 0) end return { normal = normal, distance = distance } end return nil end local PaddleHitSystem = ECS.System("transform", 2, ECS.Query.All(Paddle, Position)) function PaddleHitSystem:Initialize(Time) self.queryBalls = ECS.Query.All(Ball, Position).Build() end function PaddleHitSystem:Update(Time) local ettsBall = self:Result(self.queryBalls):ToArray() local ballSpawned = false self:Result():ForEach(function(ettPaddle) local paddle = ettPaddle[Paddle] local pPosition = ettPaddle[Position] -- collision detection for i,ettBall in ipairs(ettsBall) do local collistion = intersects(ettBall, ettPaddle) if collistion then -- move the ball out of the paddle local ballPos = ettBall[Position].value ettBall[Position].value = ballPos - collistion.normal * collistion.distance computeBounce(ettBall, ettPaddle) if #ettsBall < 2 then paddle.hits = paddle.hits + 1 if paddle.hits == 5 then -- create new ball local inverseDirection = (paddle.side == "left") and "right" or "left" self._world:Entity(Ball({ initialDirection = inverseDirection, secondary = true })) ballSpawned = true end end -- sound effect self._world:Entity( Position(ballPos), AudioSource({ clip = "rbxassetid://4458219865" }) ) -- break return true end end end) if ballSpawned then self:Result():ForEach(function(ettPaddle) local paddle = ettPaddle[Paddle] paddle.hits = 0 end) end end return PaddleHitSystem ================================================ FILE: examples/pong/src/client/systems/PaddleSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Utility = require(Client.Utility) local Constants = require(Client.Constants) local Components = Client.components local Paddle = require(Components.Paddle) local Position = require(Components.Position) local BasePart = require(Components.BasePart) local PaddleSystem = ECS.System("process", 2, ECS.Query.All(Paddle)) function PaddleSystem:Update(Time) self:Result():ForEach(function(entity) local paddle = entity[Paddle] if paddle.target ~= paddle.position then paddle.position = Utility.lerp(paddle.position, paddle.target, Constants.PLAYER_SPEED * Time.DeltaFixed) entity[Position].value = self:GetPaddlePosition(paddle) end end) end function PaddleSystem:OnEnter(Time, entity) local paddle = entity[Paddle] paddle.target = 0 paddle.position = 0 local positionVec3 = self:GetPaddlePosition(paddle) entity[Position] = Position(positionVec3) local part = Instance.new("Part") part.Name = "Paddle_"..paddle.side part.Size = Vector3.new(Constants.PADDLE_WIDTH, 2, Constants.PADDLE_HEIGHT) part.Shape = Enum.PartType.Block part.Anchored = true part.Position = positionVec3 part.Material = Enum.Material.SmoothPlastic if paddle.side == "left" then part.Color = Color3.fromRGB(33, 84, 185) else part.Color = Color3.fromRGB(255, 89, 89) end part.Parent = game.Workspace entity[BasePart] = BasePart(part) end function PaddleSystem:GetPaddlePosition(paddle) local xPos = Constants.COURT_WIDTH/2 if paddle.side == "left" then xPos = xPos * -1 end local zPosMax = Constants.COURT_HEIGHT/2 - Constants.PADDLE_HEIGHT/2 local zPos = Utility.map(paddle.position, -1, 1, -zPosMax, zPosMax) return Vector3.new(xPos, 1, zPos) end return PaddleSystem ================================================ FILE: examples/pong/src/client/systems/PlayerAiThinkSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Utility = require(Client.Utility) local Constants = require(Client.Constants) local Components = Client.components local Ball = require(Components.Ball) local Paddle = require(Components.Paddle) local Position = require(Components.Position) local Velocity = require(Components.Velocity) local Player = require(Components.Player) local PlayerAI = Player.Qualifier("AI") local PlayerHuman = Player.Qualifier("Human") local ballMaxZ = Constants.COURT_HEIGHT/2 - Constants.BALL_RADIUS local PlayerAiThinkSystem = ECS.System("process", 1, ECS.Query.All(PlayerAI, Paddle, Position)) function PlayerAiThinkSystem:Initialize(Time) self.queryHuman = ECS.Query.All(PlayerHuman, Paddle, Position).Build() self.queryBalls = ECS.Query.All(Ball, Position, Velocity).Build() end function PlayerAiThinkSystem:Update(Time) local ettPaddleAI = self:Result():FindAny() local paddle = ettPaddleAI[Paddle] local paddlePos = ettPaddleAI[Position].value -- Get the ball that is coming towards the AI and is closer local tgBallPos self:Result(self.queryBalls):ForEach(function(ettBall) local ballPos = ettBall[Position].value local ballVel = ettBall[Velocity].value local ballTowardsAI if paddle.side == "right" then ballTowardsAI = ballVel.X > 0 else ballTowardsAI = ballVel.X < 0 end if ballTowardsAI then if tgBallPos == nil then tgBallPos = ballPos else -- the target is the ball that is closest to the racket if paddle.side == "right" then if ballPos.X > tgBallPos.X then tgBallPos = ballPos end else if ballPos.X < tgBallPos.X then tgBallPos = ballPos end end end end end) if tgBallPos then paddle.target = Utility.map(tgBallPos.Z, -ballMaxZ, ballMaxZ, -1, 1) end end return PlayerAiThinkSystem ================================================ FILE: examples/pong/src/client/systems/PlayerHumanInputSystem.lua ================================================ local ECS = _G.ECS local UserInputService = game:GetService("UserInputService") local CurrentCamera = game.workspace.CurrentCamera local Client = script.Parent.Parent local Utility = require(Client.Utility) local Components = Client.components local Paddle = require(Components.Paddle) local Player = require(Components.Player) local PlayerHuman = Player.Qualifier("Human") local PlayerHumanInputSystem = ECS.System("process", 1, ECS.Query.All(PlayerHuman, Paddle)) function PlayerHumanInputSystem:Initialize() UserInputService.MouseIconEnabled = false end function PlayerHumanInputSystem:Update(Time) local screenSizeY = CurrentCamera.ViewportSize.Y local mousePosY = UserInputService:GetMouseLocation().Y local min = screenSizeY*0.2 local max = screenSizeY*0.8 mousePosY = math.max(math.min(mousePosY, max), min) local entity = self:Result():FindAny() local paddle = entity[Paddle] paddle.target = Utility.map(mousePosY, min, max, -1, 1) end return PlayerHumanInputSystem ================================================ FILE: examples/pong/src/client/systems/RenderSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Components = Client.components local Position = require(Components.Position) local BasePart = require(Components.BasePart) local RenderSystem = ECS.System("render", 2, ECS.Query.All(Position, BasePart)) function RenderSystem:Update(Time) self:Result():ForEach(function(entity) local position = entity[Position] local part = entity[BasePart].value if position.valueOld then part.Position = position.valueOld:Lerp(position.value, Time.Interpolation) else part.Position = position.value end end) end return RenderSystem ================================================ FILE: examples/pong/src/client/systems/ScoreSystem.lua ================================================ local ECS = _G.ECS local Client = script.Parent.Parent local Constants = require(Client.Constants) local Components = Client.components local Ball = require(Components.Ball) local Paddle = require(Components.Paddle) local Score = require(Components.Score) local Player = require(Components.Player) local Position = require(Components.Position) local Velocity = require(Components.Velocity) local BasePart = require(Components.BasePart) local AudioSource = require(Components.AudioSource) local ballMaxX = Constants.COURT_WIDTH/2 local ballMaxZ = Constants.COURT_HEIGHT/2 - Constants.BALL_RADIUS local ScoreSystem = ECS.System("transform", 2, ECS.Query.All(Score, Paddle)) function ScoreSystem:Initialize(Time) self.queryBalls = ECS.Query.All(Ball, Position).Build() end function ScoreSystem:Update(Time) local ettScored = false local scoredSide local balls = self:Result(self.queryBalls):ToArray() self:Result():ForEach(function(entity) local paddle = entity[Paddle] local score = entity[Score] for i,ettBall in ipairs(balls) do local ball = ettBall[Ball] local ballPos = ettBall[Position].value -- if ball hits horizontal wall, reset the game if (paddle.side == "right" and ballPos.X < -ballMaxX) then ettScored = entity elseif (paddle.side == "left" and ballPos.X > ballMaxX ) then ettScored = entity end if ettScored then score.value = score.value + 1 score.TextLabel.Text = tostring(score.value) -- sound effect self._world:Entity( Position(ballPos), AudioSource({ clip = "rbxassetid://1843023345" }) ) -- break return true end end end) if ettScored then -- remove all balls for i,ettBall in ipairs(balls) do self._world:Remove(ettBall) end -- create new ball self._world:Entity(Ball()) self:Result(self.queryPlayers):ForEach(function(entity) local paddle = entity[Paddle] paddle.hits = 0 end) end end function ScoreSystem:OnEnter(Time, entity) local score = entity[Score] local paddle = entity[Paddle] score.goalPart = game.Workspace:FindFirstChild("goal_"..paddle.side) score.TextLabel = score.goalPart.BillboardGui.TextLabel end return ScoreSystem ================================================ FILE: examples/pong/src/server/Main.server.lua ================================================ game.Players.CharacterAutoLoads = false ================================================ FILE: examples/pong/src/shared/ECS.lua ================================================ --[[ ECS Lua v2.2.0 ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development. This is a minified version of ECS Lua, to see the full source code visit https://github.com/nidorx/ecs-lua Discussions about this script are at https://devforum.roblox.com/t/841175 ------------------------------------------------------------------------------ MIT License Copyright (c) 2021 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local a,b={},{}local function c(d)if(not a[d])then a[d]={r=b[d]()}end return a[d].r end b["Archetype"]=function()local d={}local e={}local f={}local g=0 local h={}h.__index=h function h.Of(i)local j={}local k={}for m,n in ipairs(i)do if(n.IsCType and not n.isComponent)then if n.IsQualifier then if k[n]==nil then k[n]=true table.insert(j,n.Id)end n=n.SuperClass end if k[n]==nil then k[n]=true table.insert(j,n.Id)end end end table.sort(j)local l="_"..table.concat(j,"_")if d[l]==nil then d[l]=setmetatable({id=l,_components=k},h)g=g+1 end return d[l]end function h.Version()return g end function h:Has(i)return(self._components[i]==true)end function h:With(i)if self._components[i]==true then return self end local j=e[self]if not j then j={}e[self]=j end local k=j[i]if k==nil then local l={i}for m,n in pairs(self._components)do table.insert(l,m)end k=h.Of(l)j[i]=k end return k end function h:WithAll(i)local j={}for k,l in pairs(self._components)do table.insert(j,k)end for k,l in ipairs(i)do if self._components[l]==nil then table.insert(j,l)end end return h.Of(j)end function h:Without(i)if self._components[i]==nil then return self end local j=f[self]if not j then j={}f[self]=j end local k=j[i]if k==nil then local l={}for m,n in pairs(self._components)do if m~=i then table.insert(l,m)end end k=h.Of(l)j[i]=k end return k end function h:WithoutAll(i)local j={}for l,m in ipairs(i)do j[m]=true end local k={}for l,m in pairs(self._components)do if j[l]==nil then table.insert(k,l)end end return h.Of(k)end h.EMPTY=h.Of({})return h end b["Component"]=function()local d=c("Utility")local e=c("ComponentFSM")local f=d.copyDeep local g=d.mergeDeep local h=0 local function i(l,m)h=h+1 local n={Id=h,IsCType=true,SuperClass=m}n.__index=n if m==nil then m=n m._Qualifiers={["Primary"]=n}m._QualifiersArr={n}m._Initializers={}else m.HasQualifier=true n.IsQualifier=true n.HasQualifier=true end local o=m._Qualifiers local p=m._QualifiersArr setmetatable(n,{__call=function(q,r)return n.New(r)end,__index=function(q,r)if(r=="States")then return m.__States end if(r=="Case"or r=="StateInitial")then return rawget(m,r)end end,__newindex=function(q,r,s)if(r=="Case"or r=="States"or r=="StateInitial")then if n==m then if(r=="States")then if not m.IsFSM then e.AddCapability(m,s)for t,u in pairs(o)do if u~=m then e.AddMethods(m,u)end end end else rawset(q,r,s)end end else rawset(q,r,s)end end})if m.IsFSM then e.AddMethods(m,n)end function n.Qualifier(q)if type(q)~="string"then for s,t in ipairs(p)do if t==q then return q end end return nil end local r=o[q]if r==nil then r=i(l,m)o[q]=r table.insert(p,r)end return r end function n.Qualifiers(...)local q={...}if#q==0 then return p else local r={}local s={}for t,u in ipairs({...})do local v=n.Qualifier(u)if v and s[v]==nil then s[v]=true table.insert(r,v)end end return r end end function n.New(q)if(q~=nil and type(q)~="table")then q={value=q}end local r=setmetatable(l(q)or{},n)for s,t in ipairs(m._Initializers)do t(r)end r.isComponent=true r._qualifiers={[n]=r}return r end function n:GetType()return n end function n:Is(q)return q==n or q==m end function n:Primary()return self._qualifiers[m]end function n:Qualified(q)return self._qualifiers[n.Qualifier(q)]end function n:QualifiedAll()local q={}for r,s in pairs(o)do q[r]=self._qualifiers[s]end return q end function n:Merge(q)if m.HasQualifier then if self==q then return end if self._qualifiers==q._qualifiers then return end if not q:Is(m)then return end local r=n local s=q:GetType()local t if r==m then t=self._qualifiers elseif s==m then t=q._qualifiers elseif self._qualifiers[m]~=nil then t=self._qualifiers[m]._qualifiers elseif q._qualifiers[m]~=nil then t=q._qualifiers[m]._qualifiers end if t~=nil then if self._qualifiers~=t then for u,v in pairs(self._qualifiers)do if m~=u then t[u]=v v._qualifiers=t end end end if q._qualifiers~=t then for u,v in pairs(q._qualifiers)do if m~=u then t[u]=v v._qualifiers=t end end end else for u,v in pairs(q._qualifiers)do if r~=u then self._qualifiers[u]=v v._qualifiers=self._qualifiers end end end end end function n:Detach()if not m.HasQualifier then return end self._qualifiers[n]=nil self._qualifiers={[n]=self}end return n end local function j(l)return l or{}end local k={}function k.Create(l)local m=j if l~=nil then local n=type(l)if(n=="function")then m=l else if(n~="table")then l={value=l}end m=function(o)local p=f(l)if(o~=nil)then g(p,o)end return p end end end return i(m,nil)end return k end b["ComponentFSM"]=function()local d=c("Query")local e=d.Filter(function(g,h)local i=h.States local j=h.IsSuperClass local k=h.ComponentClass if j then local l=k.Qualifiers()for m,n in ipairs(l)do local o=g[n]if(o~=nil and i[o:GetState()]==true)then return true end end return false else local l=g[k]if l==nil then return false end return i[l:GetState()]==true end end)local f={}function f.AddCapability(g,h)g.IsFSM=true local i=setmetatable({},{__newindex=function(j,k,l)if(type(l)~="table")then l={l}end if table.find(l,"*")then rawset(j,k,"*")else local m=table.find(l,k)if m~=nil then table.remove(l,m)if#l==0 then l="*"end end rawset(j,k,l)end end})rawset(g,"__States",i)for j,k in pairs(h)do if g.StateInitial==nil then g.StateInitial=j end i[j]=k end f.AddMethods(g,g)table.insert(g._Initializers,function(j)j:SetState(g.StateInitial)end)end function f.AddMethods(g,h)h.IsFSM=true local i=g.States function h.In(...)local j={}local k=0 for l,m in ipairs({...})do if(i[m]~=nil and j[m]==nil)then k=k+1 j[m]=true end end if k==0 then return{}end return e({States=j,IsSuperClass=(h==g),ComponentClass=h,})end function h:SetState(j)if(j==nil or i[j]==nil)then return end local k=self:GetState()if(k==j)then return end if(k~=nil)then local m=i[k]if(m~="*"and table.find(m,j)==nil)then return end end self._state=j self._statePrev=k self._stateTime=os.clock()local l=g.Case and g.Case[j]if l then l(self,k)end end function h:GetState()return self._state or g.StateInitial end function h:GetPrevState()return self._statePrev or nil end function h:GetStateTime()return self._stateTime or 0 end end return f end b["ECS"]=function()local d=c("Query")local e=c("World")local f=c("System")local g=c("Archetype")local h=c("Component")local function i(k)e.LoopManager=k end pcall(function()if(game and game.ClassName=="DataModel")then i(c("RobloxLoopManager")())end end)local j={Query=d,World=e.New,System=f.Create,Archetype=g,Component=h.Create,SetLoopManager=i}if _G.ECS==nil then _G.ECS=j else local k=_G.warn or print k("ECS Lua was not registered in the global variables, there is already another object registered.")end return j end b["Entity"]=function()local d=c("Archetype")local e=0 local function f(m,...)local n={...}local o=m._data if(#n==1)then local q=n[1]if(q.IsCType and not q.isComponent)then return o[q]else return nil end end local p={}for q,r in ipairs(n)do if(r.IsCType and not r.isComponent)then table.insert(p,o[r])end end return table.unpack(p)end local function g(m,n,o)local p=m._data local q for r,s in ipairs(o.Qualifiers())do if s~=o then q=p[s]if q then break end end end if q then q:Merge(n)end end local function h(m,...)local n={...}local o=m._data local p=m.archetype local q=p local r={}local s=n[1]if(s and s.IsCType and not s.isComponent)then local t=n[2]local u if t==nil then local v=o[s]if v then v:Detach()end o[s]=nil q=q:Without(s)elseif(type(t)=="table"and t.isComponent)then local v=o[s]if(v~=t)then if v then v:Detach()end s=t:GetType()o[s]=t q=q:With(s)if(s.HasQualifier or s.IsQualifier)then g(m,t,s)end end else local v=o[s]if v then v:Detach()end local w=s(t)o[s]=w q=q:With(s)if(s.HasQualifier or s.IsQualifier)then g(m,w,s)end end else for t,u in ipairs(n)do if(u.isComponent)then local v=u:GetType()local w=o[v]if(w~=u)then if w then w:Detach()end o[v]=u q=q:With(v)if(v.HasQualifier or v.IsQualifier)then g(m,u,v)end end end end end if(p~=q)then m.archetype=q m._onChange:Fire(m,p)end end local function i(m,...)local n=m._data local o=m.archetype local p=o for q,r in ipairs({...})do if r.isComponent then local s=r:GetType()local t=n[s]if t then t:Detach()end n[s]=nil p=p:Without(s)elseif r.IsCType then local s=n[r]if s then s:Detach()end n[r]=nil p=p:Without(r)end end if m.archetype~=p then m.archetype=p m._onChange:Fire(m,o)end end local function j(m,n)local o=m._data local p={}if(n~=nil and n.IsCType and not n.isComponent)then local q=n.Qualifiers()for r,s in ipairs(q)do local t=o[s]if t then table.insert(p,t)end end else for q,r in pairs(o)do table.insert(p,r)end end return p end local function k(m,n)if(n~=nil and n.IsCType and not n.isComponent)then local o=m._data local p=n.Qualifiers()for q,r in ipairs(p)do local s=o[r]if s then return s end end end end local l={__index=function(m,n)if(type(n)=="table")then return f(m,n)end end,__newindex=function(m,n,o)local p=true if(type(n)=="table"and(n.IsCType and not n.isComponent))then h(m,n,o)else rawset(m,n,o)end end}function l.New(m,n)local o=d.EMPTY local p={}if(n~=nil and#n>0)then local q={}for r,s in ipairs(n)do local t=s:GetType()table.insert(q,t)p[t]=s end o=d.Of(q)end e=e+1 return setmetatable({_data=p,_onChange=m,id=e,isAlive=false,archetype=o,Get=f,Set=h,Unset=i,GetAll=j,GetAny=k,},l)end return l end b["EntityRepository"]=function()local d=c("Event")local e={}e.__index=e function e.New()return setmetatable({_archetypes={},_entitiesArchetype={},},e)end function e:Insert(f)if(self._entitiesArchetype[f]==nil)then local g=f.archetype local h=self._archetypes[g]if(h==nil)then h={count=0,entities={}}self._archetypes[g]=h end h.entities[f]=true h.count=h.count+1 self._entitiesArchetype[f]=g else self:Update(f)end end function e:Remove(f)local g=self._entitiesArchetype[f]if g==nil then return end self._entitiesArchetype[f]=nil local h=self._archetypes[g]if(h~=nil and h.entities[f]==true)then h.entities[f]=nil h.count=h.count-1 if(h.count==0)then self._archetypes[g]=nil end end end function e:Update(f)local g=self._entitiesArchetype[f]if(g==nil or g==f.archetype)then return end self:Remove(f)self:Insert(f)end function e:Query(f)local g={}for h,i in pairs(self._archetypes)do if f:Match(h)then table.insert(g,i.entities)end end return f:Result(g),#g>0 end function e:FastCheck(f)for g,h in pairs(self._archetypes)do if f:Match(g)then return true end end return false end return e end b["Event"]=function()local d={}d.__index=d function d.New(f,g)return setmetatable({_event=f,_handler=g},d)end function d:Disconnect()local f=self._event if(f and not f.destroyed)then local g=table.find(f._handlers,self._handler)if g~=nil then table.remove(f._handlers,g)end end setmetatable(self,nil)end local e={}e.__index=e function e.New()return setmetatable({_handlers={}},e)end function e:Connect(f)if(type(f)=="function")then table.insert(self._handlers,f)return d.New(self,f)end error(("Event:Connect(%s)"):format(typeof(f)),2)end function e:Fire(...)if not self.destroyed then for f,g in ipairs(self._handlers)do g(table.unpack({...}))end end end function e:Destroy()setmetatable(self,nil)self._handlers=nil self.destroyed=true end return e end b["Query"]=function()local d=c("QueryResult")local e={}local f={}f.__index=f setmetatable(f,{__call=function(i,j,k,l)return f.New(j,k,l)end,})local function g(i,j,k)local l={}local m={}local n={}for o,p in ipairs(i)do if(l[p]==nil)then if(p.IsCType and not p.isComponent)then l[p]=true table.insert(m,p)table.insert(n,p.Id)else if p.Filter then l[p]=true p[j]=true table.insert(k,p)end end end end if#m>0 then table.sort(n)local o="_"..table.concat(n,"_")return m,o end end function f.New(i,j,k)local l={}local m,n,o if(j~=nil)then j,m=g(j,"IsAnyFilter",l)end if(i~=nil)then i,n=g(i,"IsAllFilter",l)end if(k~=nil)then k,o=g(k,"IsNoneFilter",l)end return setmetatable({isQuery=true,_any=j,_all=i,_none=k,_anyKey=m,_allKey=n,_noneKey=o,_cache={},_clauses=#l>0 and l or nil},f)end function f:Result(i)return d.New(i,self._clauses)end function f:Match(i)local j=self._cache local k=j[i]if k~=nil then return k else local l=e[i]if(l==nil)then l={Any={},All={},None={}}e[i]=l end local m=self._noneKey if m then local p=l.None[m]if(p==nil)then p=true for q,r in ipairs(self._none)do if i:Has(r)then p=false break end end l.None[m]=p end if(p==false)then j[i]=false return false end end local n=self._anyKey if n then local p=l.Any[n]if(p==nil)then p=false if(l.All[n]==true)then p=true else for q,r in ipairs(self._any)do if i:Has(r)then p=true break end end end l.Any[n]=p end if(p==false)then j[i]=false return false end end local o=self._allKey if o then local p=l.All[o]if(p==nil)then local q=true for r,s in ipairs(self._all)do if(not i:Has(s))then q=false break end end if q then p=true else p=false end l.All[o]=p end j[i]=p return p end j[i]=true return true end end local function h()local i={isQueryBuilder=true}local j function i.All(...)j=nil i._all={...}return i end function i.Any(...)j=nil i._any={...}return i end function i.None(...)j=nil i._none={...}return i end function i.Build()if j==nil then j=f.New(i._all,i._any,i._none)end return j end return i end function f.All(...)return h().All(...)end function f.Any(...)return h().Any(...)end function f.None(...)return h().None(...)end function f.Filter(i)return function(j)return{Filter=i,Config=j}end end return f end b["QueryResult"]=function()local function d(l,m,n)return m,(l(m)==true),true end local function e(l,m,n)return l(m),true,true end local function f(l,m,n)local o=(n<=l)return m,o,o end local function g(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=false break end end return m,o,true end local function h(l,m,n)local o=true for p,q in ipairs(l)do if(q.Filter(m,q.Config)==false)then o=false break end end return m,o,true end local function i(l,m,n)local o=false for p,q in ipairs(l)do if(q.Filter(m,q.Config)==true)then o=true break end end return m,o,true end local j={}local k={}k.__index=k function k.New(l,m)local n=j if(m and#m>0)then local o={}local p={}local q={}n={}for r,s in ipairs(m)do if s.IsNoneFilter then table.insert(q,s)elseif s.IsAnyFilter then table.insert(p,s)else table.insert(o,s)end end if(#q>0)then table.insert(n,{g,q})end if(#o>0)then table.insert(n,{h,o})end if(#p>0)then table.insert(n,{i,p})end end return setmetatable({chunks=l,_pipeline=n,},k)end function k:With(l,m)local n={}for o,p in ipairs(self._pipeline)do table.insert(n,p)end table.insert(n,{l,m})return setmetatable({chunks=self.chunks,_pipeline=n,},k)end function k:Filter(l)return self:With(d,l)end function k:Map(l)return self:With(e,l)end function k:Limit(l)return self:With(f,l)end function k:AnyMatch(l)local m=false self:ForEach(function(n)if l(n)then m=true end return m end)return m end function k:AllMatch(l)local m=true self:ForEach(function(n)if(not l(n))then m=false end return m==false end)return m end function k:FindAny()local l self:ForEach(function(m)l=m return true end)return l end function k:ToArray()local l={}self:ForEach(function(m)table.insert(l,m)end)return l end function k:Iterator()local l=coroutine.create(function()self:ForEach(function(m,n)coroutine.yield(m,n)end)end)return function()local m,n,o=coroutine.resume(l)return o,n end end function k:ForEach(l)local m=1 local n=self._pipeline local o=#n>0 if(not o)then for p,q in ipairs(self.chunks)do for r,s in pairs(q)do if(l(r,m)==true)then return end m=m+1 end end else for p,q in ipairs(self.chunks)do for r,s in pairs(q)do local t=false local u=true local v=r if(u and o)then for w,x in ipairs(n)do local y,z,A=x[1](x[2],v,m)if(not A)then t=true end if z then v=y else u=false break end end end if u then if(l(v,m)==true)then return end m=m+1 end if t then return end end end end end return k end b["RobloxLoopManager"]=function()local function d()local e=game:GetService("RunService")return{Register=function(f)local g=e.Stepped:Connect(function()f:Update("process",os.clock())end)local h=e.Heartbeat:Connect(function()f:Update("transform",os.clock())end)local i if(not e:IsServer())then i=e.RenderStepped:Connect(function()f:Update("render",os.clock())end)end return function()g:Disconnect()h:Disconnect()if i then i:Disconnect()end end end}end return d end b["System"]=function()local d={"task","render","process","transform"}local e={}function e.Create(f,g,h,i)if(f==nil or not table.find(d,f))then error("The step parameter must one of ",table.concat(d,", "))end if(g and type(g)=="function")then i=g g=nil elseif h and type(h)=="function"then i=h h=nil end if(g and type(g)=="table"and(g.isQuery or g.isQueryBuilder))then h=g g=nil end if(g==nil or g<0)then g=50 end if type(h)=="function"then i=h h=nil end if(h and h.isQueryBuilder)then h=h.Build()end local j={Step=f,Order=g,Query=h,}j.__index=j function j.New(k,l)local m=setmetatable({version=0,_world=k,_config=l,},j)if m.Initialize then m:Initialize(l)end return m end function j:GetType()return j end function j:Result(k)return self._world:Exec(k or j.Query)end function j:Publish(k,l)end function j:Receive(k)end function j:Destroy()if self.OnDestroy then self.OnDestroy()end setmetatable(self,nil)for k,l in pairs(self)do self[k]=nil end end if i and type(i)=="function"then j.Update=i end return j end return e end b["SystemExecutor"]=function()local function d(i)local j={}local k={}for l,m in ipairs(i)do local n=m:GetType()if(m._TaskState==nil)then m._TaskState="suspended"end if not k[n]then local o={Type=n,System=m,Depends={}}k[n]=o table.insert(j,o)end end for l,m in ipairs(j)do local n=m.Type.Before if(n~=nil and#n>0)then for p,q in ipairs(n)do local r=k[q]if r then r.Depends[m]=true end end end local o=m.Type.After if(o~=nil and#o>0)then for p,q in ipairs(o)do local r=k[q]if r then m.Depends[r]=true end end end end return j end local function e(i,j)return i.Order0 do local j=false local k,l=0,#self._schedulers-1 while k<=l do k=k+1 local m=self._schedulers[k]local n,o=m.Resume(i)if o then j=true end i=i-(n+0.00001)if(i<=0)then break end end if not j then return end end end local function h(i,j,k,l)local m=i.System m._TaskState="running"if(m.ShouldUpdate==nil or m.ShouldUpdate(j))then k.version=k.version+1 m:Update(j)m.version=k.version end m._TaskState="suspended"l(i)end function f:ScheduleTasks(i)local j=self._world local k={}local l={}local m={}local n={}local o={}local p,q=0,#self._task-1 while p<=q do p=p+1 local s=self._task[p]if(s.System._TaskState=="suspended")then s.System._TaskState="scheduled"local t=false for u,v in pairs(s.Depends)do t=true if o[u]==nil then o[u]={}end table.insert(o[u],s)end if(not t)then table.insert(k,s)end m[s]=true end end local function r(s)s.Thread=nil s.LastExecTime=nil n[s]=true if o[s]then local t=o[s]local u,v=0,#t-1 while u<=v do u=u+1 local w=t[u]if m[w]then local x=true for y,z in pairs(w.Depends)do if n[y]~=true then x=false break end end if x then m[w]=nil w.LastExecTime=0 w.Thread=coroutine.create(h)table.insert(l,w)end end end end end if#k>0 then local s,t=0,#k-1 while s<=t do s=s+1 local v=k[s]m[v]=nil v.LastExecTime=0 v.Thread=coroutine.create(h)table.insert(l,v)end local u u={Resume=function(v)table.sort(l,function(A,B)return A.LastExecTimev)then break end end end for A,B in ipairs(l)do if B.Thread==nil then local C=table.find(l,B)if C~=nil then table.remove(l,C)end end end local z=#l>0 if(not z)then local A=table.find(self._schedulers,u)if A~=nil then table.remove(self._schedulers,A)end end return w,z end}table.insert(self._schedulers,u)end end return f end b["Timer"]=function()local d=4 local function e(g)local h=0.0 local i=0.0 return function(j,k,l,m)local n=g.DeltaFixed local o=j-i if o>0.25 then o=0.25 end i=j g.Now=j h=h+o if k=="process"then if h>=n then g.Interpolation=1 l(g)local p=0 while(h>=n and p file.max then file.max = line_nr end local hits = (file[line_nr] or 0) + 1 file[line_nr] = hits if hits > file.max_hits then file.max_hits = hits end if runner.tick then steps_after_save = steps_after_save + 1 if steps_after_save == runner.configuration.savestepsize then steps_after_save = 0 if not runner.paused then runner.save_stats() end end end end end return hook ================================================ FILE: modules/luacov/linescanner.lua ================================================ local LineScanner = {} LineScanner.__index = LineScanner function LineScanner:new() return setmetatable({ first = true, comment = false, after_function = false, enabled = true }, self) end -- Raw version of string.gsub local function replace(s, old, new) old = old:gsub("%p", "%%%0") new = new:gsub("%%", "%%%%") return (s:gsub(old, new)) end local fixups = { { "=", " ?= ?" }, -- '=' may be surrounded by spaces { "(", " ?%( ?" }, -- '(' may be surrounded by spaces { ")", " ?%) ?" }, -- ')' may be surrounded by spaces { "", "x ?[%[%.]? ?[ntfx0']* ?%]?" }, -- identifier, possibly indexed once { "", "x ?, ?x[x, ]*" }, -- at least two comma-separated identifiers { "", "%[? ?[ntfx0']+ ?%]?" }, -- field, possibly like ["this"] { "", "[ %(]*" }, -- optional opening parentheses } -- Utility function to make patterns more readable local function fixup(pat) for _, fixup_pair in ipairs(fixups) do pat = replace(pat, fixup_pair[1], fixup_pair[2]) end return pat end --- Lines that are always excluded from accounting local any_hits_exclusions = { "", -- Empty line "end[,; %)]*", -- Single "end" "else", -- Single "else" "repeat", -- Single "repeat" "do", -- Single "do" "if", -- Single "if" "then", -- Single "then" "while t do", -- "while true do" generates no code "if t then", -- "if true then" generates no code "local x", -- "local var" fixup "local x=", -- "local var =" fixup "local ", -- "local var1, ..., varN" fixup "local =", -- "local var1, ..., varN =" "local function x", -- "local function f (arg1, ..., argN)" } --- Lines that are only excluded from accounting when they have 0 hits local zero_hits_exclusions = { "[ntfx0',= ]+,", -- "var1 var2," multi columns table stuff "{ ?} ?,", -- Empty table before comma leaves no trace in tables and calls fixup "=.+[,;]", -- "[123] = 23," "['foo'] = "asd"," fixup "=function", -- "[123] = function(...)" fixup "='", -- "[123] = [[", possibly with opening parens "return function", -- "return function(arg1, ..., argN)" "function", -- "function(arg1, ..., argN)" "[ntfx0]", -- Single token expressions leave no trace in tables, function calls and sometimes assignments "''", -- Same for strings "{ ?}", -- Same for empty tables fixup "", -- Same for local variables indexed once fixup "local x=function", -- "local a = function(arg1, ..., argN)" fixup "local x='", -- "local a = [[", possibly with opening parens fixup "local x=(", -- "local a = (", possibly with several parens fixup "local =(", -- "local a, b = (", possibly with several parens fixup "local x=n", -- "local a = nil; local b = nil" produces no trace for the second statement fixup "='", -- "a.b = [[", possibly with opening parens fixup "=function", -- "a = function(arg1, ..., argN)" "} ?,", -- "}," generates no trace if the table ends with a key-value pair "} ?, ?function", -- same with "}, function(...)" "break", -- "break" generates no trace in Lua 5.2+ "{", -- "{" opening table "}?[ %)]*", -- optional closing paren, possibly with several closing parens "[ntf0']+ ?}[ %)]*" -- a constant at the end of a table, possibly with closing parens (for LuaJIT) } local function excluded(exclusions, line) for _, e in ipairs(exclusions) do if line:match("^ *"..e.." *$") then return true end end return false end function LineScanner:find(pattern) return self.line:find(pattern, self.i) end -- Skips string literal with quote stored as self.quote. -- @return boolean indicating success. function LineScanner:skip_string() -- Look for closing quote, possibly after even number of backslashes. local _, quote_i = self:find("^(\\*)%1"..self.quote) if not quote_i then _, quote_i = self:find("[^\\](\\*)%1"..self.quote) end if quote_i then self.i = quote_i + 1 self.quote = nil table.insert(self.simple_line_buffer, "'") return true else return false end end -- Skips long string literal with equal signs stored as self.equals. -- @return boolean indicating success. function LineScanner:skip_long_string() local _, bracket_i = self:find("%]"..self.equals.."%]") if bracket_i then self.i = bracket_i + 1 self.equals = nil if self.comment then self.comment = false else table.insert(self.simple_line_buffer, "'") end return true else return false end end -- Skips function arguments. -- @return boolean indicating success. function LineScanner:skip_args() local _, paren_i = self:find("%)") if paren_i then self.i = paren_i + 1 self.args = nil return true else return false end end function LineScanner:skip_whitespace() local next_i = self:find("%S") or #self.line + 1 if next_i ~= self.i then self.i = next_i table.insert(self.simple_line_buffer, " ") end end function LineScanner:skip_number() if self:find("^0[xX]") then self.i = self.i + 2 end local _ _, _, self.i = self:find("^[%x%.]*()") if self:find("^[eEpP][%+%-]") then -- Skip exponent, too. self.i = self.i + 2 _, _, self.i = self:find("^[%x%.]*()") end -- Skip LuaJIT number suffixes (i, ll, ull). _, _, self.i = self:find("^[iull]*()") table.insert(self.simple_line_buffer, "0") end local keywords = {["nil"] = "n", ["true"] = "t", ["false"] = "f"} for _, keyword in ipairs({ "and", "break", "do", "else", "elseif", "end", "for", "function", "goto", "if", "in", "local", "not", "or", "repeat", "return", "then", "until", "while"}) do keywords[keyword] = keyword end function LineScanner:skip_name() -- It is guaranteed that the first character matches "%a_". local _, _, name = self:find("^([%w_]*)") self.i = self.i + #name if keywords[name] then name = keywords[name] else name = "x" end table.insert(self.simple_line_buffer, name) if name == "function" then -- This flag indicates that the next pair of parentheses (function args) must be skipped. self.after_function = true end end -- Source lines can be explicitly ignored using `enable` and `disable` inline options. -- An inline option is a simple comment: `-- luacov: enable` or `-- luacov: disable`. -- Inline option parsing is not whitespace sensitive. -- All lines starting from a line containing `disable` option and up to a line containing `enable` -- option (or end of file) are excluded. function LineScanner:check_inline_options(comment_body) if comment_body:find("^%s*luacov:%s*enable%s*$") then self.enabled = true elseif comment_body:find("^%s*luacov:%s*disable%s*$") then self.enabled = false end end -- Consumes and analyzes a line. -- @return boolean indicating whether line must be excluded. -- @return boolean indicating whether line must be excluded if not hit. function LineScanner:consume(line) if self.first then self.first = false if line:match("^#!") then -- Ignore Unix hash-bang magic line. return true, true end end self.line = line -- As scanner goes through the line, it puts its simplified parts into buffer. -- Punctuation is preserved. Whitespace is replaced with single space. -- Literal strings are replaced with "''", so that a string literal -- containing special characters does not confuse exclusion rules. -- Numbers are replaced with "0". -- Identifiers are replaced with "x". -- Literal keywords (nil, true and false) are replaced with "n", "t" and "f", -- other keywords are preserved. -- Function declaration arguments are removed. self.simple_line_buffer = {} self.i = 1 while self.i <= #line do -- One iteration of this loop handles one token, where -- string literal start and end are considered distinct tokens. if self.quote then if not self:skip_string() then -- String literal ends on another line. break end elseif self.equals then if not self:skip_long_string() then -- Long string literal or comment ends on another line. break end elseif self.args then if not self:skip_args() then -- Function arguments end on another line. break end else self:skip_whitespace() if self:find("^%.%d") then self.i = self.i + 1 end if self:find("^%d") then self:skip_number() elseif self:find("^[%a_]") then self:skip_name() else if self:find("^%-%-") then self.comment = true self.i = self.i + 2 end local _, bracket_i, equals = self:find("^%[(=*)%[") if equals then self.i = bracket_i + 1 self.equals = equals if not self.comment then table.insert(self.simple_line_buffer, "'") end elseif self.comment then -- Simple comment, check if it contains inline options and skip line. self.comment = false local comment_body = self.line:sub(self.i) self:check_inline_options(comment_body) break else local char = line:sub(self.i, self.i) if char == "." then -- Dot can't be saved as one character because of -- ".." and "..." tokens and the fact that number literals -- can start with one. local _, _, dots = self:find("^(%.*)") self.i = self.i + #dots table.insert(self.simple_line_buffer, dots) else self.i = self.i + 1 if char == "'" or char == '"' then table.insert(self.simple_line_buffer, "'") self.quote = char elseif self.after_function and char == "(" then -- This is the opening parenthesis of function declaration args. self.after_function = false self.args = true else -- Save other punctuation literally. -- This inserts an empty string when at the end of line, -- which is fine. table.insert(self.simple_line_buffer, char) end end end end end end if not self.enabled then -- Disabled by inline options, always exclude the line. return true, true end local simple_line = table.concat(self.simple_line_buffer) return excluded(any_hits_exclusions, simple_line), excluded(zero_hits_exclusions, simple_line) end return LineScanner ================================================ FILE: modules/luacov/reporter/default.lua ================================================ return require "luacov.reporter" ================================================ FILE: modules/luacov/reporter.lua ================================================ ------------------------ -- Report module, will transform statistics file into a report. -- @class module -- @name luacov.reporter local reporter = {} local LineScanner = require("luacov.linescanner") local luacov = require("luacov.runner") local util = require("luacov.util") local lfs_ok, lfs = pcall(require, "lfs") ---------------------------------------------------------------- local dir_sep = package.config:sub(1, 1) if not dir_sep:find("[/\\]") then dir_sep = "/" end --- returns all files inside dir --- @param dir directory to be listed --- @treturn table with filenames and attributes local function dirtree(dir) assert(dir and dir ~= "", "Please pass directory parameter") if dir:sub(-1):match("[/\\]") then dir=string.sub(dir, 1, -2) end dir = dir:gsub("[/\\]", dir_sep) local function yieldtree(directory) for entry in lfs.dir(directory) do if entry ~= "." and entry ~= ".." then entry=directory..dir_sep..entry local attr=lfs.attributes(entry) coroutine.yield(entry,attr) if attr.mode == "directory" then yieldtree(entry) end end end end return coroutine.wrap(function() yieldtree(dir) end) end ---------------------------------------------------------------- --- checks if string 'filename' has pattern 'pattern' --- @param filename --- @param pattern --- @return boolean local function fileMatches(filename, pattern) return string.find(filename, pattern) end ---------------------------------------------------------------- --- Basic reporter class stub. -- Implements 'new', 'run' and 'close' methods required by `report`. -- Provides some helper methods and stubs to be overridden by child classes. -- @usage -- local MyReporter = setmetatable({}, ReporterBase) -- MyReporter.__index = MyReporter -- function MyReporter:on_hit_line(...) -- self:write(("File %s: hit line %s %d times"):format(...)) -- end -- @type ReporterBase local ReporterBase = {} do ReporterBase.__index = ReporterBase function ReporterBase:new(conf) local stats = require("luacov.stats") local data = stats.load(conf.statsfile) if not data then return nil, "Could not load stats file " .. conf.statsfile .. "." end local files = {} local filtered_data = {} local max_hits = 0 -- Several original paths can map to one real path, -- their stats should be merged in this case. for filename, file_stats in pairs(data) do if luacov.file_included(filename) then filename = luacov.real_name(filename) if filtered_data[filename] then luacov.update_stats(filtered_data[filename], file_stats) else table.insert(files, filename) filtered_data[filename] = file_stats end max_hits = math.max(max_hits, filtered_data[filename].max_hits) end end -- including files without tests -- only .lua files if conf.includeuntestedfiles then if not lfs_ok then print("The option includeuntestedfiles requires the lfs module (from luafilesystem) to be installed.") os.exit(1) end local function add_empty_file_coverage_data(file_path) -- Leading "./" must be trimmed from the file paths because the paths of tested -- files do not have a leading "./" either if (file_path:match("^%.[/\\]")) then file_path = file_path:sub(3) end if luacov.file_included(file_path) then local file_stats = { max = 0, max_hits = 0 } local filename = luacov.real_name(file_path) if not filtered_data[filename] then table.insert(files, filename) filtered_data[filename] = file_stats end end end local function add_empty_dir_coverage_data(directory_path) for filename, attr in dirtree(directory_path) do if attr.mode == "file" and fileMatches(filename, '.%.lua$') then add_empty_file_coverage_data(filename) end end end if (conf.includeuntestedfiles == true) then add_empty_dir_coverage_data("." .. dir_sep) elseif (type(conf.includeuntestedfiles) == "table" and conf.includeuntestedfiles[1]) then for _, include_path in ipairs(conf.includeuntestedfiles) do if (fileMatches(include_path, '.%.lua$')) then add_empty_file_coverage_data(include_path) else add_empty_dir_coverage_data(include_path) end end end end table.sort(files) local out, err = io.open(conf.reportfile, "w") if not out then return nil, err end local o = setmetatable({ _out = out, _cfg = conf, _data = filtered_data, _files = files, _mhit = max_hits, }, self) return o end --- Returns configuration table. -- @see luacov.defaults function ReporterBase:config() return self._cfg end --- Returns maximum number of hits per line in all coverage data. function ReporterBase:max_hits() return self._mhit end --- Writes strings to report file. -- @param ... strings. function ReporterBase:write(...) return self._out:write(...) end function ReporterBase:close() self._out:close() self._private = nil end --- Returns array of filenames to be reported. function ReporterBase:files() return self._files end --- Returns coverage data for a file. -- @param filename name of the file. -- @see luacov.stats.load function ReporterBase:stats(filename) return self._data[filename] end -- Stub methods follow. -- luacheck: push no unused args --- Stub method called before reporting. function ReporterBase:on_start() end --- Stub method called before processing a file. -- @param filename name of the file. function ReporterBase:on_new_file(filename) end --- Stub method called if a file couldn't be processed due to an error. -- @param filename name of the file. -- @param error_type "open", "read" or "load". -- @param message error message. function ReporterBase:on_file_error(filename, error_type, message) end --- Stub method called for each empty source line -- and other lines that can't be hit. -- @param filename name of the file. -- @param lineno line number. -- @param line the line itself as a string. function ReporterBase:on_empty_line(filename, lineno, line) end --- Stub method called for each missed source line. -- @param filename name of the file. -- @param lineno line number. -- @param line the line itself as a string. function ReporterBase:on_mis_line(filename, lineno, line) end --- Stub method called for each hit source line. -- @param filename name of the file. -- @param lineno line number. -- @param line the line itself as a string. -- @param hits number of times the line was hit. Should be positive. function ReporterBase:on_hit_line(filename, lineno, line, hits) end --- Stub method called after a file has been processed. -- @param filename name of the file. -- @param hits total number of hit lines in the file. -- @param miss total number of missed lines in the file. function ReporterBase:on_end_file(filename, hits, miss) end --- Stub method called after reporting. function ReporterBase:on_end() end -- luacheck: pop local cluacov_ok = pcall(require, "cluacov.version") local deepactivelines if cluacov_ok then deepactivelines = require("cluacov.deepactivelines") end function ReporterBase:_run_file(filename) local file, open_err = io.open(filename) if not file then self:on_file_error(filename, "open", util.unprefix(open_err, filename .. ": ")) return end local active_lines if cluacov_ok then local src, read_err = file:read("*a") if not src then self:on_file_error(filename, "read", read_err) return end src = src:gsub("^#![^\n]*", "") local func, load_err = util.load_string(src, nil, "@file") if not func then self:on_file_error(filename, "load", "line " .. util.unprefix(load_err, "file:")) return end active_lines = deepactivelines.get(func) file:seek("set") end self:on_new_file(filename) local file_hits, file_miss = 0, 0 local filedata = self:stats(filename) local line_nr = 1 local scanner = LineScanner:new() while true do local line = file:read("*l") if not line then break end local always_excluded, excluded_when_not_hit = scanner:consume(line) local hits = filedata[line_nr] or 0 local included = not always_excluded and (not excluded_when_not_hit or hits ~= 0) if cluacov_ok then included = included and active_lines[line_nr] end if included then if hits == 0 then self:on_mis_line(filename, line_nr, line) file_miss = file_miss + 1 else self:on_hit_line(filename, line_nr, line, hits) file_hits = file_hits + 1 end else self:on_empty_line(filename, line_nr, line) end line_nr = line_nr + 1 end file:close() self:on_end_file(filename, file_hits, file_miss) end function ReporterBase:run() self:on_start() for _, filename in ipairs(self:files()) do self:_run_file(filename) end self:on_end() end end --- @section end ---------------------------------------------------------------- ---------------------------------------------------------------- local DefaultReporter = setmetatable({}, ReporterBase) do DefaultReporter.__index = DefaultReporter function DefaultReporter:on_start() local most_hits = self:max_hits() local most_hits_length = #("%d"):format(most_hits) self._summary = {} self._empty_format = (" "):rep(most_hits_length + 1) self._zero_format = ("*"):rep(most_hits_length).."0" self._count_format = ("%% %dd"):format(most_hits_length+1) self._printed_first_header = false end function DefaultReporter:on_new_file(filename) self:write(("="):rep(78), "\n") self:write(filename, "\n") self:write(("="):rep(78), "\n") end function DefaultReporter:on_file_error(filename, error_type, message) --luacheck: no self io.stderr:write(("Couldn't %s %s: %s\n"):format(error_type, filename, message)) end function DefaultReporter:on_empty_line(_, _, line) if line == "" then self:write("\n") else self:write(self._empty_format, " ", line, "\n") end end function DefaultReporter:on_mis_line(_, _, line) self:write(self._zero_format, " ", line, "\n") end function DefaultReporter:on_hit_line(_, _, line, hits) self:write(self._count_format:format(hits), " ", line, "\n") end function DefaultReporter:on_end_file(filename, hits, miss) self._summary[filename] = { hits = hits, miss = miss } self:write("\n") end local function coverage_to_string(hits, missed) local total = hits + missed if total == 0 then total = 1 end return ("%.2f%%"):format(hits/total*100.0) end function DefaultReporter:on_end() self:write(("="):rep(78), "\n") self:write("Summary\n") self:write(("="):rep(78), "\n") self:write("\n") local lines = {{"File", "Hits", "Missed", "Coverage"}} local total_hits, total_missed = 0, 0 for _, filename in ipairs(self:files()) do local summary = self._summary[filename] if summary then local hits, missed = summary.hits, summary.miss table.insert(lines, { filename, tostring(summary.hits), tostring(summary.miss), coverage_to_string(hits, missed) }) total_hits = total_hits + hits total_missed = total_missed + missed end end table.insert(lines, { "Total", tostring(total_hits), tostring(total_missed), coverage_to_string(total_hits, total_missed) }) local max_column_lengths = {} for _, line in ipairs(lines) do for column_nr, column in ipairs(line) do max_column_lengths[column_nr] = math.max(max_column_lengths[column_nr] or -1, #column) end end local table_width = #max_column_lengths - 1 for _, column_length in ipairs(max_column_lengths) do table_width = table_width + column_length end for line_nr, line in ipairs(lines) do if line_nr == #lines or line_nr == 2 then self:write(("-"):rep(table_width), "\n") end for column_nr, column in ipairs(line) do self:write(column) if column_nr == #line then self:write("\n") else self:write((" "):rep(max_column_lengths[column_nr] - #column + 1)) end end end end end ---------------------------------------------------------------- --- Runs the report generator. -- To load a config, use `luacov.runner.load_config` first. -- @param[opt] reporter_class custom reporter class. Will be -- instantiated using 'new' method with configuration -- (see `luacov.defaults`) as the argument. It should -- return nil + error if something went wrong. -- After acquiring a reporter object its 'run' and 'close' -- methods will be called. -- The easiest way to implement a custom reporter class is to -- extend `ReporterBase`. function reporter.report(reporter_class) local configuration = luacov.load_config() reporter_class = reporter_class or DefaultReporter local rep, err = reporter_class:new(configuration) if not rep then print(err) print("Run your Lua program with -lluacov and then rerun luacov.") os.exit(1) end rep:run() rep:close() if configuration.deletestats then os.remove(configuration.statsfile) end end reporter.ReporterBase = ReporterBase reporter.DefaultReporter = DefaultReporter return reporter ================================================ FILE: modules/luacov/runner.lua ================================================ --------------------------------------------------- -- Statistics collecting module. -- Calling the module table is a shortcut to calling the `init` function. -- @class module -- @name luacov.runner local runner = {} --- LuaCov version in `MAJOR.MINOR.PATCH` format. runner.version = "0.15.0" local stats = require("luacov.stats") local util = require("luacov.util") runner.defaults = require("luacov.defaults") local debug = require("debug") local raw_os_exit = os.exit local new_anchor = newproxy or function() return {} end -- luacheck: compat -- Returns an anchor that runs fn when collected. local function on_exit_wrap(fn) local anchor = new_anchor() debug.setmetatable(anchor, {__gc = fn}) return anchor end runner.data = {} runner.paused = true runner.initialized = false runner.tick = false -- Checks if a string matches at least one of patterns. -- @param patterns array of patterns or nil -- @param str string to match -- @param on_empty return value in case of empty pattern array local function match_any(patterns, str, on_empty) if not patterns or not patterns[1] then return on_empty end for _, pattern in ipairs(patterns) do if string.match(str, pattern) then return true end end return false end -------------------------------------------------- -- Uses LuaCov's configuration to check if a file is included for -- coverage data collection. -- @param filename name of the file. -- @return true if file is included, false otherwise. function runner.file_included(filename) -- Normalize file names before using patterns. filename = string.gsub(filename, "\\", "/") filename = string.gsub(filename, "%.lua$", "") -- If include list is empty, everything is included by default. -- If exclude list is empty, nothing is excluded by default. return match_any(runner.configuration.include, filename, true) and not match_any(runner.configuration.exclude, filename, false) end -------------------------------------------------- -- Adds stats to an existing file stats table. -- @param old_stats stats to be updated. -- @param extra_stats another stats table, will be broken during update. function runner.update_stats(old_stats, extra_stats) old_stats.max = math.max(old_stats.max, extra_stats.max) -- Remove string keys so that they do not appear when iterating -- over 'extra_stats'. extra_stats.max = nil extra_stats.max_hits = nil for line_nr, run_nr in pairs(extra_stats) do old_stats[line_nr] = (old_stats[line_nr] or 0) + run_nr old_stats.max_hits = math.max(old_stats.max_hits, old_stats[line_nr]) end end -- Adds accumulated stats to existing stats file or writes a new one, then resets data. function runner.save_stats() local loaded = stats.load(runner.configuration.statsfile) or {} for name, file_data in pairs(runner.data) do if loaded[name] then runner.update_stats(loaded[name], file_data) else loaded[name] = file_data end end stats.save(runner.configuration.statsfile, loaded) runner.data = {} end local cluacov_ok = pcall(require, "cluacov.version") -------------------------------------------------- -- Debug hook set by LuaCov. -- Acknowledges that a line is executed, but does nothing -- if called manually before coverage gathering is started. -- @param _ event type, should always be "line". -- @param line_nr line number. -- @param[opt] level passed to debug.getinfo to get name of processed file, -- 2 by default. Increase it if this function is called manually -- from another debug hook. -- @usage -- local function custom_hook(_, line) -- runner.debug_hook(_, line, 3) -- extra_processing(line) -- end -- @function debug_hook runner.debug_hook = require(cluacov_ok and "cluacov.hook" or "luacov.hook").new(runner) ------------------------------------------------------ -- Runs the reporter specified in configuration. -- @param[opt] configuration if string, filename of config file (used to call `load_config`). -- If table then config table (see file `luacov.default.lua` for an example). -- If `configuration.reporter` is not set, runs the default reporter; -- otherwise, it must be a module name in 'luacov.reporter' namespace. -- The module must contain 'report' function, which is called without arguments. function runner.run_report(configuration) configuration = runner.load_config(configuration) local reporter = "luacov.reporter" if configuration.reporter then reporter = reporter .. "." .. configuration.reporter end require(reporter).report() end local on_exit_run_once = false local function on_exit() -- Lua >= 5.2 could call __gc when user call os.exit -- so this method could be called twice if on_exit_run_once then return end on_exit_run_once = true runner.save_stats() if runner.configuration.runreport then runner.run_report(runner.configuration) end end local dir_sep = package.config:sub(1, 1) local wildcard_expansion = "[^/]+" if not dir_sep:find("[/\\]") then dir_sep = "/" end local function escape_module_punctuation(ch) if ch == "." then return "/" elseif ch == "*" then return wildcard_expansion else return "%" .. ch end end local function reversed_module_name_parts(name) local parts = {} for part in name:gmatch("[^%.]+") do table.insert(parts, 1, part) end return parts end -- This function is used for sorting module names. -- More specific names should come first. -- E.g. rule for 'foo.bar' should override rule for 'foo.*', -- rule for 'foo.*' should override rule for 'foo.*.*', -- and rule for 'a.b' should override rule for 'b'. -- To be more precise, because names become patterns that are matched -- from the end, the name that has the first (from the end) literal part -- (and the corresponding part for the other name is not literal) -- is considered more specific. local function compare_names(name1, name2) local parts1 = reversed_module_name_parts(name1) local parts2 = reversed_module_name_parts(name2) for i = 1, math.max(#parts1, #parts2) do if not parts1[i] then return false end if not parts2[i] then return true end local is_literal1 = not parts1[i]:find("%*") local is_literal2 = not parts2[i]:find("%*") if is_literal1 ~= is_literal2 then return is_literal1 end end -- Names are at the same level of specificness, -- fall back to lexicographical comparison. return name1 < name2 end -- Sets runner.modules using runner.configuration.modules. -- Produces arrays of module patterns and filenames and sets -- them as runner.modules.patterns and runner.modules.filenames. -- Appends these patterns to the include list. local function acknowledge_modules() runner.modules = {patterns = {}, filenames = {}} if not runner.configuration.modules then return end if not runner.configuration.include then runner.configuration.include = {} end local names = {} for name in pairs(runner.configuration.modules) do table.insert(names, name) end table.sort(names, compare_names) for _, name in ipairs(names) do local pattern = name:gsub("%p", escape_module_punctuation) .. "$" local filename = runner.configuration.modules[name]:gsub("[/\\]", dir_sep) table.insert(runner.modules.patterns, pattern) table.insert(runner.configuration.include, pattern) table.insert(runner.modules.filenames, filename) if filename:match("init%.lua$") then pattern = pattern:gsub("$$", "/init$") table.insert(runner.modules.patterns, pattern) table.insert(runner.configuration.include, pattern) table.insert(runner.modules.filenames, filename) end end end -------------------------------------------------- -- Returns real name for a source file name -- using `luacov.defaults.modules` option. -- @param filename name of the file. function runner.real_name(filename) local orig_filename = filename -- Normalize file names before using patterns. filename = filename:gsub("\\", "/"):gsub("%.lua$", "") for i, pattern in ipairs(runner.modules.patterns) do local match = filename:match(pattern) if match then local new_filename = runner.modules.filenames[i] if pattern:find(wildcard_expansion, 1, true) then -- Given a prefix directory, join it -- with matched part of source file name. if not new_filename:match("/$") then new_filename = new_filename .. "/" end new_filename = new_filename .. match .. ".lua" end -- Switch slashes back to native. return (new_filename:gsub("^%.[/\\]", ""):gsub("[/\\]", dir_sep)) end end return orig_filename end -- Always exclude luacov's own files. local luacov_excludes = { "luacov$", "luacov/hook$", "luacov/reporter$", "luacov/reporter/default$", "luacov/defaults$", "luacov/runner$", "luacov/stats$", "luacov/tick$", "luacov/util$", "cluacov/version$" } local function is_absolute(path) if path:sub(1, 1) == dir_sep or path:sub(1, 1) == "/" then return true end if dir_sep == "\\" and path:find("^%a:") then return true end return false end local function get_cur_dir() local pwd_cmd = dir_sep == "\\" and "cd 2>nul" or "pwd 2>/dev/null" local handler = io.popen(pwd_cmd, "r") local cur_dir = handler:read() handler:close() cur_dir = cur_dir:gsub("\r?\n$", "") if cur_dir:sub(-1) ~= dir_sep and cur_dir:sub(-1) ~= "/" then cur_dir = cur_dir .. dir_sep end return cur_dir end -- Sets configuration. If some options are missing, default values are used instead. local function set_config(configuration) runner.configuration = {} for option, default_value in pairs(runner.defaults) do runner.configuration[option] = default_value end for option, value in pairs(configuration) do runner.configuration[option] = value end -- Program using LuaCov may change directory during its execution. -- Convert path options to absolute paths to use correct paths anyway. local cur_dir for _, option in ipairs({"statsfile", "reportfile"}) do local path = runner.configuration[option] if not is_absolute(path) then cur_dir = cur_dir or get_cur_dir() runner.configuration[option] = cur_dir .. path end end acknowledge_modules() for _, patt in ipairs(luacov_excludes) do table.insert(runner.configuration.exclude, patt) end runner.tick = runner.tick or runner.configuration.tick end local function load_config_file(name, is_default) local conf = setmetatable({}, {__index = _G}) local ok, ret, error_msg = util.load_config(name, conf) if ok then if type(ret) == "table" then for key, value in pairs(ret) do if conf[key] == nil then conf[key] = value end end end return conf end local error_type = ret if error_type == "read" and is_default then return nil end io.stderr:write(("Error: couldn't %s config file %s: %s\n"):format(error_type, name, error_msg)) raw_os_exit(1) end local default_config_file = ".luacov" ------------------------------------------------------ -- Loads a valid configuration. -- @param[opt] configuration user provided config (config-table or filename) -- @return existing configuration if already set, otherwise loads a new -- config from the provided data or the defaults. -- When loading a new config, if some options are missing, default values -- from `luacov.defaults` are used instead. function runner.load_config(configuration) if not runner.configuration then if not configuration then -- Nothing provided, load from default location if possible. set_config(load_config_file(default_config_file, true) or runner.defaults) elseif type(configuration) == "string" then set_config(load_config_file(configuration)) elseif type(configuration) == "table" then set_config(configuration) else error("Expected filename, config table or nil. Got " .. type(configuration)) end end return runner.configuration end -------------------------------------------------- -- Pauses saving data collected by LuaCov's runner. -- Allows other processes to write to the same stats file. -- Data is still collected during pause. function runner.pause() runner.paused = true end -------------------------------------------------- -- Resumes saving data collected by LuaCov's runner. function runner.resume() runner.paused = false end local hook_per_thread -- Determines whether debug hooks are separate for each thread. local function has_hook_per_thread() if hook_per_thread == nil then local old_hook, old_mask, old_count = debug.gethook() local noop = function() end debug.sethook(noop, "l") local thread_hook = coroutine.wrap(function() return debug.gethook() end)() hook_per_thread = thread_hook ~= noop debug.sethook(old_hook, old_mask, old_count) end return hook_per_thread end -------------------------------------------------- -- Wraps a function, enabling coverage gathering in it explicitly. -- LuaCov gathers coverage using a debug hook, and patches coroutine -- library to set it on created threads when under standard Lua, where each -- coroutine has its own hook. If a coroutine is created using Lua C API -- or before the monkey-patching, this wrapper should be applied to the -- main function of the coroutine. Under LuaJIT this function is redundant, -- as there is only one, global debug hook. -- @param f a function -- @return a function that enables coverage gathering and calls the original function. -- @usage -- local coro = coroutine.create(runner.with_luacov(func)) function runner.with_luacov(f) return function(...) if has_hook_per_thread() then debug.sethook(runner.debug_hook, "l") end return f(...) end end -------------------------------------------------- -- Initializes LuaCov runner to start collecting data. -- @param[opt] configuration if string, filename of config file (used to call `load_config`). -- If table then config table (see file `luacov.default.lua` for an example) function runner.init(configuration) runner.configuration = runner.load_config(configuration) -- metatable trick on filehandle won't work if Lua exits through -- os.exit() hence wrap that with exit code as well os.exit = function(...) -- luacheck: no global on_exit() raw_os_exit(...) end debug.sethook(runner.debug_hook, "l") if has_hook_per_thread() then -- debug must be set for each coroutine separately -- hence wrap coroutine function to set the hook there -- as well local rawcoroutinecreate = coroutine.create coroutine.create = function(...) -- luacheck: no global local co = rawcoroutinecreate(...) debug.sethook(co, runner.debug_hook, "l") return co end -- Version of assert which handles non-string errors properly. local function safeassert(ok, ...) if ok then return ... else error(..., 0) end end coroutine.wrap = function(...) -- luacheck: no global local co = rawcoroutinecreate(...) debug.sethook(co, runner.debug_hook, "l") return function(...) return safeassert(coroutine.resume(co, ...)) end end end if not runner.tick then runner.on_exit_trick = on_exit_wrap(on_exit) end runner.initialized = true runner.paused = false end -------------------------------------------------- -- Shuts down LuaCov's runner. -- This should only be called from daemon processes or sandboxes which have -- disabled os.exit and other hooks that are used to determine shutdown. function runner.shutdown() on_exit() end -- Gets the sourcefilename from a function. -- @param func function to lookup. -- @return sourcefilename or nil when not found. local function getsourcefile(func) assert(type(func) == "function") local d = debug.getinfo(func).source if d and d:sub(1, 1) == "@" then return d:sub(2) end end -- Looks for a function inside a table. -- @param searched set of already checked tables. local function findfunction(t, searched) if searched[t] then return end searched[t] = true for _, v in pairs(t) do if type(v) == "function" then return v elseif type(v) == "table" then local func = findfunction(v, searched) if func then return func end end end end -- Gets source filename from a file name, module name, function or table. -- @param name string; filename, -- string; modulename as passed to require(), -- function; where containing file is looked up, -- table; module table where containing file is looked up -- @raise error message if could not find source filename. -- @return source filename. local function getfilename(name) if type(name) == "function" then local sourcefile = getsourcefile(name) if not sourcefile then error("Could not infer source filename") end return sourcefile elseif type(name) == "table" then local func = findfunction(name, {}) if not func then error("Could not find a function within " .. tostring(name)) end return getfilename(func) else if type(name) ~= "string" then error("Bad argument: " .. tostring(name)) end if util.file_exists(name) then return name end local success, result = pcall(require, name) if not success then error("Module/file '" .. name .. "' was not found") end if type(result) ~= "table" and type(result) ~= "function" then error("Module '" .. name .. "' did not return a result to lookup its file name") end return getfilename(result) end end -- Escapes a filename. -- Escapes magic pattern characters, removes .lua extension -- and replaces dir seps by '/'. local function escapefilename(name) return name:gsub("%.lua$", ""):gsub("[%%%^%$%.%(%)%[%]%+%*%-%?]","%%%0"):gsub("\\", "/") end local function addfiletolist(name, list) local f = "^"..escapefilename(getfilename(name)).."$" table.insert(list, f) return f end local function addtreetolist(name, level, list) local f = escapefilename(getfilename(name)) if level or f:match("/init$") then -- chop the last backslash and everything after it f = f:match("^(.*)/") or f end local t = "^"..f.."/" -- the tree behind the file f = "^"..f.."$" -- the file table.insert(list, f) table.insert(list, t) return f, t end -- Returns a pcall result, with the initial 'true' value removed -- and 'false' replaced with nil. local function checkresult(ok, ...) if ok then return ... -- success, strip 'true' value else return nil, ... -- failure; nil + error end end ------------------------------------------------------------------- -- Adds a file to the exclude list (see `luacov.defaults`). -- If passed a function, then through debuginfo the source filename is collected. In case of a table -- it will recursively search the table for a function, which is then resolved to a filename through debuginfo. -- If the parameter is a string, it will first check if a file by that name exists. If it doesn't exist -- it will call `require(name)` to load a module by that name, and the result of require (function or -- table expected) is used as described above to get the sourcefile. -- @param name -- * string; literal filename, -- * string; modulename as passed to require(), -- * function; where containing file is looked up, -- * table; module table where containing file is looked up -- @return the pattern as added to the list, or nil + error function runner.excludefile(name) return checkresult(pcall(addfiletolist, name, runner.configuration.exclude)) end ------------------------------------------------------------------- -- Adds a file to the include list (see `luacov.defaults`). -- @param name see `excludefile` -- @return the pattern as added to the list, or nil + error function runner.includefile(name) return checkresult(pcall(addfiletolist, name, runner.configuration.include)) end ------------------------------------------------------------------- -- Adds a tree to the exclude list (see `luacov.defaults`). -- If `name = 'luacov'` and `level = nil` then -- module 'luacov' (luacov.lua) and the tree 'luacov' (containing `luacov/runner.lua` etc.) is excluded. -- If `name = 'pl.path'` and `level = true` then -- module 'pl' (pl.lua) and the tree 'pl' (containing `pl/path.lua` etc.) is excluded. -- NOTE: in case of an 'init.lua' file, the 'level' parameter will always be set -- @param name see `excludefile` -- @param level if truthy then one level up is added, including the tree -- @return the 2 patterns as added to the list (file and tree), or nil + error function runner.excludetree(name, level) return checkresult(pcall(addtreetolist, name, level, runner.configuration.exclude)) end ------------------------------------------------------------------- -- Adds a tree to the include list (see `luacov.defaults`). -- @param name see `excludefile` -- @param level see `includetree` -- @return the 2 patterns as added to the list (file and tree), or nil + error function runner.includetree(name, level) return checkresult(pcall(addtreetolist, name, level, runner.configuration.include)) end return setmetatable(runner, {__call = function(_, configfile) runner.init(configfile) end}) ================================================ FILE: modules/luacov/stats.lua ================================================ ----------------------------------------------------- -- Manages the file with statistics (being) collected. -- @class module -- @name luacov.stats local stats = {} ----------------------------------------------------- -- Loads the stats file. -- @param statsfile path to the stats file. -- @return table with data or nil if couldn't load. -- The table maps filenames to stats tables. -- Per-file tables map line numbers to hits or nils when there are no hits. -- Additionally, .max field contains maximum line number -- and .max_hits contains maximum number of hits in the file. function stats.load(statsfile) local data = {} local fd = io.open(statsfile, "r") if not fd then return nil end while true do local max = fd:read("*n") if not max then break end if fd:read(1) ~= ":" then break end local filename = fd:read("*l") if not filename then break end data[filename] = { max = max, max_hits = 0 } for i = 1, max do local hits = fd:read("*n") if not hits then break end if fd:read(1) ~= " " then break end if hits > 0 then data[filename][i] = hits data[filename].max_hits = math.max(data[filename].max_hits, hits) end end end fd:close() return data end ----------------------------------------------------- -- Saves data to the stats file. -- @param statsfile path to the stats file. -- @param data data to store. function stats.save(statsfile, data) local fd = assert(io.open(statsfile, "w")) local filenames = {} for filename in pairs(data) do table.insert(filenames, filename) end table.sort(filenames) for _, filename in ipairs(filenames) do local filedata = data[filename] fd:write(filedata.max, ":", filename, "\n") for i = 1, filedata.max do fd:write(tostring(filedata[i] or 0), " ") end fd:write("\n") end fd:close() end return stats ================================================ FILE: modules/luacov/tick.lua ================================================ --- Load luacov using this if you want it to periodically -- save the stats file. This is useful if your script is -- a daemon (i.e., does not properly terminate). -- @class module -- @name luacov.tick -- @see luacov.defaults.savestepsize local runner = require("luacov.runner") runner.tick = true runner.init() return {} ================================================ FILE: modules/luacov/util.lua ================================================ --------------------------------------------------- -- Utility module. -- @class module -- @name luacov.util local util = {} --- Removes a prefix from a string if it's present. -- @param str a string. -- @param prefix a prefix string. -- @return original string if does not start with prefix -- or string without prefix. function util.unprefix(str, prefix) if str:sub(1, #prefix) == prefix then return str:sub(#prefix + 1) else return str end end -- Returns contents of a file or nil + error message. local function read_file(name) local f, open_err = io.open(name, "rb") if not f then return nil, util.unprefix(open_err, name .. ": ") end local contents, read_err = f:read("*a") f:close() if contents then return contents else return nil, read_err end end --- Loads a string. -- @param str a string. -- @param[opt] env environment table. -- @param[opt] chunkname chunk name. function util.load_string(str, env, chunkname) if _VERSION:find("5%.1") then local func, err = loadstring(str, chunkname) -- luacheck: compat if not func then return nil, err end if env then setfenv(func, env) -- luacheck: compat end return func else return load(str, chunkname, "bt", env or _ENV) -- luacheck: compat end end --- Load a config file. -- Reads, loads and runs a Lua file in an environment. -- @param name file name. -- @param env environment table. -- @return true and the first return value of config on success, -- nil + error type + error message on failure, where error type -- can be "read", "load" or "run". function util.load_config(name, env) local src, read_err = read_file(name) if not src then return nil, "read", read_err end local func, load_err = util.load_string(src, env, "@config") if not func then return nil, "load", "line " .. util.unprefix(load_err, "config:") end local ok, ret = pcall(func) if not ok then return nil, "run", "line " .. util.unprefix(ret, "config:") end return true, ret end --- Checks if a file exists. -- @param name file name. -- @return true if file can be opened, false otherwise. function util.file_exists(name) local f = io.open(name) if f then f:close() return true else return false end end return util ================================================ FILE: modules/luacov.lua ================================================ --- Loads `luacov.runner` and immediately starts it. -- Useful for launching scripts from the command-line. Returns the `luacov.runner` module. -- @class module -- @name luacov -- @usage lua -lluacov sometest.lua local runner = require("luacov.runner") runner.init() return runner ================================================ FILE: modules/luaunit.lua ================================================ --[[ luaunit.lua Description: A unit testing framework Homepage: https://github.com/bluebird75/luaunit Development by Philippe Fremy Based on initial work of Ryu, Gwang (http://www.gpgstudy.com/gpgiki/LuaUnit) License: BSD License, see LICENSE.txt ]]-- require("math") local M={} -- private exported functions (for testing) M.private = {} M.VERSION='3.4' M._VERSION=M.VERSION -- For LuaUnit v2 compatibility -- a version which distinguish between regular Lua and LuaJit M._LUAVERSION = (jit and jit.version) or _VERSION --[[ Some people like assertEquals( actual, expected ) and some people prefer assertEquals( expected, actual ). ]]-- M.ORDER_ACTUAL_EXPECTED = true M.PRINT_TABLE_REF_IN_ERROR_MSG = false M.LINE_LENGTH = 80 M.TABLE_DIFF_ANALYSIS_THRESHOLD = 10 -- display deep analysis for more than 10 items M.LIST_DIFF_ANALYSIS_THRESHOLD = 10 -- display deep analysis for more than 10 items -- this setting allow to remove entries from the stack-trace, for -- example to hide a call to a framework which would be calling luaunit M.STRIP_EXTRA_ENTRIES_IN_STACK_TRACE = 0 --[[ EPS is meant to help with Lua's floating point math in simple corner cases like almostEquals(1.1-0.1, 1), which may not work as-is (e.g. on numbers with rational binary representation) if the user doesn't provide some explicit error margin. The default margin used by almostEquals() in such cases is EPS; and since Lua may be compiled with different numeric precisions (single vs. double), we try to select a useful default for it dynamically. Note: If the initial value is not acceptable, it can be changed by the user to better suit specific needs. See also: https://en.wikipedia.org/wiki/Machine_epsilon ]] M.EPS = 2^-52 -- = machine epsilon for "double", ~2.22E-16 if math.abs(1.1 - 1 - 0.1) > M.EPS then -- rounding error is above EPS, assume single precision M.EPS = 2^-23 -- = machine epsilon for "float", ~1.19E-07 end -- set this to false to debug luaunit local STRIP_LUAUNIT_FROM_STACKTRACE = true M.VERBOSITY_DEFAULT = 10 M.VERBOSITY_LOW = 1 M.VERBOSITY_QUIET = 0 M.VERBOSITY_VERBOSE = 20 M.DEFAULT_DEEP_ANALYSIS = nil M.FORCE_DEEP_ANALYSIS = true M.DISABLE_DEEP_ANALYSIS = false -- set EXPORT_ASSERT_TO_GLOBALS to have all asserts visible as global values -- EXPORT_ASSERT_TO_GLOBALS = true -- we need to keep a copy of the script args before it is overriden local cmdline_argv = rawget(_G, "arg") M.FAILURE_PREFIX = 'LuaUnit test FAILURE: ' -- prefix string for failed tests M.SUCCESS_PREFIX = 'LuaUnit test SUCCESS: ' -- prefix string for successful tests finished early M.SKIP_PREFIX = 'LuaUnit test SKIP: ' -- prefix string for skipped tests M.USAGE=[[Usage: lua [options] [testname1 [testname2] ... ] Options: -h, --help: Print this help --version: Print version information -v, --verbose: Increase verbosity -q, --quiet: Set verbosity to minimum -e, --error: Stop on first error -f, --failure: Stop on first failure or error -s, --shuffle: Shuffle tests before running them -o, --output OUTPUT: Set output type to OUTPUT Possible values: text, tap, junit, nil -n, --name NAME: For junit only, mandatory name of xml file -r, --repeat NUM: Execute all tests NUM times, e.g. to trig the JIT -p, --pattern PATTERN: Execute all test names matching the Lua PATTERN May be repeated to include several patterns Make sure you escape magic chars like +? with % -x, --exclude PATTERN: Exclude all test names matching the Lua PATTERN May be repeated to exclude several patterns Make sure you escape magic chars like +? with % testname1, testname2, ... : tests to run in the form of testFunction, TestClass or TestClass.testMethod You may also control LuaUnit options with the following environment variables: * LUAUNIT_OUTPUT: same as --output * LUAUNIT_JUNIT_FNAME: same as --name ]] ---------------------------------------------------------------- -- -- general utility functions -- ---------------------------------------------------------------- --[[ Note on catching exit I have seen the case where running a big suite of test cases and one of them would perform a os.exit(0), making the outside world think that the full test suite was executed successfully. This is an attempt to mitigate this problem: we override os.exit() to now let a test exit the framework while we are running. When we are not running, it behaves normally. ]] M.oldOsExit = os.exit os.exit = function(...) if M.LuaUnit and #M.LuaUnit.instances ~= 0 then local msg = [[You are trying to exit but there is still a running instance of LuaUnit. LuaUnit expects to run until the end before exiting with a complete status of successful/failed tests. To force exit LuaUnit while running, please call before os.exit (assuming lu is the luaunit module loaded): lu.unregisterCurrentSuite() ]] M.private.error_fmt(2, msg) end M.oldOsExit(...) end local function pcall_or_abort(func, ...) -- unpack is a global function for Lua 5.1, otherwise use table.unpack local unpack = rawget(_G, "unpack") or table.unpack local result = {pcall(func, ...)} if not result[1] then -- an error occurred print(result[2]) -- error message print() print(M.USAGE) os.exit(-1) end return unpack(result, 2) end local crossTypeOrdering = { number = 1, boolean = 2, string = 3, table = 4, other = 5 } local crossTypeComparison = { number = function(a, b) return a < b end, string = function(a, b) return a < b end, other = function(a, b) return tostring(a) < tostring(b) end, } local function crossTypeSort(a, b) local type_a, type_b = type(a), type(b) if type_a == type_b then local func = crossTypeComparison[type_a] or crossTypeComparison.other return func(a, b) end type_a = crossTypeOrdering[type_a] or crossTypeOrdering.other type_b = crossTypeOrdering[type_b] or crossTypeOrdering.other return type_a < type_b end local function __genSortedIndex( t ) -- Returns a sequence consisting of t's keys, sorted. local sortedIndex = {} for key,_ in pairs(t) do table.insert(sortedIndex, key) end table.sort(sortedIndex, crossTypeSort) return sortedIndex end M.private.__genSortedIndex = __genSortedIndex local function sortedNext(state, control) -- Equivalent of the next() function of table iteration, but returns the -- keys in sorted order (see __genSortedIndex and crossTypeSort). -- The state is a temporary variable during iteration and contains the -- sorted key table (state.sortedIdx). It also stores the last index (into -- the keys) used by the iteration, to find the next one quickly. local key --print("sortedNext: control = "..tostring(control) ) if control == nil then -- start of iteration state.count = #state.sortedIdx state.lastIdx = 1 key = state.sortedIdx[1] return key, state.t[key] end -- normally, we expect the control variable to match the last key used if control ~= state.sortedIdx[state.lastIdx] then -- strange, we have to find the next value by ourselves -- the key table is sorted in crossTypeSort() order! -> use bisection local lower, upper = 1, state.count repeat state.lastIdx = math.modf((lower + upper) / 2) key = state.sortedIdx[state.lastIdx] if key == control then break -- key found (and thus prev index) end if crossTypeSort(key, control) then -- key < control, continue search "right" (towards upper bound) lower = state.lastIdx + 1 else -- key > control, continue search "left" (towards lower bound) upper = state.lastIdx - 1 end until lower > upper if lower > upper then -- only true if the key wasn't found, ... state.lastIdx = state.count -- ... so ensure no match in code below end end -- proceed by retrieving the next value (or nil) from the sorted keys state.lastIdx = state.lastIdx + 1 key = state.sortedIdx[state.lastIdx] if key then return key, state.t[key] end -- getting here means returning `nil`, which will end the iteration end local function sortedPairs(tbl) -- Equivalent of the pairs() function on tables. Allows to iterate in -- sorted order. As required by "generic for" loops, this will return the -- iterator (function), an "invariant state", and the initial control value. -- (see http://www.lua.org/pil/7.2.html) return sortedNext, {t = tbl, sortedIdx = __genSortedIndex(tbl)}, nil end M.private.sortedPairs = sortedPairs -- seed the random with a strongly varying seed math.randomseed(math.floor(os.clock()*1E11)) local function randomizeTable( t ) -- randomize the item orders of the table t for i = #t, 2, -1 do local j = math.random(i) if i ~= j then t[i], t[j] = t[j], t[i] end end end M.private.randomizeTable = randomizeTable local function strsplit(delimiter, text) -- Split text into a list consisting of the strings in text, separated -- by strings matching delimiter (which may _NOT_ be a pattern). -- Example: strsplit(", ", "Anna, Bob, Charlie, Dolores") if delimiter == "" or delimiter == nil then -- this would result in endless loops error("delimiter is nil or empty string!") end if text == nil then return nil end local list, pos, first, last = {}, 1 while true do first, last = text:find(delimiter, pos, true) if first then -- found? table.insert(list, text:sub(pos, first - 1)) pos = last + 1 else table.insert(list, text:sub(pos)) break end end return list end M.private.strsplit = strsplit local function hasNewLine( s ) -- return true if s has a newline return (string.find(s, '\n', 1, true) ~= nil) end M.private.hasNewLine = hasNewLine local function prefixString( prefix, s ) -- Prefix all the lines of s with prefix return prefix .. string.gsub(s, '\n', '\n' .. prefix) end M.private.prefixString = prefixString local function strMatch(s, pattern, start, final ) -- return true if s matches completely the pattern from index start to index end -- return false in every other cases -- if start is nil, matches from the beginning of the string -- if final is nil, matches to the end of the string start = start or 1 final = final or string.len(s) local foundStart, foundEnd = string.find(s, pattern, start, false) return foundStart == start and foundEnd == final end M.private.strMatch = strMatch local function patternFilter(patterns, expr) -- Run `expr` through the inclusion and exclusion rules defined in patterns -- and return true if expr shall be included, false for excluded. -- Inclusion pattern are defined as normal patterns, exclusions -- patterns start with `!` and are followed by a normal pattern -- result: nil = UNKNOWN (not matched yet), true = ACCEPT, false = REJECT -- default: true if no explicit "include" is found, set to false otherwise local default, result = true, nil if patterns ~= nil then for _, pattern in ipairs(patterns) do local exclude = pattern:sub(1,1) == '!' if exclude then pattern = pattern:sub(2) else -- at least one include pattern specified, a match is required default = false end -- print('pattern: ',pattern) -- print('exclude: ',exclude) -- print('default: ',default) if string.find(expr, pattern) then -- set result to false when excluding, true otherwise result = not exclude end end end if result ~= nil then return result end return default end M.private.patternFilter = patternFilter local function xmlEscape( s ) -- Return s escaped for XML attributes -- escapes table: -- " " -- ' ' -- < < -- > > -- & & return string.gsub( s, '.', { ['&'] = "&", ['"'] = """, ["'"] = "'", ['<'] = "<", ['>'] = ">", } ) end M.private.xmlEscape = xmlEscape local function xmlCDataEscape( s ) -- Return s escaped for CData section, escapes: "]]>" return string.gsub( s, ']]>', ']]>' ) end M.private.xmlCDataEscape = xmlCDataEscape local function lstrip( s ) --[[Return s with all leading white spaces and tabs removed]] local idx = 0 while idx < s:len() do idx = idx + 1 local c = s:sub(idx,idx) if c ~= ' ' and c ~= '\t' then break end end return s:sub(idx) end M.private.lstrip = lstrip local function extractFileLineInfo( s ) --[[ From a string in the form "(leading spaces) dir1/dir2\dir3\file.lua:linenb: msg" Return the "file.lua:linenb" information ]] local s2 = lstrip(s) local firstColon = s2:find(':', 1, true) if firstColon == nil then -- string is not in the format file:line: return s end local secondColon = s2:find(':', firstColon+1, true) if secondColon == nil then -- string is not in the format file:line: return s end return s2:sub(1, secondColon-1) end M.private.extractFileLineInfo = extractFileLineInfo local function stripLuaunitTrace2( stackTrace, errMsg ) --[[ -- Example of a traceback: < [C]: in function 'xpcall' ./luaunit.lua:1449: in function 'protectedCall' ./luaunit.lua:1508: in function 'execOneFunction' ./luaunit.lua:1596: in function 'runSuiteByInstances' ./luaunit.lua:1660: in function 'runSuiteByNames' ./luaunit.lua:1736: in function 'runSuite' example_with_luaunit.lua:140: in main chunk [C]: in ?>> error message: <> Other example: < [C]: in function 'xpcall' ./luaunit.lua:1517: in function 'protectedCall' ./luaunit.lua:1578: in function 'execOneFunction' ./luaunit.lua:1677: in function 'runSuiteByInstances' ./luaunit.lua:1730: in function 'runSuiteByNames' ./luaunit.lua:1806: in function 'runSuite' example_with_luaunit.lua:140: in main chunk [C]: in ?>> error message: <> < [C]: in function 'xpcall' luaunit2/luaunit.lua:1532: in function 'protectedCall' luaunit2/luaunit.lua:1591: in function 'execOneFunction' luaunit2/luaunit.lua:1679: in function 'runSuiteByInstances' luaunit2/luaunit.lua:1743: in function 'runSuiteByNames' luaunit2/luaunit.lua:1819: in function 'runSuite' luaunit2/example_with_luaunit.lua:140: in main chunk [C]: in ?>> error message: <> -- first line is "stack traceback": KEEP -- next line may be luaunit line: REMOVE -- next lines are call in the program under testOk: REMOVE -- next lines are calls from luaunit to call the program under test: KEEP -- Strategy: -- keep first line -- remove lines that are part of luaunit -- kepp lines until we hit a luaunit line The strategy for stripping is: * keep first line "stack traceback:" * part1: * analyse all lines of the stack from bottom to top of the stack (first line to last line) * extract the "file:line:" part of the line * compare it with the "file:line" part of the error message * if it does not match strip the line * if it matches, keep the line and move to part 2 * part2: * anything NOT starting with luaunit.lua is the interesting part of the stack trace * anything starting again with luaunit.lua is part of the test launcher and should be stripped out ]] local function isLuaunitInternalLine( s ) -- return true if line of stack trace comes from inside luaunit return s:find('[/\\]luaunit%.lua:%d+: ') ~= nil end -- print( '<<'..stackTrace..'>>' ) local t = strsplit( '\n', stackTrace ) -- print( prettystr(t) ) local idx = 2 local errMsgFileLine = extractFileLineInfo(errMsg) -- print('emfi="'..errMsgFileLine..'"') -- remove lines that are still part of luaunit while t[idx] and extractFileLineInfo(t[idx]) ~= errMsgFileLine do -- print('Removing : '..t[idx] ) table.remove(t, idx) end -- keep lines until we hit luaunit again while t[idx] and (not isLuaunitInternalLine(t[idx])) do -- print('Keeping : '..t[idx] ) idx = idx + 1 end -- remove remaining luaunit lines while t[idx] do -- print('Removing2 : '..t[idx] ) table.remove(t, idx) end -- print( prettystr(t) ) return table.concat( t, '\n') end M.private.stripLuaunitTrace2 = stripLuaunitTrace2 local function prettystr_sub(v, indentLevel, printTableRefs, cycleDetectTable ) local type_v = type(v) if "string" == type_v then -- use clever delimiters according to content: -- enclose with single quotes if string contains ", but no ' if v:find('"', 1, true) and not v:find("'", 1, true) then return "'" .. v .. "'" end -- use double quotes otherwise, escape embedded " return '"' .. v:gsub('"', '\\"') .. '"' elseif "table" == type_v then --if v.__class__ then -- return string.gsub( tostring(v), 'table', v.__class__ ) --end return M.private._table_tostring(v, indentLevel, printTableRefs, cycleDetectTable) elseif "number" == type_v then -- eliminate differences in formatting between various Lua versions if v ~= v then return "#NaN" -- "not a number" end if v == math.huge then return "#Inf" -- "infinite" end if v == -math.huge then return "-#Inf" end if _VERSION == "Lua 5.3" then local i = math.tointeger(v) if i then return tostring(i) end end end return tostring(v) end local function prettystr( v ) --[[ Pretty string conversion, to display the full content of a variable of any type. * string are enclosed with " by default, or with ' if string contains a " * tables are expanded to show their full content, with indentation in case of nested tables ]]-- local cycleDetectTable = {} local s = prettystr_sub(v, 1, M.PRINT_TABLE_REF_IN_ERROR_MSG, cycleDetectTable) if cycleDetectTable.detected and not M.PRINT_TABLE_REF_IN_ERROR_MSG then -- some table contain recursive references, -- so we must recompute the value by including all table references -- else the result looks like crap cycleDetectTable = {} s = prettystr_sub(v, 1, true, cycleDetectTable) end return s end M.prettystr = prettystr function M.adjust_err_msg_with_iter( err_msg, iter_msg ) --[[ Adjust the error message err_msg: trim the FAILURE_PREFIX or SUCCESS_PREFIX information if needed, add the iteration message if any and return the result. err_msg: string, error message captured with pcall iter_msg: a string describing the current iteration ("iteration N") or nil if there is no iteration in this test. Returns: (new_err_msg, test_status) new_err_msg: string, adjusted error message, or nil in case of success test_status: M.NodeStatus.FAIL, SUCCESS or ERROR according to the information contained in the error message. ]] if iter_msg then iter_msg = iter_msg..', ' else iter_msg = '' end local RE_FILE_LINE = '.*:%d+: ' -- error message is not necessarily a string, -- so convert the value to string with prettystr() if type( err_msg ) ~= 'string' then err_msg = prettystr( err_msg ) end if (err_msg:find( M.SUCCESS_PREFIX ) == 1) or err_msg:match( '('..RE_FILE_LINE..')' .. M.SUCCESS_PREFIX .. ".*" ) then -- test finished early with success() return nil, M.NodeStatus.SUCCESS end if (err_msg:find( M.SKIP_PREFIX ) == 1) or (err_msg:match( '('..RE_FILE_LINE..')' .. M.SKIP_PREFIX .. ".*" ) ~= nil) then -- substitute prefix by iteration message err_msg = err_msg:gsub('.*'..M.SKIP_PREFIX, iter_msg, 1) -- print("failure detected") return err_msg, M.NodeStatus.SKIP end if (err_msg:find( M.FAILURE_PREFIX ) == 1) or (err_msg:match( '('..RE_FILE_LINE..')' .. M.FAILURE_PREFIX .. ".*" ) ~= nil) then -- substitute prefix by iteration message err_msg = err_msg:gsub(M.FAILURE_PREFIX, iter_msg, 1) -- print("failure detected") return err_msg, M.NodeStatus.FAIL end -- print("error detected") -- regular error, not a failure if iter_msg then local match -- "./test\\test_luaunit.lua:2241: some error msg match = err_msg:match( '(.*:%d+: ).*' ) if match then err_msg = err_msg:gsub( match, match .. iter_msg ) else -- no file:line: infromation, just add the iteration info at the beginning of the line err_msg = iter_msg .. err_msg end end return err_msg, M.NodeStatus.ERROR end local function tryMismatchFormatting( table_a, table_b, doDeepAnalysis, margin ) --[[ Prepares a nice error message when comparing tables, performing a deeper analysis. Arguments: * table_a, table_b: tables to be compared * doDeepAnalysis: M.DEFAULT_DEEP_ANALYSIS: (the default if not specified) perform deep analysis only for big lists and big dictionnaries M.FORCE_DEEP_ANALYSIS : always perform deep analysis M.DISABLE_DEEP_ANALYSIS: never perform deep analysis * margin: supplied only for almost equality Returns: {success, result} * success: false if deep analysis could not be performed in this case, just use standard assertion message * result: if success is true, a multi-line string with deep analysis of the two lists ]] -- check if table_a & table_b are suitable for deep analysis if type(table_a) ~= 'table' or type(table_b) ~= 'table' then return false end if doDeepAnalysis == M.DISABLE_DEEP_ANALYSIS then return false end local len_a, len_b, isPureList = #table_a, #table_b, true for k1, v1 in pairs(table_a) do if type(k1) ~= 'number' or k1 > len_a then -- this table a mapping isPureList = false break end end if isPureList then for k2, v2 in pairs(table_b) do if type(k2) ~= 'number' or k2 > len_b then -- this table a mapping isPureList = false break end end end if isPureList and math.min(len_a, len_b) < M.LIST_DIFF_ANALYSIS_THRESHOLD then if not (doDeepAnalysis == M.FORCE_DEEP_ANALYSIS) then return false end end if isPureList then return M.private.mismatchFormattingPureList( table_a, table_b, margin ) else -- only work on mapping for the moment -- return M.private.mismatchFormattingMapping( table_a, table_b, doDeepAnalysis ) return false end end M.private.tryMismatchFormatting = tryMismatchFormatting local function getTaTbDescr() if not M.ORDER_ACTUAL_EXPECTED then return 'expected', 'actual' end return 'actual', 'expected' end local function extendWithStrFmt( res, ... ) table.insert( res, string.format( ... ) ) end local function mismatchFormattingMapping( table_a, table_b, doDeepAnalysis ) --[[ Prepares a nice error message when comparing tables which are not pure lists, performing a deeper analysis. Returns: {success, result} * success: false if deep analysis could not be performed in this case, just use standard assertion message * result: if success is true, a multi-line string with deep analysis of the two lists ]] -- disable for the moment --[[ local result = {} local descrTa, descrTb = getTaTbDescr() local keysCommon = {} local keysOnlyTa = {} local keysOnlyTb = {} local keysDiffTaTb = {} local k, v for k,v in pairs( table_a ) do if is_equal( v, table_b[k] ) then table.insert( keysCommon, k ) else if table_b[k] == nil then table.insert( keysOnlyTa, k ) else table.insert( keysDiffTaTb, k ) end end end for k,v in pairs( table_b ) do if not is_equal( v, table_a[k] ) and table_a[k] == nil then table.insert( keysOnlyTb, k ) end end local len_a = #keysCommon + #keysDiffTaTb + #keysOnlyTa local len_b = #keysCommon + #keysDiffTaTb + #keysOnlyTb local limited_display = (len_a < 5 or len_b < 5) if math.min(len_a, len_b) < M.TABLE_DIFF_ANALYSIS_THRESHOLD then return false end if not limited_display then if len_a == len_b then extendWithStrFmt( result, 'Table A (%s) and B (%s) both have %d items', descrTa, descrTb, len_a ) else extendWithStrFmt( result, 'Table A (%s) has %d items and table B (%s) has %d items', descrTa, len_a, descrTb, len_b ) end if #keysCommon == 0 and #keysDiffTaTb == 0 then table.insert( result, 'Table A and B have no keys in common, they are totally different') else local s_other = 'other ' if #keysCommon then extendWithStrFmt( result, 'Table A and B have %d identical items', #keysCommon ) else table.insert( result, 'Table A and B have no identical items' ) s_other = '' end if #keysDiffTaTb ~= 0 then result[#result] = string.format( '%s and %d items differing present in both tables', result[#result], #keysDiffTaTb) else result[#result] = string.format( '%s and no %sitems differing present in both tables', result[#result], s_other, #keysDiffTaTb) end end extendWithStrFmt( result, 'Table A has %d keys not present in table B and table B has %d keys not present in table A', #keysOnlyTa, #keysOnlyTb ) end local function keytostring(k) if "string" == type(k) and k:match("^[_%a][_%w]*$") then return k end return prettystr(k) end if #keysDiffTaTb ~= 0 then table.insert( result, 'Items differing in A and B:') for k,v in sortedPairs( keysDiffTaTb ) do extendWithStrFmt( result, ' - A[%s]: %s', keytostring(v), prettystr(table_a[v]) ) extendWithStrFmt( result, ' + B[%s]: %s', keytostring(v), prettystr(table_b[v]) ) end end if #keysOnlyTa ~= 0 then table.insert( result, 'Items only in table A:' ) for k,v in sortedPairs( keysOnlyTa ) do extendWithStrFmt( result, ' - A[%s]: %s', keytostring(v), prettystr(table_a[v]) ) end end if #keysOnlyTb ~= 0 then table.insert( result, 'Items only in table B:' ) for k,v in sortedPairs( keysOnlyTb ) do extendWithStrFmt( result, ' + B[%s]: %s', keytostring(v), prettystr(table_b[v]) ) end end if #keysCommon ~= 0 then table.insert( result, 'Items common to A and B:') for k,v in sortedPairs( keysCommon ) do extendWithStrFmt( result, ' = A and B [%s]: %s', keytostring(v), prettystr(table_a[v]) ) end end return true, table.concat( result, '\n') ]] end M.private.mismatchFormattingMapping = mismatchFormattingMapping local function mismatchFormattingPureList( table_a, table_b, margin ) --[[ Prepares a nice error message when comparing tables which are lists, performing a deeper analysis. margin is supplied only for almost equality Returns: {success, result} * success: false if deep analysis could not be performed in this case, just use standard assertion message * result: if success is true, a multi-line string with deep analysis of the two lists ]] local result, descrTa, descrTb = {}, getTaTbDescr() local len_a, len_b, refa, refb = #table_a, #table_b, '', '' if M.PRINT_TABLE_REF_IN_ERROR_MSG then refa, refb = string.format( '<%s> ', M.private.table_ref(table_a)), string.format('<%s> ', M.private.table_ref(table_b) ) end local longest, shortest = math.max(len_a, len_b), math.min(len_a, len_b) local deltalv = longest - shortest local commonUntil = shortest for i = 1, shortest do if not M.private.is_table_equals(table_a[i], table_b[i], margin) then commonUntil = i - 1 break end end local commonBackTo = shortest - 1 for i = 0, shortest - 1 do if not M.private.is_table_equals(table_a[len_a-i], table_b[len_b-i], margin) then commonBackTo = i - 1 break end end table.insert( result, 'List difference analysis:' ) if len_a == len_b then -- TODO: handle expected/actual naming extendWithStrFmt( result, '* lists %sA (%s) and %sB (%s) have the same size', refa, descrTa, refb, descrTb ) else extendWithStrFmt( result, '* list sizes differ: list %sA (%s) has %d items, list %sB (%s) has %d items', refa, descrTa, len_a, refb, descrTb, len_b ) end extendWithStrFmt( result, '* lists A and B start differing at index %d', commonUntil+1 ) if commonBackTo >= 0 then if deltalv > 0 then extendWithStrFmt( result, '* lists A and B are equal again from index %d for A, %d for B', len_a-commonBackTo, len_b-commonBackTo ) else extendWithStrFmt( result, '* lists A and B are equal again from index %d', len_a-commonBackTo ) end end local function insertABValue(ai, bi) bi = bi or ai if M.private.is_table_equals( table_a[ai], table_b[bi], margin) then return extendWithStrFmt( result, ' = A[%d], B[%d]: %s', ai, bi, prettystr(table_a[ai]) ) else extendWithStrFmt( result, ' - A[%d]: %s', ai, prettystr(table_a[ai])) extendWithStrFmt( result, ' + B[%d]: %s', bi, prettystr(table_b[bi])) end end -- common parts to list A & B, at the beginning if commonUntil > 0 then table.insert( result, '* Common parts:' ) for i = 1, commonUntil do insertABValue( i ) end end -- diffing parts to list A & B if commonUntil < shortest - commonBackTo - 1 then table.insert( result, '* Differing parts:' ) for i = commonUntil + 1, shortest - commonBackTo - 1 do insertABValue( i ) end end -- display indexes of one list, with no match on other list if shortest - commonBackTo <= longest - commonBackTo - 1 then table.insert( result, '* Present only in one list:' ) for i = shortest - commonBackTo, longest - commonBackTo - 1 do if len_a > len_b then extendWithStrFmt( result, ' - A[%d]: %s', i, prettystr(table_a[i]) ) -- table.insert( result, '+ (no matching B index)') else -- table.insert( result, '- no matching A index') extendWithStrFmt( result, ' + B[%d]: %s', i, prettystr(table_b[i]) ) end end end -- common parts to list A & B, at the end if commonBackTo >= 0 then table.insert( result, '* Common parts at the end of the lists' ) for i = longest - commonBackTo, longest do if len_a > len_b then insertABValue( i, i-deltalv ) else insertABValue( i-deltalv, i ) end end end return true, table.concat( result, '\n') end M.private.mismatchFormattingPureList = mismatchFormattingPureList local function prettystrPairs(value1, value2, suffix_a, suffix_b) --[[ This function helps with the recurring task of constructing the "expected vs. actual" error messages. It takes two arbitrary values and formats corresponding strings with prettystr(). To keep the (possibly complex) output more readable in case the resulting strings contain line breaks, they get automatically prefixed with additional newlines. Both suffixes are optional (default to empty strings), and get appended to the "value1" string. "suffix_a" is used if line breaks were encountered, "suffix_b" otherwise. Returns the two formatted strings (including padding/newlines). ]] local str1, str2 = prettystr(value1), prettystr(value2) if hasNewLine(str1) or hasNewLine(str2) then -- line break(s) detected, add padding return "\n" .. str1 .. (suffix_a or ""), "\n" .. str2 end return str1 .. (suffix_b or ""), str2 end M.private.prettystrPairs = prettystrPairs local UNKNOWN_REF = 'table 00-unknown ref' local ref_generator = { value=1, [UNKNOWN_REF]=0 } local function table_ref( t ) -- return the default tostring() for tables, with the table ID, even if the table has a metatable -- with the __tostring converter local ref = '' local mt = getmetatable( t ) if mt == nil then ref = tostring(t) else local success, result success, result = pcall(setmetatable, t, nil) if not success then -- protected table, if __tostring is defined, we can -- not get the reference. And we can not know in advance. ref = tostring(t) if not ref:match( 'table: 0?x?[%x]+' ) then return UNKNOWN_REF end else ref = tostring(t) setmetatable( t, mt ) end end -- strip the "table: " part ref = ref:sub(8) if ref ~= UNKNOWN_REF and ref_generator[ref] == nil then -- Create a new reference number ref_generator[ref] = ref_generator.value ref_generator.value = ref_generator.value+1 end if M.PRINT_TABLE_REF_IN_ERROR_MSG then return string.format('table %02d-%s', ref_generator[ref], ref) else return string.format('table %02d', ref_generator[ref]) end end M.private.table_ref = table_ref local TABLE_TOSTRING_SEP = ", " local TABLE_TOSTRING_SEP_LEN = string.len(TABLE_TOSTRING_SEP) local function _table_tostring( tbl, indentLevel, printTableRefs, cycleDetectTable ) printTableRefs = printTableRefs or M.PRINT_TABLE_REF_IN_ERROR_MSG cycleDetectTable = cycleDetectTable or {} cycleDetectTable[tbl] = true local result, dispOnMultLines = {}, false -- like prettystr but do not enclose with "" if the string is just alphanumerical -- this is better for displaying table keys who are often simple strings local function keytostring(k) if "string" == type(k) and k:match("^[_%a][_%w]*$") then return k end return prettystr_sub(k, indentLevel+1, printTableRefs, cycleDetectTable) end local mt = getmetatable( tbl ) if mt and mt.__tostring then -- if table has a __tostring() function in its metatable, use it to display the table -- else, compute a regular table result = tostring(tbl) if type(result) ~= 'string' then return string.format( '', prettystr(result) ) end result = strsplit( '\n', result ) return M.private._table_tostring_format_multiline_string( result, indentLevel ) else -- no metatable, compute the table representation local entry, count, seq_index = nil, 0, 1 for k, v in sortedPairs( tbl ) do -- key part if k == seq_index then -- for the sequential part of tables, we'll skip the "=" output entry = '' seq_index = seq_index + 1 elseif cycleDetectTable[k] then -- recursion in the key detected cycleDetectTable.detected = true entry = "<"..table_ref(k)..">=" else entry = keytostring(k) .. "=" end -- value part if cycleDetectTable[v] then -- recursion in the value detected! cycleDetectTable.detected = true entry = entry .. "<"..table_ref(v)..">" else entry = entry .. prettystr_sub( v, indentLevel+1, printTableRefs, cycleDetectTable ) end count = count + 1 result[count] = entry end return M.private._table_tostring_format_result( tbl, result, indentLevel, printTableRefs ) end end M.private._table_tostring = _table_tostring -- prettystr_sub() needs it local function _table_tostring_format_multiline_string( tbl_str, indentLevel ) local indentString = '\n'..string.rep(" ", indentLevel - 1) return table.concat( tbl_str, indentString ) end M.private._table_tostring_format_multiline_string = _table_tostring_format_multiline_string local function _table_tostring_format_result( tbl, result, indentLevel, printTableRefs ) -- final function called in _table_to_string() to format the resulting list of -- string describing the table. local dispOnMultLines = false -- set dispOnMultLines to true if the maximum LINE_LENGTH would be exceeded with the values local totalLength = 0 for k, v in ipairs( result ) do totalLength = totalLength + string.len( v ) if totalLength >= M.LINE_LENGTH then dispOnMultLines = true break end end -- set dispOnMultLines to true if the max LINE_LENGTH would be exceeded -- with the values and the separators. if not dispOnMultLines then -- adjust with length of separator(s): -- two items need 1 sep, three items two seps, ... plus len of '{}' if #result > 0 then totalLength = totalLength + TABLE_TOSTRING_SEP_LEN * (#result - 1) end dispOnMultLines = (totalLength + 2 >= M.LINE_LENGTH) end -- now reformat the result table (currently holding element strings) if dispOnMultLines then local indentString = string.rep(" ", indentLevel - 1) result = { "{\n ", indentString, table.concat(result, ",\n " .. indentString), "\n", indentString, "}" } else result = {"{", table.concat(result, TABLE_TOSTRING_SEP), "}"} end if printTableRefs then table.insert(result, 1, "<"..table_ref(tbl).."> ") -- prepend table ref end return table.concat(result) end M.private._table_tostring_format_result = _table_tostring_format_result -- prettystr_sub() needs it local function table_findkeyof(t, element) -- Return the key k of the given element in table t, so that t[k] == element -- (or `nil` if element is not present within t). Note that we use our -- 'general' is_equal comparison for matching, so this function should -- handle table-type elements gracefully and consistently. if type(t) == "table" then for k, v in pairs(t) do if M.private.is_table_equals(v, element) then return k end end end return nil end local function _is_table_items_equals(actual, expected ) local type_a, type_e = type(actual), type(expected) if type_a ~= type_e then return false elseif (type_a == 'table') --[[and (type_e == 'table')]] then for k, v in pairs(actual) do if table_findkeyof(expected, v) == nil then return false -- v not contained in expected end end for k, v in pairs(expected) do if table_findkeyof(actual, v) == nil then return false -- v not contained in actual end end return true elseif actual ~= expected then return false end return true end --[[ This is a specialized metatable to help with the bookkeeping of recursions in _is_table_equals(). It provides an __index table that implements utility functions for easier management of the table. The "cached" method queries the state of a specific (actual,expected) pair; and the "store" method sets this state to the given value. The state of pairs not "seen" / visited is assumed to be `nil`. ]] local _recursion_cache_MT = { __index = { -- Return the cached value for an (actual,expected) pair (or `nil`) cached = function(t, actual, expected) local subtable = t[actual] or {} return subtable[expected] end, -- Store cached value for a specific (actual,expected) pair. -- Returns the value, so it's easy to use for a "tailcall" (return ...). store = function(t, actual, expected, value, asymmetric) local subtable = t[actual] if not subtable then subtable = {} t[actual] = subtable end subtable[expected] = value -- Unless explicitly marked "asymmetric": Consider the recursion -- on (expected,actual) to be equivalent to (actual,expected) by -- default, and thus cache the value for both. if not asymmetric then t:store(expected, actual, value, true) end return value end } } local function _is_table_equals(actual, expected, cycleDetectTable, marginForAlmostEqual) --[[Returns true if both table are equal. If argument marginForAlmostEqual is suppied, number comparison is done using alomstEqual instead of strict equality. cycleDetectTable is an internal argument used during recursion on tables. ]] --print('_is_table_equals( \n '..prettystr(actual)..'\n , '..prettystr(expected).. -- '\n , '..prettystr(cycleDetectTable)..'\n , '..prettystr(marginForAlmostEqual)..' )') local type_a, type_e = type(actual), type(expected) if type_a ~= type_e then return false -- different types won't match end if type_a == 'number' then if marginForAlmostEqual ~= nil then return M.almostEquals(actual, expected, marginForAlmostEqual) else return actual == expected end elseif type_a ~= 'table' then -- other types compare directly return actual == expected end cycleDetectTable = cycleDetectTable or { actual={}, expected={} } if cycleDetectTable.actual[ actual ] then -- oh, we hit a cycle in actual if cycleDetectTable.expected[ expected ] then -- uh, we hit a cycle at the same time in expected -- so the two tables have similar structure return true end -- cycle was hit only in actual, the structure differs from expected return false end if cycleDetectTable.expected[ expected ] then -- no cycle in actual, but cycle in expected -- the structure differ return false end -- at this point, no table cycle detected, we are -- seeing this table for the first time -- mark the cycle detection cycleDetectTable.actual[ actual ] = true cycleDetectTable.expected[ expected ] = true local actualKeysMatched = {} for k, v in pairs(actual) do actualKeysMatched[k] = true -- Keep track of matched keys if not _is_table_equals(v, expected[k], cycleDetectTable, marginForAlmostEqual) then -- table differs on this key -- clear the cycle detection before returning cycleDetectTable.actual[ actual ] = nil cycleDetectTable.expected[ expected ] = nil return false end end for k, v in pairs(expected) do if not actualKeysMatched[k] then -- Found a key that we did not see in "actual" -> mismatch -- clear the cycle detection before returning cycleDetectTable.actual[ actual ] = nil cycleDetectTable.expected[ expected ] = nil return false end -- Otherwise actual[k] was already matched against v = expected[k]. end -- all key match, we have a match ! cycleDetectTable.actual[ actual ] = nil cycleDetectTable.expected[ expected ] = nil return true end M.private._is_table_equals = _is_table_equals local function failure(main_msg, extra_msg_or_nil, level) -- raise an error indicating a test failure -- for error() compatibility we adjust "level" here (by +1), to report the -- calling context local msg if type(extra_msg_or_nil) == 'string' and extra_msg_or_nil:len() > 0 then msg = extra_msg_or_nil .. '\n' .. main_msg else msg = main_msg end error(M.FAILURE_PREFIX .. msg, (level or 1) + 1 + M.STRIP_EXTRA_ENTRIES_IN_STACK_TRACE) end local function is_table_equals(actual, expected, marginForAlmostEqual) return _is_table_equals(actual, expected, nil, marginForAlmostEqual) end M.private.is_table_equals = is_table_equals local function fail_fmt(level, extra_msg_or_nil, ...) -- failure with printf-style formatted message and given error level failure(string.format(...), extra_msg_or_nil, (level or 1) + 1) end M.private.fail_fmt = fail_fmt local function error_fmt(level, ...) -- printf-style error() error(string.format(...), (level or 1) + 1 + M.STRIP_EXTRA_ENTRIES_IN_STACK_TRACE) end M.private.error_fmt = error_fmt ---------------------------------------------------------------- -- -- assertions -- ---------------------------------------------------------------- local function errorMsgEquality(actual, expected, doDeepAnalysis, margin) -- margin is supplied only for almost equal verification if not M.ORDER_ACTUAL_EXPECTED then expected, actual = actual, expected end if type(expected) == 'string' or type(expected) == 'table' then local strExpected, strActual = prettystrPairs(expected, actual) local result = string.format("expected: %s\nactual: %s", strExpected, strActual) if margin then result = result .. '\nwere not equal by the margin of: '..prettystr(margin) end -- extend with mismatch analysis if possible: local success, mismatchResult success, mismatchResult = tryMismatchFormatting( actual, expected, doDeepAnalysis, margin ) if success then result = table.concat( { result, mismatchResult }, '\n' ) end return result end return string.format("expected: %s, actual: %s", prettystr(expected), prettystr(actual)) end function M.assertError(f, ...) -- assert that calling f with the arguments will raise an error -- example: assertError( f, 1, 2 ) => f(1,2) should generate an error if pcall( f, ... ) then failure( "Expected an error when calling function but no error generated", nil, 2 ) end end function M.fail( msg ) -- stops a test due to a failure failure( msg, nil, 2 ) end function M.failIf( cond, msg ) -- Fails a test with "msg" if condition is true if cond then failure( msg, nil, 2 ) end end function M.skip(msg) -- skip a running test error_fmt(2, M.SKIP_PREFIX .. msg) end function M.skipIf( cond, msg ) -- skip a running test if condition is met if cond then error_fmt(2, M.SKIP_PREFIX .. msg) end end function M.runOnlyIf( cond, msg ) -- continue a running test if condition is met, else skip it if not cond then error_fmt(2, M.SKIP_PREFIX .. prettystr(msg)) end end function M.success() -- stops a test with a success error_fmt(2, M.SUCCESS_PREFIX) end function M.successIf( cond ) -- stops a test with a success if condition is met if cond then error_fmt(2, M.SUCCESS_PREFIX) end end ------------------------------------------------------------------ -- Equality assertions ------------------------------------------------------------------ function M.assertEquals(actual, expected, extra_msg_or_nil, doDeepAnalysis) if type(actual) == 'table' and type(expected) == 'table' then if not is_table_equals(actual, expected) then failure( errorMsgEquality(actual, expected, doDeepAnalysis), extra_msg_or_nil, 2 ) end elseif type(actual) ~= type(expected) then failure( errorMsgEquality(actual, expected), extra_msg_or_nil, 2 ) elseif actual ~= expected then failure( errorMsgEquality(actual, expected), extra_msg_or_nil, 2 ) end end function M.almostEquals( actual, expected, margin ) if type(actual) ~= 'number' or type(expected) ~= 'number' or type(margin) ~= 'number' then error_fmt(3, 'almostEquals: must supply only number arguments.\nArguments supplied: %s, %s, %s', prettystr(actual), prettystr(expected), prettystr(margin)) end if margin < 0 then error_fmt(3, 'almostEquals: margin must not be negative, current value is ' .. margin) end return math.abs(expected - actual) <= margin end function M.assertAlmostEquals( actual, expected, margin, extra_msg_or_nil ) -- check that two floats are close by margin margin = margin or M.EPS if type(margin) ~= 'number' then error_fmt(2, 'almostEquals: margin must be a number, not %s', prettystr(margin)) end if type(actual) == 'table' and type(expected) == 'table' then -- handle almost equals for table if not is_table_equals(actual, expected, margin) then failure( errorMsgEquality(actual, expected, nil, margin), extra_msg_or_nil, 2 ) end elseif type(actual) == 'number' and type(expected) == 'number' and type(margin) == 'number' then if not M.almostEquals(actual, expected, margin) then if not M.ORDER_ACTUAL_EXPECTED then expected, actual = actual, expected end local delta = math.abs(actual - expected) fail_fmt(2, extra_msg_or_nil, 'Values are not almost equal\n' .. 'Actual: %s, expected: %s, delta %s above margin of %s', actual, expected, delta, margin) end else error_fmt(3, 'almostEquals: must supply only number or table arguments.\nArguments supplied: %s, %s, %s', prettystr(actual), prettystr(expected), prettystr(margin)) end end function M.assertNotEquals(actual, expected, extra_msg_or_nil) if type(actual) ~= type(expected) then return end if type(actual) == 'table' and type(expected) == 'table' then if not is_table_equals(actual, expected) then return end elseif actual ~= expected then return end fail_fmt(2, extra_msg_or_nil, 'Received the not expected value: %s', prettystr(actual)) end function M.assertNotAlmostEquals( actual, expected, margin, extra_msg_or_nil ) -- check that two floats are not close by margin margin = margin or M.EPS if M.almostEquals(actual, expected, margin) then if not M.ORDER_ACTUAL_EXPECTED then expected, actual = actual, expected end local delta = math.abs(actual - expected) fail_fmt(2, extra_msg_or_nil, 'Values are almost equal\nActual: %s, expected: %s' .. ', delta %s below margin of %s', actual, expected, delta, margin) end end function M.assertItemsEquals(actual, expected, extra_msg_or_nil) -- checks that the items of table expected -- are contained in table actual. Warning, this function -- is at least O(n^2) if not _is_table_items_equals(actual, expected ) then expected, actual = prettystrPairs(expected, actual) fail_fmt(2, extra_msg_or_nil, 'Content of the tables are not identical:\nExpected: %s\nActual: %s', expected, actual) end end ------------------------------------------------------------------ -- String assertion ------------------------------------------------------------------ function M.assertStrContains( str, sub, isPattern, extra_msg_or_nil ) -- this relies on lua string.find function -- a string always contains the empty string -- assert( type(str) == 'string', 'Argument 1 of assertStrContains() should be a string.' ) ) -- assert( type(sub) == 'string', 'Argument 2 of assertStrContains() should be a string.' ) ) if not string.find(str, sub, 1, not isPattern) then sub, str = prettystrPairs(sub, str, '\n') fail_fmt(2, extra_msg_or_nil, 'Could not find %s %s in string %s', isPattern and 'pattern' or 'substring', sub, str) end end function M.assertStrIContains( str, sub, extra_msg_or_nil ) -- this relies on lua string.find function -- a string always contains the empty string if not string.find(str:lower(), sub:lower(), 1, true) then sub, str = prettystrPairs(sub, str, '\n') fail_fmt(2, extra_msg_or_nil, 'Could not find (case insensitively) substring %s in string %s', sub, str) end end function M.assertNotStrContains( str, sub, isPattern, extra_msg_or_nil ) -- this relies on lua string.find function -- a string always contains the empty string if string.find(str, sub, 1, not isPattern) then sub, str = prettystrPairs(sub, str, '\n') fail_fmt(2, extra_msg_or_nil, 'Found the not expected %s %s in string %s', isPattern and 'pattern' or 'substring', sub, str) end end function M.assertNotStrIContains( str, sub, extra_msg_or_nil ) -- this relies on lua string.find function -- a string always contains the empty string if string.find(str:lower(), sub:lower(), 1, true) then sub, str = prettystrPairs(sub, str, '\n') fail_fmt(2, extra_msg_or_nil, 'Found (case insensitively) the not expected substring %s in string %s', sub, str) end end function M.assertStrMatches( str, pattern, start, final, extra_msg_or_nil ) -- Verify a full match for the string if not strMatch( str, pattern, start, final ) then pattern, str = prettystrPairs(pattern, str, '\n') fail_fmt(2, extra_msg_or_nil, 'Could not match pattern %s with string %s', pattern, str) end end local function _assertErrorMsgEquals( stripFileAndLine, expectedMsg, func, ... ) local no_error, error_msg = pcall( func, ... ) if no_error then failure( 'No error generated when calling function but expected error: '..M.prettystr(expectedMsg), nil, 3 ) end if type(expectedMsg) == "string" and type(error_msg) ~= "string" then -- table are converted to string automatically error_msg = tostring(error_msg) end local differ = false if stripFileAndLine then if error_msg:gsub("^.+:%d+: ", "") ~= expectedMsg then differ = true end else if error_msg ~= expectedMsg then local tr = type(error_msg) local te = type(expectedMsg) if te == 'table' then if tr ~= 'table' then differ = true else local ok = pcall(M.assertItemsEquals, error_msg, expectedMsg) if not ok then differ = true end end else differ = true end end end if differ then error_msg, expectedMsg = prettystrPairs(error_msg, expectedMsg) fail_fmt(3, nil, 'Error message expected: %s\nError message received: %s\n', expectedMsg, error_msg) end end function M.assertErrorMsgEquals( expectedMsg, func, ... ) -- assert that calling f with the arguments will raise an error -- example: assertError( f, 1, 2 ) => f(1,2) should generate an error _assertErrorMsgEquals(false, expectedMsg, func, ...) end function M.assertErrorMsgContentEquals(expectedMsg, func, ...) _assertErrorMsgEquals(true, expectedMsg, func, ...) end function M.assertErrorMsgContains( partialMsg, func, ... ) -- assert that calling f with the arguments will raise an error -- example: assertError( f, 1, 2 ) => f(1,2) should generate an error local no_error, error_msg = pcall( func, ... ) if no_error then failure( 'No error generated when calling function but expected error containing: '..prettystr(partialMsg), nil, 2 ) end if type(error_msg) ~= "string" then error_msg = tostring(error_msg) end if not string.find( error_msg, partialMsg, nil, true ) then error_msg, partialMsg = prettystrPairs(error_msg, partialMsg) fail_fmt(2, nil, 'Error message does not contain: %s\nError message received: %s\n', partialMsg, error_msg) end end function M.assertErrorMsgMatches( expectedMsg, func, ... ) -- assert that calling f with the arguments will raise an error -- example: assertError( f, 1, 2 ) => f(1,2) should generate an error local no_error, error_msg = pcall( func, ... ) if no_error then failure( 'No error generated when calling function but expected error matching: "'..expectedMsg..'"', nil, 2 ) end if type(error_msg) ~= "string" then error_msg = tostring(error_msg) end if not strMatch( error_msg, expectedMsg ) then expectedMsg, error_msg = prettystrPairs(expectedMsg, error_msg) fail_fmt(2, nil, 'Error message does not match pattern: %s\nError message received: %s\n', expectedMsg, error_msg) end end ------------------------------------------------------------------ -- Type assertions ------------------------------------------------------------------ function M.assertEvalToTrue(value, extra_msg_or_nil) if not value then failure("expected: a value evaluating to true, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertEvalToFalse(value, extra_msg_or_nil) if value then failure("expected: false or nil, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsTrue(value, extra_msg_or_nil) if value ~= true then failure("expected: true, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertNotIsTrue(value, extra_msg_or_nil) if value == true then failure("expected: not true, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsFalse(value, extra_msg_or_nil) if value ~= false then failure("expected: false, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertNotIsFalse(value, extra_msg_or_nil) if value == false then failure("expected: not false, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsNil(value, extra_msg_or_nil) if value ~= nil then failure("expected: nil, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertNotIsNil(value, extra_msg_or_nil) if value == nil then failure("expected: not nil, actual: nil", extra_msg_or_nil, 2) end end --[[ Add type assertion functions to the module table M. Each of these functions takes a single parameter "value", and checks that its Lua type matches the expected string (derived from the function name): M.assertIsXxx(value) -> ensure that type(value) conforms to "xxx" ]] for _, funcName in ipairs( {'assertIsNumber', 'assertIsString', 'assertIsTable', 'assertIsBoolean', 'assertIsFunction', 'assertIsUserdata', 'assertIsThread'} ) do local typeExpected = funcName:match("^assertIs([A-Z]%a*)$") -- Lua type() always returns lowercase, also make sure the match() succeeded typeExpected = typeExpected and typeExpected:lower() or error("bad function name '"..funcName.."' for type assertion") M[funcName] = function(value, extra_msg_or_nil) if type(value) ~= typeExpected then if type(value) == 'nil' then fail_fmt(2, extra_msg_or_nil, 'expected: a %s value, actual: nil', typeExpected, type(value), prettystrPairs(value)) else fail_fmt(2, extra_msg_or_nil, 'expected: a %s value, actual: type %s, value %s', typeExpected, type(value), prettystrPairs(value)) end end end end --[[ Add shortcuts for verifying type of a variable, without failure (luaunit v2 compatibility) M.isXxx(value) -> returns true if type(value) conforms to "xxx" ]] for _, typeExpected in ipairs( {'Number', 'String', 'Table', 'Boolean', 'Function', 'Userdata', 'Thread', 'Nil' } ) do local typeExpectedLower = typeExpected:lower() local isType = function(value) return (type(value) == typeExpectedLower) end M['is'..typeExpected] = isType M['is_'..typeExpectedLower] = isType end --[[ Add non-type assertion functions to the module table M. Each of these functions takes a single parameter "value", and checks that its Lua type differs from the expected string (derived from the function name): M.assertNotIsXxx(value) -> ensure that type(value) is not "xxx" ]] for _, funcName in ipairs( {'assertNotIsNumber', 'assertNotIsString', 'assertNotIsTable', 'assertNotIsBoolean', 'assertNotIsFunction', 'assertNotIsUserdata', 'assertNotIsThread'} ) do local typeUnexpected = funcName:match("^assertNotIs([A-Z]%a*)$") -- Lua type() always returns lowercase, also make sure the match() succeeded typeUnexpected = typeUnexpected and typeUnexpected:lower() or error("bad function name '"..funcName.."' for type assertion") M[funcName] = function(value, extra_msg_or_nil) if type(value) == typeUnexpected then fail_fmt(2, extra_msg_or_nil, 'expected: not a %s type, actual: value %s', typeUnexpected, prettystrPairs(value)) end end end function M.assertIs(actual, expected, extra_msg_or_nil) if actual ~= expected then if not M.ORDER_ACTUAL_EXPECTED then actual, expected = expected, actual end local old_print_table_ref_in_error_msg = M.PRINT_TABLE_REF_IN_ERROR_MSG M.PRINT_TABLE_REF_IN_ERROR_MSG = true expected, actual = prettystrPairs(expected, actual, '\n', '') M.PRINT_TABLE_REF_IN_ERROR_MSG = old_print_table_ref_in_error_msg fail_fmt(2, extra_msg_or_nil, 'expected and actual object should not be different\nExpected: %s\nReceived: %s', expected, actual) end end function M.assertNotIs(actual, expected, extra_msg_or_nil) if actual == expected then local old_print_table_ref_in_error_msg = M.PRINT_TABLE_REF_IN_ERROR_MSG M.PRINT_TABLE_REF_IN_ERROR_MSG = true local s_expected if not M.ORDER_ACTUAL_EXPECTED then s_expected = prettystrPairs(actual) else s_expected = prettystrPairs(expected) end M.PRINT_TABLE_REF_IN_ERROR_MSG = old_print_table_ref_in_error_msg fail_fmt(2, extra_msg_or_nil, 'expected and actual object should be different: %s', s_expected ) end end ------------------------------------------------------------------ -- Scientific assertions ------------------------------------------------------------------ function M.assertIsNaN(value, extra_msg_or_nil) if type(value) ~= "number" or value == value then failure("expected: NaN, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertNotIsNaN(value, extra_msg_or_nil) if type(value) == "number" and value ~= value then failure("expected: not NaN, actual: NaN", extra_msg_or_nil, 2) end end function M.assertIsInf(value, extra_msg_or_nil) if type(value) ~= "number" or math.abs(value) ~= math.huge then failure("expected: #Inf, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsPlusInf(value, extra_msg_or_nil) if type(value) ~= "number" or value ~= math.huge then failure("expected: #Inf, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsMinusInf(value, extra_msg_or_nil) if type(value) ~= "number" or value ~= -math.huge then failure("expected: -#Inf, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end function M.assertNotIsPlusInf(value, extra_msg_or_nil) if type(value) == "number" and value == math.huge then failure("expected: not #Inf, actual: #Inf", extra_msg_or_nil, 2) end end function M.assertNotIsMinusInf(value, extra_msg_or_nil) if type(value) == "number" and value == -math.huge then failure("expected: not -#Inf, actual: -#Inf", extra_msg_or_nil, 2) end end function M.assertNotIsInf(value, extra_msg_or_nil) if type(value) == "number" and math.abs(value) == math.huge then failure("expected: not infinity, actual: " .. prettystr(value), extra_msg_or_nil, 2) end end function M.assertIsPlusZero(value, extra_msg_or_nil) if type(value) ~= 'number' or value ~= 0 then failure("expected: +0.0, actual: " ..prettystr(value), extra_msg_or_nil, 2) else if (1/value == -math.huge) then -- more precise error diagnosis failure("expected: +0.0, actual: -0.0", extra_msg_or_nil, 2) else if (1/value ~= math.huge) then -- strange, case should have already been covered failure("expected: +0.0, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end end end function M.assertIsMinusZero(value, extra_msg_or_nil) if type(value) ~= 'number' or value ~= 0 then failure("expected: -0.0, actual: " ..prettystr(value), extra_msg_or_nil, 2) else if (1/value == math.huge) then -- more precise error diagnosis failure("expected: -0.0, actual: +0.0", extra_msg_or_nil, 2) else if (1/value ~= -math.huge) then -- strange, case should have already been covered failure("expected: -0.0, actual: " ..prettystr(value), extra_msg_or_nil, 2) end end end end function M.assertNotIsPlusZero(value, extra_msg_or_nil) if type(value) == 'number' and (1/value == math.huge) then failure("expected: not +0.0, actual: +0.0", extra_msg_or_nil, 2) end end function M.assertNotIsMinusZero(value, extra_msg_or_nil) if type(value) == 'number' and (1/value == -math.huge) then failure("expected: not -0.0, actual: -0.0", extra_msg_or_nil, 2) end end function M.assertTableContains(t, expected, extra_msg_or_nil) -- checks that table t contains the expected element if table_findkeyof(t, expected) == nil then t, expected = prettystrPairs(t, expected) fail_fmt(2, extra_msg_or_nil, 'Table %s does NOT contain the expected element %s', t, expected) end end function M.assertNotTableContains(t, expected, extra_msg_or_nil) -- checks that table t doesn't contain the expected element local k = table_findkeyof(t, expected) if k ~= nil then t, expected = prettystrPairs(t, expected) fail_fmt(2, extra_msg_or_nil, 'Table %s DOES contain the unwanted element %s (at key %s)', t, expected, prettystr(k)) end end ---------------------------------------------------------------- -- Compatibility layer ---------------------------------------------------------------- -- for compatibility with LuaUnit v2.x function M.wrapFunctions() -- In LuaUnit version <= 2.1 , this function was necessary to include -- a test function inside the global test suite. Nowadays, the functions -- are simply run directly as part of the test discovery process. -- so just do nothing ! io.stderr:write[[Use of WrapFunctions() is no longer needed. Just prefix your test function names with "test" or "Test" and they will be picked up and run by LuaUnit. ]] end local list_of_funcs = { -- { official function name , alias } -- general assertions { 'assertEquals' , 'assert_equals' }, { 'assertItemsEquals' , 'assert_items_equals' }, { 'assertNotEquals' , 'assert_not_equals' }, { 'assertAlmostEquals' , 'assert_almost_equals' }, { 'assertNotAlmostEquals' , 'assert_not_almost_equals' }, { 'assertEvalToTrue' , 'assert_eval_to_true' }, { 'assertEvalToFalse' , 'assert_eval_to_false' }, { 'assertStrContains' , 'assert_str_contains' }, { 'assertStrIContains' , 'assert_str_icontains' }, { 'assertNotStrContains' , 'assert_not_str_contains' }, { 'assertNotStrIContains' , 'assert_not_str_icontains' }, { 'assertStrMatches' , 'assert_str_matches' }, { 'assertError' , 'assert_error' }, { 'assertErrorMsgEquals' , 'assert_error_msg_equals' }, { 'assertErrorMsgContains' , 'assert_error_msg_contains' }, { 'assertErrorMsgMatches' , 'assert_error_msg_matches' }, { 'assertErrorMsgContentEquals', 'assert_error_msg_content_equals' }, { 'assertIs' , 'assert_is' }, { 'assertNotIs' , 'assert_not_is' }, { 'assertTableContains' , 'assert_table_contains' }, { 'assertNotTableContains' , 'assert_not_table_contains' }, { 'wrapFunctions' , 'WrapFunctions' }, { 'wrapFunctions' , 'wrap_functions' }, -- type assertions: assertIsXXX -> assert_is_xxx { 'assertIsNumber' , 'assert_is_number' }, { 'assertIsString' , 'assert_is_string' }, { 'assertIsTable' , 'assert_is_table' }, { 'assertIsBoolean' , 'assert_is_boolean' }, { 'assertIsNil' , 'assert_is_nil' }, { 'assertIsTrue' , 'assert_is_true' }, { 'assertIsFalse' , 'assert_is_false' }, { 'assertIsNaN' , 'assert_is_nan' }, { 'assertIsInf' , 'assert_is_inf' }, { 'assertIsPlusInf' , 'assert_is_plus_inf' }, { 'assertIsMinusInf' , 'assert_is_minus_inf' }, { 'assertIsPlusZero' , 'assert_is_plus_zero' }, { 'assertIsMinusZero' , 'assert_is_minus_zero' }, { 'assertIsFunction' , 'assert_is_function' }, { 'assertIsThread' , 'assert_is_thread' }, { 'assertIsUserdata' , 'assert_is_userdata' }, -- type assertions: assertIsXXX -> assertXxx { 'assertIsNumber' , 'assertNumber' }, { 'assertIsString' , 'assertString' }, { 'assertIsTable' , 'assertTable' }, { 'assertIsBoolean' , 'assertBoolean' }, { 'assertIsNil' , 'assertNil' }, { 'assertIsTrue' , 'assertTrue' }, { 'assertIsFalse' , 'assertFalse' }, { 'assertIsNaN' , 'assertNaN' }, { 'assertIsInf' , 'assertInf' }, { 'assertIsPlusInf' , 'assertPlusInf' }, { 'assertIsMinusInf' , 'assertMinusInf' }, { 'assertIsPlusZero' , 'assertPlusZero' }, { 'assertIsMinusZero' , 'assertMinusZero'}, { 'assertIsFunction' , 'assertFunction' }, { 'assertIsThread' , 'assertThread' }, { 'assertIsUserdata' , 'assertUserdata' }, -- type assertions: assertIsXXX -> assert_xxx (luaunit v2 compat) { 'assertIsNumber' , 'assert_number' }, { 'assertIsString' , 'assert_string' }, { 'assertIsTable' , 'assert_table' }, { 'assertIsBoolean' , 'assert_boolean' }, { 'assertIsNil' , 'assert_nil' }, { 'assertIsTrue' , 'assert_true' }, { 'assertIsFalse' , 'assert_false' }, { 'assertIsNaN' , 'assert_nan' }, { 'assertIsInf' , 'assert_inf' }, { 'assertIsPlusInf' , 'assert_plus_inf' }, { 'assertIsMinusInf' , 'assert_minus_inf' }, { 'assertIsPlusZero' , 'assert_plus_zero' }, { 'assertIsMinusZero' , 'assert_minus_zero' }, { 'assertIsFunction' , 'assert_function' }, { 'assertIsThread' , 'assert_thread' }, { 'assertIsUserdata' , 'assert_userdata' }, -- type assertions: assertNotIsXXX -> assert_not_is_xxx { 'assertNotIsNumber' , 'assert_not_is_number' }, { 'assertNotIsString' , 'assert_not_is_string' }, { 'assertNotIsTable' , 'assert_not_is_table' }, { 'assertNotIsBoolean' , 'assert_not_is_boolean' }, { 'assertNotIsNil' , 'assert_not_is_nil' }, { 'assertNotIsTrue' , 'assert_not_is_true' }, { 'assertNotIsFalse' , 'assert_not_is_false' }, { 'assertNotIsNaN' , 'assert_not_is_nan' }, { 'assertNotIsInf' , 'assert_not_is_inf' }, { 'assertNotIsPlusInf' , 'assert_not_plus_inf' }, { 'assertNotIsMinusInf' , 'assert_not_minus_inf' }, { 'assertNotIsPlusZero' , 'assert_not_plus_zero' }, { 'assertNotIsMinusZero' , 'assert_not_minus_zero' }, { 'assertNotIsFunction' , 'assert_not_is_function' }, { 'assertNotIsThread' , 'assert_not_is_thread' }, { 'assertNotIsUserdata' , 'assert_not_is_userdata' }, -- type assertions: assertNotIsXXX -> assertNotXxx (luaunit v2 compat) { 'assertNotIsNumber' , 'assertNotNumber' }, { 'assertNotIsString' , 'assertNotString' }, { 'assertNotIsTable' , 'assertNotTable' }, { 'assertNotIsBoolean' , 'assertNotBoolean' }, { 'assertNotIsNil' , 'assertNotNil' }, { 'assertNotIsTrue' , 'assertNotTrue' }, { 'assertNotIsFalse' , 'assertNotFalse' }, { 'assertNotIsNaN' , 'assertNotNaN' }, { 'assertNotIsInf' , 'assertNotInf' }, { 'assertNotIsPlusInf' , 'assertNotPlusInf' }, { 'assertNotIsMinusInf' , 'assertNotMinusInf' }, { 'assertNotIsPlusZero' , 'assertNotPlusZero' }, { 'assertNotIsMinusZero' , 'assertNotMinusZero' }, { 'assertNotIsFunction' , 'assertNotFunction' }, { 'assertNotIsThread' , 'assertNotThread' }, { 'assertNotIsUserdata' , 'assertNotUserdata' }, -- type assertions: assertNotIsXXX -> assert_not_xxx { 'assertNotIsNumber' , 'assert_not_number' }, { 'assertNotIsString' , 'assert_not_string' }, { 'assertNotIsTable' , 'assert_not_table' }, { 'assertNotIsBoolean' , 'assert_not_boolean' }, { 'assertNotIsNil' , 'assert_not_nil' }, { 'assertNotIsTrue' , 'assert_not_true' }, { 'assertNotIsFalse' , 'assert_not_false' }, { 'assertNotIsNaN' , 'assert_not_nan' }, { 'assertNotIsInf' , 'assert_not_inf' }, { 'assertNotIsPlusInf' , 'assert_not_plus_inf' }, { 'assertNotIsMinusInf' , 'assert_not_minus_inf' }, { 'assertNotIsPlusZero' , 'assert_not_plus_zero' }, { 'assertNotIsMinusZero' , 'assert_not_minus_zero' }, { 'assertNotIsFunction' , 'assert_not_function' }, { 'assertNotIsThread' , 'assert_not_thread' }, { 'assertNotIsUserdata' , 'assert_not_userdata' }, -- all assertions with Coroutine duplicate Thread assertions { 'assertIsThread' , 'assertIsCoroutine' }, { 'assertIsThread' , 'assertCoroutine' }, { 'assertIsThread' , 'assert_is_coroutine' }, { 'assertIsThread' , 'assert_coroutine' }, { 'assertNotIsThread' , 'assertNotIsCoroutine' }, { 'assertNotIsThread' , 'assertNotCoroutine' }, { 'assertNotIsThread' , 'assert_not_is_coroutine' }, { 'assertNotIsThread' , 'assert_not_coroutine' }, } -- Create all aliases in M for _,v in ipairs( list_of_funcs ) do local funcname, alias = v[1], v[2] M[alias] = M[funcname] if EXPORT_ASSERT_TO_GLOBALS then _G[funcname] = M[funcname] _G[alias] = M[funcname] end end ---------------------------------------------------------------- -- -- Outputters -- ---------------------------------------------------------------- -- A common "base" class for outputters -- For concepts involved (class inheritance) see http://www.lua.org/pil/16.2.html local genericOutput = { __class__ = 'genericOutput' } -- class local genericOutput_MT = { __index = genericOutput } -- metatable M.genericOutput = genericOutput -- publish, so that custom classes may derive from it function genericOutput.new(runner, default_verbosity) -- runner is the "parent" object controlling the output, usually a LuaUnit instance local t = { runner = runner } if runner then t.result = runner.result t.verbosity = runner.verbosity or default_verbosity t.fname = runner.fname else t.verbosity = default_verbosity end return setmetatable( t, genericOutput_MT) end -- abstract ("empty") methods function genericOutput:startSuite() -- Called once, when the suite is started end function genericOutput:startClass(className) -- Called each time a new test class is started end function genericOutput:startTest(testName) -- called each time a new test is started, right before the setUp() -- the current test status node is already created and available in: self.result.currentNode end function genericOutput:updateStatus(node) -- called with status failed or error as soon as the error/failure is encountered -- this method is NOT called for a successful test because a test is marked as successful by default -- and does not need to be updated end function genericOutput:endTest(node) -- called when the test is finished, after the tearDown() method end function genericOutput:endClass() -- called when executing the class is finished, before moving on to the next class of at the end of the test execution end function genericOutput:endSuite() -- called at the end of the test suite execution end ---------------------------------------------------------------- -- class TapOutput ---------------------------------------------------------------- local TapOutput = genericOutput.new() -- derived class local TapOutput_MT = { __index = TapOutput } -- metatable TapOutput.__class__ = 'TapOutput' -- For a good reference for TAP format, check: http://testanything.org/tap-specification.html function TapOutput.new(runner) local t = genericOutput.new(runner, M.VERBOSITY_LOW) return setmetatable( t, TapOutput_MT) end function TapOutput:startSuite() print("1.."..self.result.selectedCount) print('# Started on '..self.result.startDate) end function TapOutput:startClass(className) if className ~= '[TestFunctions]' then print('# Starting class: '..className) end end function TapOutput:updateStatus( node ) if node:isSkipped() then io.stdout:write("ok ", self.result.currentTestNumber, "\t# SKIP ", node.msg, "\n" ) return end io.stdout:write("not ok ", self.result.currentTestNumber, "\t", node.testName, "\n") if self.verbosity > M.VERBOSITY_LOW then print( prefixString( '# ', node.msg ) ) end if (node:isFailure() or node:isError()) and self.verbosity > M.VERBOSITY_DEFAULT then print( prefixString( '# ', node.stackTrace ) ) end end function TapOutput:endTest( node ) if node:isSuccess() then io.stdout:write("ok ", self.result.currentTestNumber, "\t", node.testName, "\n") end end function TapOutput:endSuite() print( '# '..M.LuaUnit.statusLine( self.result ) ) return self.result.notSuccessCount end -- class TapOutput end ---------------------------------------------------------------- -- class JUnitOutput ---------------------------------------------------------------- -- See directory junitxml for more information about the junit format local JUnitOutput = genericOutput.new() -- derived class local JUnitOutput_MT = { __index = JUnitOutput } -- metatable JUnitOutput.__class__ = 'JUnitOutput' function JUnitOutput.new(runner) local t = genericOutput.new(runner, M.VERBOSITY_LOW) t.testList = {} return setmetatable( t, JUnitOutput_MT ) end function JUnitOutput:startSuite() -- open xml file early to deal with errors if self.fname == nil then error('With Junit, an output filename must be supplied with --name!') end if string.sub(self.fname,-4) ~= '.xml' then self.fname = self.fname..'.xml' end self.fd = io.open(self.fname, "w") if self.fd == nil then error("Could not open file for writing: "..self.fname) end print('# XML output to '..self.fname) print('# Started on '..self.result.startDate) end function JUnitOutput:startClass(className) if className ~= '[TestFunctions]' then print('# Starting class: '..className) end end function JUnitOutput:startTest(testName) print('# Starting test: '..testName) end function JUnitOutput:updateStatus( node ) if node:isFailure() then print( '# Failure: ' .. prefixString( '# ', node.msg ):sub(4, nil) ) -- print('# ' .. node.stackTrace) elseif node:isError() then print( '# Error: ' .. prefixString( '# ' , node.msg ):sub(4, nil) ) -- print('# ' .. node.stackTrace) end end function JUnitOutput:endSuite() print( '# '..M.LuaUnit.statusLine(self.result)) -- XML file writing self.fd:write('\n') self.fd:write('\n') self.fd:write(string.format( ' \n', self.result.runCount, self.result.startIsodate, self.result.duration, self.result.errorCount, self.result.failureCount, self.result.skippedCount )) self.fd:write(" \n") self.fd:write(string.format(' \n', _VERSION ) ) self.fd:write(string.format(' \n', M.VERSION) ) -- XXX please include system name and version if possible self.fd:write(" \n") for i,node in ipairs(self.result.allTests) do self.fd:write(string.format(' \n', node.className, node.testName, node.duration ) ) if node:isNotSuccess() then self.fd:write(node:statusXML()) end self.fd:write(' \n') end -- Next two lines are needed to validate junit ANT xsd, but really not useful in general: self.fd:write(' \n') self.fd:write(' \n') self.fd:write(' \n') self.fd:write('\n') self.fd:close() return self.result.notSuccessCount end -- class TapOutput end ---------------------------------------------------------------- -- class TextOutput ---------------------------------------------------------------- --[[ Example of other unit-tests suite text output -- Python Non verbose: For each test: . or F or E If some failed tests: ============== ERROR / FAILURE: TestName (testfile.testclass) --------- Stack trace then -------------- then "Ran x tests in 0.000s" then OK or FAILED (failures=1, error=1) -- Python Verbose: testname (filename.classname) ... ok testname (filename.classname) ... FAIL testname (filename.classname) ... ERROR then -------------- then "Ran x tests in 0.000s" then OK or FAILED (failures=1, error=1) -- Ruby: Started . Finished in 0.002695 seconds. 1 tests, 2 assertions, 0 failures, 0 errors -- Ruby: >> ruby tc_simple_number2.rb Loaded suite tc_simple_number2 Started F.. Finished in 0.038617 seconds. 1) Failure: test_failure(TestSimpleNumber) [tc_simple_number2.rb:16]: Adding doesn't work. <3> expected but was <4>. 3 tests, 4 assertions, 1 failures, 0 errors -- Java Junit .......F. Time: 0,003 There was 1 failure: 1) testCapacity(junit.samples.VectorTest)junit.framework.AssertionFailedError at junit.samples.VectorTest.testCapacity(VectorTest.java:87) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) FAILURES!!! Tests run: 8, Failures: 1, Errors: 0 -- Maven # mvn test ------------------------------------------------------- T E S T S ------------------------------------------------------- Running math.AdditionTest Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.03 sec <<< FAILURE! Results : Failed tests: testLireSymbole(math.AdditionTest) Tests run: 2, Failures: 1, Errors: 0, Skipped: 0 -- LuaUnit ---- non verbose * display . or F or E when running tests ---- verbose * display test name + ok/fail ---- * blank line * number) ERROR or FAILURE: TestName Stack trace * blank line * number) ERROR or FAILURE: TestName Stack trace then -------------- then "Ran x tests in 0.000s (%d not selected, %d skipped)" then OK or FAILED (failures=1, error=1) ]] local TextOutput = genericOutput.new() -- derived class local TextOutput_MT = { __index = TextOutput } -- metatable TextOutput.__class__ = 'TextOutput' function TextOutput.new(runner) local t = genericOutput.new(runner, M.VERBOSITY_DEFAULT) t.errorList = {} return setmetatable( t, TextOutput_MT ) end function TextOutput:startSuite() if self.verbosity > M.VERBOSITY_DEFAULT then print( 'Started on '.. self.result.startDate ) end end function TextOutput:startTest(testName) if self.verbosity > M.VERBOSITY_DEFAULT then io.stdout:write( " ", self.result.currentNode.testName, " ... " ) end end function TextOutput:endTest( node ) if node:isSuccess() then if self.verbosity > M.VERBOSITY_DEFAULT then io.stdout:write("Ok\n") else io.stdout:write(".") io.stdout:flush() end else if self.verbosity > M.VERBOSITY_DEFAULT then print( node.status ) print( node.msg ) --[[ -- find out when to do this: if self.verbosity > M.VERBOSITY_DEFAULT then print( node.stackTrace ) end ]] else -- write only the first character of status E, F or S io.stdout:write(string.sub(node.status, 1, 1)) io.stdout:flush() end end end function TextOutput:displayOneFailedTest( index, fail ) print(index..") "..fail.testName ) print( fail.msg ) print( fail.stackTrace ) print() end function TextOutput:displayErroredTests() if #self.result.errorTests ~= 0 then print("Tests with errors:") print("------------------") for i, v in ipairs(self.result.errorTests) do self:displayOneFailedTest(i, v) end end end function TextOutput:displayFailedTests() if #self.result.failedTests ~= 0 then print("Failed tests:") print("-------------") for i, v in ipairs(self.result.failedTests) do self:displayOneFailedTest(i, v) end end end function TextOutput:endSuite() if self.verbosity > M.VERBOSITY_DEFAULT then print("=========================================================") else print() end self:displayErroredTests() self:displayFailedTests() print( M.LuaUnit.statusLine( self.result ) ) if self.result.notSuccessCount == 0 then print('OK') end end -- class TextOutput end ---------------------------------------------------------------- -- class NilOutput ---------------------------------------------------------------- local function nopCallable() --print(42) return nopCallable end local NilOutput = { __class__ = 'NilOuptut' } -- class local NilOutput_MT = { __index = nopCallable } -- metatable function NilOutput.new(runner) return setmetatable( { __class__ = 'NilOutput' }, NilOutput_MT ) end ---------------------------------------------------------------- -- -- class LuaUnit -- ---------------------------------------------------------------- M.LuaUnit = { outputType = TextOutput, verbosity = M.VERBOSITY_DEFAULT, __class__ = 'LuaUnit', instances = {} } local LuaUnit_MT = { __index = M.LuaUnit } if EXPORT_ASSERT_TO_GLOBALS then LuaUnit = M.LuaUnit end function M.LuaUnit.new() local newInstance = setmetatable( {}, LuaUnit_MT ) return newInstance end -----------------[[ Utility methods ]]--------------------- function M.LuaUnit.asFunction(aObject) -- return "aObject" if it is a function, and nil otherwise if 'function' == type(aObject) then return aObject end end function M.LuaUnit.splitClassMethod(someName) --[[ Return a pair of className, methodName strings for a name in the form "class.method". If no class part (or separator) is found, will return nil, someName instead (the latter being unchanged). This convention thus also replaces the older isClassMethod() test: You just have to check for a non-nil className (return) value. ]] local separator = string.find(someName, '.', 1, true) if separator then return someName:sub(1, separator - 1), someName:sub(separator + 1) end return nil, someName end function M.LuaUnit.isMethodTestName( s ) -- return true is the name matches the name of a test method -- default rule is that is starts with 'Test' or with 'test' return string.sub(s, 1, 4):lower() == 'test' end function M.LuaUnit.isTestName( s ) -- return true is the name matches the name of a test -- default rule is that is starts with 'Test' or with 'test' return string.sub(s, 1, 4):lower() == 'test' end function M.LuaUnit.collectTests() -- return a list of all test names in the global namespace -- that match LuaUnit.isTestName local testNames = {} for k, _ in pairs(_G) do if type(k) == "string" and M.LuaUnit.isTestName( k ) then table.insert( testNames , k ) end end table.sort( testNames ) return testNames end function M.LuaUnit.parseCmdLine( cmdLine ) -- parse the command line -- Supported command line parameters: -- --verbose, -v: increase verbosity -- --quiet, -q: silence output -- --error, -e: treat errors as fatal (quit program) -- --output, -o, + name: select output type -- --pattern, -p, + pattern: run test matching pattern, may be repeated -- --exclude, -x, + pattern: run test not matching pattern, may be repeated -- --shuffle, -s, : shuffle tests before reunning them -- --name, -n, + fname: name of output file for junit, default to stdout -- --repeat, -r, + num: number of times to execute each test -- [testnames, ...]: run selected test names -- -- Returns a table with the following fields: -- verbosity: nil, M.VERBOSITY_DEFAULT, M.VERBOSITY_QUIET, M.VERBOSITY_VERBOSE -- output: nil, 'tap', 'junit', 'text', 'nil' -- testNames: nil or a list of test names to run -- exeRepeat: num or 1 -- pattern: nil or a list of patterns -- exclude: nil or a list of patterns local result, state = {}, nil local SET_OUTPUT = 1 local SET_PATTERN = 2 local SET_EXCLUDE = 3 local SET_FNAME = 4 local SET_REPEAT = 5 if cmdLine == nil then return result end local function parseOption( option ) if option == '--help' or option == '-h' then result['help'] = true return elseif option == '--version' then result['version'] = true return elseif option == '--verbose' or option == '-v' then result['verbosity'] = M.VERBOSITY_VERBOSE return elseif option == '--quiet' or option == '-q' then result['verbosity'] = M.VERBOSITY_QUIET return elseif option == '--error' or option == '-e' then result['quitOnError'] = true return elseif option == '--failure' or option == '-f' then result['quitOnFailure'] = true return elseif option == '--shuffle' or option == '-s' then result['shuffle'] = true return elseif option == '--output' or option == '-o' then state = SET_OUTPUT return state elseif option == '--name' or option == '-n' then state = SET_FNAME return state elseif option == '--repeat' or option == '-r' then state = SET_REPEAT return state elseif option == '--pattern' or option == '-p' then state = SET_PATTERN return state elseif option == '--exclude' or option == '-x' then state = SET_EXCLUDE return state end error('Unknown option: '..option,3) end local function setArg( cmdArg, state ) if state == SET_OUTPUT then result['output'] = cmdArg return elseif state == SET_FNAME then result['fname'] = cmdArg return elseif state == SET_REPEAT then result['exeRepeat'] = tonumber(cmdArg) or error('Malformed -r argument: '..cmdArg) return elseif state == SET_PATTERN then if result['pattern'] then table.insert( result['pattern'], cmdArg ) else result['pattern'] = { cmdArg } end return elseif state == SET_EXCLUDE then local notArg = '!'..cmdArg if result['pattern'] then table.insert( result['pattern'], notArg ) else result['pattern'] = { notArg } end return end error('Unknown parse state: '.. state) end for i, cmdArg in ipairs(cmdLine) do if state ~= nil then setArg( cmdArg, state, result ) state = nil else if cmdArg:sub(1,1) == '-' then state = parseOption( cmdArg ) else if result['testNames'] then table.insert( result['testNames'], cmdArg ) else result['testNames'] = { cmdArg } end end end end if result['help'] then M.LuaUnit.help() end if result['version'] then M.LuaUnit.version() end if state ~= nil then error('Missing argument after '..cmdLine[ #cmdLine ],2 ) end return result end function M.LuaUnit.help() print(M.USAGE) os.exit(0) end function M.LuaUnit.version() print('LuaUnit v'..M.VERSION..' by Philippe Fremy ') os.exit(0) end ---------------------------------------------------------------- -- class NodeStatus ---------------------------------------------------------------- local NodeStatus = { __class__ = 'NodeStatus' } -- class local NodeStatus_MT = { __index = NodeStatus } -- metatable M.NodeStatus = NodeStatus -- values of status NodeStatus.SUCCESS = 'SUCCESS' NodeStatus.SKIP = 'SKIP' NodeStatus.FAIL = 'FAIL' NodeStatus.ERROR = 'ERROR' function NodeStatus.new( number, testName, className ) -- default constructor, test are PASS by default local t = { number = number, testName = testName, className = className } setmetatable( t, NodeStatus_MT ) t:success() return t end function NodeStatus:success() self.status = self.SUCCESS -- useless because lua does this for us, but it helps me remembering the relevant field names self.msg = nil self.stackTrace = nil end function NodeStatus:skip(msg) self.status = self.SKIP self.msg = msg self.stackTrace = nil end function NodeStatus:fail(msg, stackTrace) self.status = self.FAIL self.msg = msg self.stackTrace = stackTrace end function NodeStatus:error(msg, stackTrace) self.status = self.ERROR self.msg = msg self.stackTrace = stackTrace end function NodeStatus:isSuccess() return self.status == NodeStatus.SUCCESS end function NodeStatus:isNotSuccess() -- Return true if node is either failure or error or skip return (self.status == NodeStatus.FAIL or self.status == NodeStatus.ERROR or self.status == NodeStatus.SKIP) end function NodeStatus:isSkipped() return self.status == NodeStatus.SKIP end function NodeStatus:isFailure() return self.status == NodeStatus.FAIL end function NodeStatus:isError() return self.status == NodeStatus.ERROR end function NodeStatus:statusXML() if self:isError() then return table.concat( {' \n', ' \n'}) elseif self:isFailure() then return table.concat( {' \n', ' \n'}) elseif self:isSkipped() then return table.concat({' ', xmlEscape(self.msg),'\n' } ) end return ' \n' -- (not XSD-compliant! normally shouldn't get here) end --------------[[ Output methods ]]------------------------- local function conditional_plural(number, singular) -- returns a grammatically well-formed string "%d " local suffix = '' if number ~= 1 then -- use plural suffix = (singular:sub(-2) == 'ss') and 'es' or 's' end return string.format('%d %s%s', number, singular, suffix) end function M.LuaUnit.statusLine(result) -- return status line string according to results local s = { string.format('Ran %d tests in %0.3f seconds', result.runCount, result.duration), conditional_plural(result.successCount, 'success'), } if result.notSuccessCount > 0 then if result.failureCount > 0 then table.insert(s, conditional_plural(result.failureCount, 'failure')) end if result.errorCount > 0 then table.insert(s, conditional_plural(result.errorCount, 'error')) end else table.insert(s, '0 failures') end if result.skippedCount > 0 then table.insert(s, string.format("%d skipped", result.skippedCount)) end if result.nonSelectedCount > 0 then table.insert(s, string.format("%d non-selected", result.nonSelectedCount)) end return table.concat(s, ', ') end function M.LuaUnit:startSuite(selectedCount, nonSelectedCount) self.result = { selectedCount = selectedCount, nonSelectedCount = nonSelectedCount, successCount = 0, runCount = 0, currentTestNumber = 0, currentClassName = "", currentNode = nil, suiteStarted = true, startTime = os.clock(), startDate = os.date(os.getenv('LUAUNIT_DATEFMT')), startIsodate = os.date('%Y-%m-%dT%H:%M:%S'), patternIncludeFilter = self.patternIncludeFilter, -- list of test node status allTests = {}, failedTests = {}, errorTests = {}, skippedTests = {}, failureCount = 0, errorCount = 0, notSuccessCount = 0, skippedCount = 0, } self.outputType = self.outputType or TextOutput self.output = self.outputType.new(self) self.output:startSuite() end function M.LuaUnit:startClass( className, classInstance ) self.result.currentClassName = className self.output:startClass( className ) self:setupClass( className, classInstance ) end function M.LuaUnit:startTest( testName ) self.result.currentTestNumber = self.result.currentTestNumber + 1 self.result.runCount = self.result.runCount + 1 self.result.currentNode = NodeStatus.new( self.result.currentTestNumber, testName, self.result.currentClassName ) self.result.currentNode.startTime = os.clock() table.insert( self.result.allTests, self.result.currentNode ) self.output:startTest( testName ) end function M.LuaUnit:updateStatus( err ) -- "err" is expected to be a table / result from protectedCall() if err.status == NodeStatus.SUCCESS then return end local node = self.result.currentNode --[[ As a first approach, we will report only one error or one failure for one test. However, we can have the case where the test is in failure, and the teardown is in error. In such case, it's a good idea to report both a failure and an error in the test suite. This is what Python unittest does for example. However, it mixes up counts so need to be handled carefully: for example, there could be more (failures + errors) count that tests. What happens to the current node ? We will do this more intelligent version later. ]] -- if the node is already in failure/error, just don't report the new error (see above) if node.status ~= NodeStatus.SUCCESS then return end if err.status == NodeStatus.FAIL then node:fail( err.msg, err.trace ) table.insert( self.result.failedTests, node ) elseif err.status == NodeStatus.ERROR then node:error( err.msg, err.trace ) table.insert( self.result.errorTests, node ) elseif err.status == NodeStatus.SKIP then node:skip( err.msg ) table.insert( self.result.skippedTests, node ) else error('No such status: ' .. prettystr(err.status)) end self.output:updateStatus( node ) end function M.LuaUnit:endTest() local node = self.result.currentNode -- print( 'endTest() '..prettystr(node)) -- print( 'endTest() '..prettystr(node:isNotSuccess())) node.duration = os.clock() - node.startTime node.startTime = nil self.output:endTest( node ) if node:isSuccess() then self.result.successCount = self.result.successCount + 1 elseif node:isError() then if self.quitOnError or self.quitOnFailure then -- Runtime error - abort test execution as requested by -- "--error" option. This is done by setting a special -- flag that gets handled in internalRunSuiteByInstances(). print("\nERROR during LuaUnit test execution:\n" .. node.msg) self.result.aborted = true end elseif node:isFailure() then if self.quitOnFailure then -- Failure - abort test execution as requested by -- "--failure" option. This is done by setting a special -- flag that gets handled in internalRunSuiteByInstances(). print("\nFailure during LuaUnit test execution:\n" .. node.msg) self.result.aborted = true end elseif node:isSkipped() then self.result.runCount = self.result.runCount - 1 else error('No such node status: ' .. prettystr(node.status)) end self.result.currentNode = nil end function M.LuaUnit:endClass() self:teardownClass( self.lastClassName, self.lastClassInstance ) self.output:endClass() end function M.LuaUnit:endSuite() if self.result.suiteStarted == false then error('LuaUnit:endSuite() -- suite was already ended' ) end self.result.duration = os.clock()-self.result.startTime self.result.suiteStarted = false -- Expose test counts for outputter's endSuite(). This could be managed -- internally instead by using the length of the lists of failed tests -- but unit tests rely on these fields being present. self.result.failureCount = #self.result.failedTests self.result.errorCount = #self.result.errorTests self.result.notSuccessCount = self.result.failureCount + self.result.errorCount self.result.skippedCount = #self.result.skippedTests self.output:endSuite() end function M.LuaUnit:setOutputType(outputType, fname) -- Configures LuaUnit runner output -- outputType is one of: NIL, TAP, JUNIT, TEXT -- when outputType is junit, the additional argument fname is used to set the name of junit output file -- for other formats, fname is ignored if outputType:upper() == "NIL" then self.outputType = NilOutput return end if outputType:upper() == "TAP" then self.outputType = TapOutput return end if outputType:upper() == "JUNIT" then self.outputType = JUnitOutput if fname then self.fname = fname end return end if outputType:upper() == "TEXT" then self.outputType = TextOutput return end error( 'No such format: '..outputType,2) end --------------[[ Runner ]]----------------- function M.LuaUnit:protectedCall(classInstance, methodInstance, prettyFuncName) -- if classInstance is nil, this is just a function call -- else, it's method of a class being called. local function err_handler(e) -- transform error into a table, adding the traceback information return { status = NodeStatus.ERROR, msg = e, trace = string.sub(debug.traceback("", 1), 2) } end local ok, err if classInstance then -- stupid Lua < 5.2 does not allow xpcall with arguments so let's use a workaround ok, err = xpcall( function () methodInstance(classInstance) end, err_handler ) else ok, err = xpcall( function () methodInstance() end, err_handler ) end if ok then return {status = NodeStatus.SUCCESS} end -- print('ok="'..prettystr(ok)..'" err="'..prettystr(err)..'"') local iter_msg iter_msg = self.exeRepeat and 'iteration '..self.currentCount err.msg, err.status = M.adjust_err_msg_with_iter( err.msg, iter_msg ) if err.status == NodeStatus.SUCCESS or err.status == NodeStatus.SKIP then err.trace = nil return err end -- reformat / improve the stack trace if prettyFuncName then -- we do have the real method name err.trace = err.trace:gsub("in (%a+) 'methodInstance'", "in %1 '"..prettyFuncName.."'") end if STRIP_LUAUNIT_FROM_STACKTRACE then err.trace = stripLuaunitTrace2(err.trace, err.msg) end return err -- return the error "object" (table) end function M.LuaUnit:execOneFunction(className, methodName, classInstance, methodInstance) -- When executing a test function, className and classInstance must be nil -- When executing a class method, all parameters must be set if type(methodInstance) ~= 'function' then self:unregisterSuite() error( tostring(methodName)..' must be a function, not '..type(methodInstance)) end local prettyFuncName if className == nil then className = '[TestFunctions]' prettyFuncName = methodName else prettyFuncName = className..'.'..methodName end if self.lastClassName ~= className then if self.lastClassName ~= nil then self:endClass() end self:startClass( className, classInstance ) self.lastClassName = className self.lastClassInstance = classInstance end self:startTest(prettyFuncName) local node = self.result.currentNode for iter_n = 1, self.exeRepeat or 1 do if node:isNotSuccess() then break end self.currentCount = iter_n -- run setUp first (if any) if classInstance then local func = self.asFunction( classInstance.setUp ) or self.asFunction( classInstance.Setup ) or self.asFunction( classInstance.setup ) or self.asFunction( classInstance.SetUp ) if func then self:updateStatus(self:protectedCall(classInstance, func, className..'.setUp')) end end -- run testMethod() if node:isSuccess() then self:updateStatus(self:protectedCall(classInstance, methodInstance, prettyFuncName)) end -- lastly, run tearDown (if any) if classInstance then local func = self.asFunction( classInstance.tearDown ) or self.asFunction( classInstance.TearDown ) or self.asFunction( classInstance.teardown ) or self.asFunction( classInstance.Teardown ) if func then self:updateStatus(self:protectedCall(classInstance, func, className..'.tearDown')) end end end self:endTest() end function M.LuaUnit.expandOneClass( result, className, classInstance ) --[[ Input: a list of { name, instance }, a class name, a class instance Ouptut: modify result to add all test method instance in the form: { className.methodName, classInstance } ]] for methodName, methodInstance in sortedPairs(classInstance) do if M.LuaUnit.asFunction(methodInstance) and M.LuaUnit.isMethodTestName( methodName ) then table.insert( result, { className..'.'..methodName, classInstance } ) end end end function M.LuaUnit.expandClasses( listOfNameAndInst ) --[[ -- expand all classes (provided as {className, classInstance}) to a list of {className.methodName, classInstance} -- functions and methods remain untouched Input: a list of { name, instance } Output: * { function name, function instance } : do nothing * { class.method name, class instance }: do nothing * { class name, class instance } : add all method names in the form of (className.methodName, classInstance) ]] local result = {} for i,v in ipairs( listOfNameAndInst ) do local name, instance = v[1], v[2] if M.LuaUnit.asFunction(instance) then table.insert( result, { name, instance } ) else if type(instance) ~= 'table' then error( 'Instance must be a table or a function, not a '..type(instance)..' with value '..prettystr(instance)) end local className, methodName = M.LuaUnit.splitClassMethod( name ) if className then local methodInstance = instance[methodName] if methodInstance == nil then error( "Could not find method in class "..tostring(className).." for method "..tostring(methodName) ) end table.insert( result, { name, instance } ) else M.LuaUnit.expandOneClass( result, name, instance ) end end end return result end function M.LuaUnit.applyPatternFilter( patternIncFilter, listOfNameAndInst ) local included, excluded = {}, {} for i, v in ipairs( listOfNameAndInst ) do -- local name, instance = v[1], v[2] if patternFilter( patternIncFilter, v[1] ) then table.insert( included, v ) else table.insert( excluded, v ) end end return included, excluded end local function getKeyInListWithGlobalFallback( key, listOfNameAndInst ) local result = nil for i,v in ipairs( listOfNameAndInst ) do if(listOfNameAndInst[i][1] == key) then result = listOfNameAndInst[i][2] break end end if(not M.LuaUnit.asFunction( result ) ) then result = _G[key] end return result end function M.LuaUnit:setupSuite( listOfNameAndInst ) local setupSuite = getKeyInListWithGlobalFallback("setupSuite", listOfNameAndInst) if self.asFunction( setupSuite ) then self:updateStatus( self:protectedCall( nil, setupSuite, 'setupSuite' ) ) end end function M.LuaUnit:teardownSuite(listOfNameAndInst) local teardownSuite = getKeyInListWithGlobalFallback("teardownSuite", listOfNameAndInst) if self.asFunction( teardownSuite ) then self:updateStatus( self:protectedCall( nil, teardownSuite, 'teardownSuite') ) end end function M.LuaUnit:setupClass( className, instance ) if type( instance ) == 'table' and self.asFunction( instance.setupClass ) then self:updateStatus( self:protectedCall( instance, instance.setupClass, className..'.setupClass' ) ) end end function M.LuaUnit:teardownClass( className, instance ) if type( instance ) == 'table' and self.asFunction( instance.teardownClass ) then self:updateStatus( self:protectedCall( instance, instance.teardownClass, className..'.teardownClass' ) ) end end function M.LuaUnit:internalRunSuiteByInstances( listOfNameAndInst ) --[[ Run an explicit list of tests. Each item of the list must be one of: * { function name, function instance } * { class name, class instance } * { class.method name, class instance } This function is internal to LuaUnit. The official API to perform this action is runSuiteByInstances() ]] local expandedList = self.expandClasses( listOfNameAndInst ) if self.shuffle then randomizeTable( expandedList ) end local filteredList, filteredOutList = self.applyPatternFilter( self.patternIncludeFilter, expandedList ) self:startSuite( #filteredList, #filteredOutList ) self:setupSuite( listOfNameAndInst ) for i,v in ipairs( filteredList ) do local name, instance = v[1], v[2] if M.LuaUnit.asFunction(instance) then self:execOneFunction( nil, name, nil, instance ) else -- expandClasses() should have already taken care of sanitizing the input assert( type(instance) == 'table' ) local className, methodName = M.LuaUnit.splitClassMethod( name ) assert( className ~= nil ) local methodInstance = instance[methodName] assert(methodInstance ~= nil) self:execOneFunction( className, methodName, instance, methodInstance ) end if self.result.aborted then break -- "--error" or "--failure" option triggered end end if self.lastClassName ~= nil then self:endClass() end self:teardownSuite( listOfNameAndInst ) self:endSuite() if self.result.aborted then print("LuaUnit ABORTED (as requested by --error or --failure option)") self:unregisterSuite() os.exit(-2) end end function M.LuaUnit:internalRunSuiteByNames( listOfName ) --[[ Run LuaUnit with a list of generic names, coming either from command-line or from global namespace analysis. Convert the list into a list of (name, valid instances (table or function)) and calls internalRunSuiteByInstances. ]] local instanceName, instance local listOfNameAndInst = {} for i,name in ipairs( listOfName ) do local className, methodName = M.LuaUnit.splitClassMethod( name ) if className then instanceName = className instance = _G[instanceName] if instance == nil then self:unregisterSuite() error( "No such name in global space: "..instanceName ) end if type(instance) ~= 'table' then self:unregisterSuite() error( 'Instance of '..instanceName..' must be a table, not '..type(instance)) end local methodInstance = instance[methodName] if methodInstance == nil then self:unregisterSuite() error( "Could not find method in class "..tostring(className).." for method "..tostring(methodName) ) end else -- for functions and classes instanceName = name instance = _G[instanceName] end if instance == nil then self:unregisterSuite() error( "No such name in global space: "..instanceName ) end if (type(instance) ~= 'table' and type(instance) ~= 'function') then self:unregisterSuite() error( 'Name must match a function or a table: '..instanceName ) end table.insert( listOfNameAndInst, { name, instance } ) end self:internalRunSuiteByInstances( listOfNameAndInst ) end function M.LuaUnit.run(...) -- Run some specific test classes. -- If no arguments are passed, run the class names specified on the -- command line. If no class name is specified on the command line -- run all classes whose name starts with 'Test' -- -- If arguments are passed, they must be strings of the class names -- that you want to run or generic command line arguments (-o, -p, -v, ...) local runner = M.LuaUnit.new() return runner:runSuite(...) end function M.LuaUnit:registerSuite() -- register the current instance into our global array of instances -- print('-> Register suite') M.LuaUnit.instances[ #M.LuaUnit.instances+1 ] = self end function M.unregisterCurrentSuite() -- force unregister the last registered suite table.remove(M.LuaUnit.instances, #M.LuaUnit.instances) end function M.LuaUnit:unregisterSuite() -- print('<- Unregister suite') -- remove our current instqances from the global array of instances local instanceIdx = nil for i, instance in ipairs(M.LuaUnit.instances) do if instance == self then instanceIdx = i break end end if instanceIdx ~= nil then table.remove(M.LuaUnit.instances, instanceIdx) -- print('Unregister done') end end function M.LuaUnit:initFromArguments( ... ) --[[Parses all arguments from either command-line or direct call and set internal flags of LuaUnit runner according to it. Return the list of names which were possibly passed on the command-line or as arguments ]] local args = {...} if type(args[1]) == 'table' and args[1].__class__ == 'LuaUnit' then -- run was called with the syntax M.LuaUnit:runSuite() -- we support both M.LuaUnit.run() and M.LuaUnit:run() -- strip out the first argument self to make it a command-line argument list table.remove(args,1) end if #args == 0 then args = cmdline_argv end local options = pcall_or_abort( M.LuaUnit.parseCmdLine, args ) -- We expect these option fields to be either `nil` or contain -- valid values, so it's safe to always copy them directly. self.verbosity = options.verbosity self.quitOnError = options.quitOnError self.quitOnFailure = options.quitOnFailure self.exeRepeat = options.exeRepeat self.patternIncludeFilter = options.pattern self.shuffle = options.shuffle options.output = options.output or os.getenv('LUAUNIT_OUTPUT') options.fname = options.fname or os.getenv('LUAUNIT_JUNIT_FNAME') if options.output then if options.output:lower() == 'junit' and options.fname == nil then print('With junit output, a filename must be supplied with -n or --name') os.exit(-1) end pcall_or_abort(self.setOutputType, self, options.output, options.fname) end return options.testNames end function M.LuaUnit:runSuite( ... ) testNames = self:initFromArguments(...) self:registerSuite() self:internalRunSuiteByNames( testNames or M.LuaUnit.collectTests() ) self:unregisterSuite() return self.result.notSuccessCount end function M.LuaUnit:runSuiteByInstances( listOfNameAndInst, commandLineArguments ) --[[ Run all test functions or tables provided as input. Input: a list of { name, instance } instance can either be a function or a table containing test functions starting with the prefix "test" return the number of failures and errors, 0 meaning success ]] -- parse the command-line arguments testNames = self:initFromArguments( commandLineArguments ) self:registerSuite() self:internalRunSuiteByInstances( listOfNameAndInst ) self:unregisterSuite() return self.result.notSuccessCount end -- class LuaUnit -- For compatbility with LuaUnit v2 M.run = M.LuaUnit.run M.Run = M.LuaUnit.run function M:setVerbosity( verbosity ) -- set the verbosity value (as integer) M.LuaUnit.verbosity = verbosity end M.set_verbosity = M.setVerbosity M.SetVerbosity = M.setVerbosity return M ================================================ FILE: modules/minify.lua ================================================ --[[ MIT License Copyright (c) 2017 Mark Langen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @link https://github.com/n1tehawk/lua-minify ]] -- eyecandy to increase readability for empty if branches and keep luacheck happy local function do_nothing() end local function lookupify(tb) local t = {} for _, v in pairs(tb) do t[v] = true end return t end local function CountTable(tb, limit) local c, k = 0, next(tb, nil) while k ~= nil do c = c + 1 if limit and (c >= limit) then break end k = next(tb, k) end return c end local indentation = ' ' -- normally either multiple spaces or "\t" local function indentStr(level) return string.rep(indentation, level or 0) end local function FormatTable(tb, atIndent, ignoreFunc) -- Note: This is currently unused, -- and it might be better to check for a __tostring metamethod instead if type(tb.Print) == 'function' then return tb.Print() end -- set parameter defaults atIndent = atIndent or 0 ignoreFunc = ignoreFunc or function() return false end local consecutiveIndex, useNewlines = 1, CountTable(tb, 2) > 1 local baseIndent = indentStr(atIndent + 1) local out = {'{'} -- table of output strings if useNewlines then table.insert(out, '\n') end for k, v in pairs(tb) do local type_k, type_v = type(k), type(v) -- cache types, used multiple times if type_v ~= 'function' and not ignoreFunc(k) then if useNewlines then table.insert(out, baseIndent) end -- key if type_k == 'string' then if k:match("^[A-Za-z_][A-Za-z0-9_]*$") then table.insert(out, k) -- plain identifier key, no need to quote else -- bracket and quote key table.insert(out, '["') table.insert(out, k) table.insert(out, '"]') end table.insert(out, " = ") elseif type_k == 'number' and k == consecutiveIndex then -- as long as a "list"-type table has consecutive entries, -- there's no need to output the key consecutiveIndex = consecutiveIndex + 1 else -- non-consecutive indices and non-string keys table.insert(out, '[') table.insert(out, tostring(k)) table.insert(out, '] = ') end -- value if type_v == 'string' then table.insert(out, '"') table.insert(out, v) table.insert(out, '"') elseif type_v == 'table' then -- recursive output of table-type values table.insert(out, FormatTable(v, atIndent + (useNewlines and 1 or 0), ignoreFunc)) else table.insert(out, tostring(v)) end if next(tb, k) then table.insert(out, ',') end if useNewlines then table.insert(out, '\n') end end end if useNewlines then table.insert(out, indentStr(atIndent)) end table.insert(out, '}') return table.concat(out) end local WhiteChars = lookupify{' ', '\n', '\t', '\r'} local EscapeForCharacter = {['\r'] = '\\r', ['\n'] = '\\n', ['\t'] = '\\t', ['"'] = '\\"', ["'"] = "\\'", ['\\'] = '\\'} local CharacterForEscape = {['r'] = '\r', ['n'] = '\n', ['t'] = '\t', ['"'] = '"', ["'"] = "'", ['\\'] = '\\'} local AllIdentStartChars = lookupify{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '_'} local AllIdentChars = lookupify{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '_', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'} local Digits = lookupify{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'} local HexDigits = lookupify{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'a', 'B', 'b', 'C', 'c', 'D', 'd', 'E', 'e', 'F', 'f'} local Symbols = lookupify{'+', '-', '*', '/', '^', '%', ',', '{', '}', '[', ']', '(', ')', ';', '#', '.', ':'} local EqualSymbols = lookupify{'~', '=', '>', '<'} local Keywords = lookupify{ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'goto', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while', } local BlockFollowKeyword = lookupify{'else', 'elseif', 'until', 'end'} local UnopSet = lookupify{'-', 'not', '#'} local BinopSet = lookupify{ '+', '-', '*', '/', '%', '^', '#', '..', '.', ':', '>', '<', '<=', '>=', '~=', '==', 'and', 'or' } local GlobalRenameIgnore = lookupify{ } local BinaryPriority = { ['+'] = {6, 6}; ['-'] = {6, 6}; ['*'] = {7, 7}; ['/'] = {7, 7}; ['%'] = {7, 7}; ['^'] = {10, 9}; ['..'] = {5, 4}; ['=='] = {3, 3}; ['~='] = {3, 3}; ['>'] = {3, 3}; ['<'] = {3, 3}; ['>='] = {3, 3}; ['<='] = {3, 3}; ['and'] = {2, 2}; ['or'] = {1, 1}; } local UnaryPriority = 8 -- Eof, Ident, Keyword, Number, String, Symbol -- decode string position to line and column number, with optional start values local function _decode_position(text, pos, line, col) line, col = line or 1, col or 0 if pos > 0 then if pos > text:len() then pos = text:len() end for i = 1, pos do col = col + 1 if text:sub(i, i) == '\n' then line = line + 1 col = 0 end end if col == 0 then col = 1 end end return line, col end local function CreateLuaTokenStream(text) -- Tracking for the current position in the buffer, and -- the current line / character we are on. local p = 1 -- Output buffer for tokens local tokenBuffer = {} -- Get a character, or '' if at eof local function look(n) n = p + (n or 0) return text:sub(n, n) end local function get() local c = text:sub(p, p) p = p + 1 return c end -- Error local function _error(str) local line, col = _decode_position(text, p) for _, token in pairs(tokenBuffer) do print(token.Type.."<"..token.Source..">") end error("file<"..line..":"..col..">: "..str) end -- Consume a long data with equals count of `eqcount' local function longdata(eqcount) while true do local c = get() if c == '' then _error("Unfinished long string.") elseif c == ']' then local done = true -- Until contested for _ = 1, eqcount do if look() == '=' then p = p + 1 else done = false break end end if done and get() == ']' then return end end end end -- Get the opening part for a long data `[` `=`* `[` -- Precondition: The first `[` has been consumed -- Return: nil or the equals count local function getopen() local eqcount = 0 while look(eqcount) == '=' do eqcount = eqcount + 1 end if look(eqcount) == '[' then p = p + eqcount + 1 return eqcount end return nil end local whiteStart, tokenStart -- used in main loop, upvalues for token() -- Add token local function token(type) local tk = { Type = type; LeadingWhite = text:sub(whiteStart, tokenStart-1); Source = text:sub(tokenStart, p-1); } table.insert(tokenBuffer, tk) return tk end -- Parse tokens loop while true do -- Mark the whitespace start whiteStart = p -- Get the leading whitespace + comments while true do local c = look() if c == '' then break elseif c == '-' then if look(1) == '-' then p = p + 2 -- Consume comment body if look() == '[' then p = p + 1 local eqcount = getopen() if eqcount then -- Long comment body longdata(eqcount) else -- Normal comment body local c2 repeat c2 = get() until c2 == '' or c2 == '\n' end else -- Normal comment body local c2 repeat c2 = get() until c2 == '' or c2 == '\n' end else break end elseif WhiteChars[c] then p = p + 1 else break end end -- Mark the token start tokenStart = p -- Switch on token type local c1 = get() if c1 == '' then -- End of file token('Eof') break elseif c1 == '\'' or c1 == '\"' then -- String constant local c2 repeat c2 = get() if c2 == '' then _error("Unfinished string.") elseif c2 == '\\' then local c3 = get() if not(Digits[c3] or CharacterForEscape[c3]) then _error("Invalid Escape Sequence `"..c3.."`.") end end until c2 == c1 token('String') elseif AllIdentStartChars[c1] then -- Ident or Keyword while AllIdentChars[look()] do p = p + 1 end if Keywords[text:sub(tokenStart, p-1)] then token('Keyword') else token('Ident') end elseif Digits[c1] or (c1 == '.' and Digits[look()]) then -- Number if c1 == '0' and look() == 'x' then p = p + 1 -- Hex number while HexDigits[look()] do p = p + 1 end else -- Normal Number while Digits[look()] do p = p + 1 end if look() == '.' then -- With decimal point p = p + 1 while Digits[look()] do p = p + 1 end end if look() == 'e' or look() == 'E' then -- With exponent p = p + 1 if look() == '-' then p = p + 1 end while Digits[look()] do p = p + 1 end end end token('Number') elseif c1 == '[' then -- '[' Symbol or Long String local eqCount = getopen() if eqCount then -- Long string longdata(eqCount) token('String') else -- Symbol token('Symbol') end elseif c1 == '.' then -- Greedily consume up to 3 `.` for . / .. / ... tokens if look() == '.' then get() if look() == '.' then get() end end token('Symbol') elseif EqualSymbols[c1] then if look() == '=' then p = p + 1 end token('Symbol') elseif Symbols[c1] then token('Symbol') else _error("Bad symbol `"..c1.."` in source.") end end return tokenBuffer end local function CreateLuaParser(tokens) if type(tokens) == "string" then -- tokenize from string first tokens = CreateLuaTokenStream(tokens) end -- Token stream and pointer into it assert(type(tokens) == "table") -- for _, tok in pairs(tokens) do -- print(tok.Type..": "..tok.Source) -- end local p = 1 local function get() local tok = tokens[p] if p < #tokens then p = p + 1 end return tok end local function peek(n) n = p + (n or 0) return tokens[n] or tokens[#tokens] end local function getTokenStartPosition(token) local line, col = 1, 0 local tkNum = 1 repeat local tk = tokens[tkNum] tkNum = tkNum + 1 local text = tk.LeadingWhite if tk ~= token then text = text .. tk.Source end line, col = _decode_position(text, #text, line, col) until tk == token return line..":"..(col+1) end local function debugMark() local tk = peek() return "<"..tk.Type.." `"..tk.Source.."`> at: "..getTokenStartPosition(tk) end local function isBlockFollow() local tok = peek() return tok.Type == 'Eof' or (tok.Type == 'Keyword' and BlockFollowKeyword[tok.Source]) end local function isUnop() return UnopSet[peek().Source] or false end local function isBinop() return BinopSet[peek().Source] or false end local function expect(type, source, type2, source2) local tk = peek() if tk.Type == type and (source == nil or tk.Source == source) then return get() elseif tk.Type == type2 and (source2 == nil or tk.Source == source2) then return get() else for i = -3, 3 do print("Tokens["..i.."] = `"..peek(i).Source.."`") end if source then error(getTokenStartPosition(tk)..": `"..source.."` expected.") else error(getTokenStartPosition(tk)..": "..type ..(type2 and (" or "..type2) or "") .." expected.") end end end local function MkNode(node) local function _GetSelfToken(self) return self.Token end local getf = node.GetFirstToken or _GetSelfToken local getl = node.GetLastToken or _GetSelfToken function node:GetFirstToken() local t = getf(self) assert(t) return t end function node:GetLastToken() local t = getl(self) assert(t) return t end return node end -- Forward decls local block, expr -- Expression list local function exprlist() local exprList = {} local commaList = {} table.insert(exprList, expr()) while peek().Source == ',' do table.insert(commaList, get()) table.insert(exprList, expr()) end return exprList, commaList end local function prefixexpr() local tk = peek() if tk.Source == '(' then local oparenTk = get() local inner = expr() local cparenTk = expect('Symbol', ')') return MkNode{ Type = 'ParenExpr'; Expression = inner; Token_OpenParen = oparenTk; Token_CloseParen = cparenTk; GetFirstToken = function(self) return self.Token_OpenParen end; GetLastToken = function(self) return self.Token_CloseParen end; } elseif tk.Type == 'Ident' then return MkNode{ Type = 'VariableExpr'; Token = get(); } else print(debugMark()) error(getTokenStartPosition(tk)..": Unexpected symbol") end end local function tableexpr() local obrace = expect('Symbol', '{') local entries = {} local separators = {} while peek().Source ~= '}' do if peek().Source == '[' then -- Index local obrac = get() local index = expr() local cbrac = expect('Symbol', ']') local eq = expect('Symbol', '=') local value = expr() table.insert(entries, { EntryType = 'Index'; Index = index; Value = value; Token_OpenBracket = obrac; Token_CloseBracket = cbrac; Token_Equals = eq; }) elseif peek().Type == 'Ident' and peek(1).Source == '=' then -- Field local field = get() local eq = get() local value = expr() table.insert(entries, { EntryType = 'Field'; Field = field; Value = value; Token_Equals = eq; }) else -- Value local value = expr() table.insert(entries, { EntryType = 'Value'; Value = value; }) end -- Comma or Semicolon separator if peek().Source == ',' or peek().Source == ';' then table.insert(separators, get()) else break end end local cbrace = expect('Symbol', '}') return MkNode{ Type = 'TableLiteral'; EntryList = entries; Token_SeparatorList = separators; Token_OpenBrace = obrace; Token_CloseBrace = cbrace; GetFirstToken = function(self) return self.Token_OpenBrace end; GetLastToken = function(self) return self.Token_CloseBrace end; } end -- List of identifiers local function varlist(isFuncDecl) local varList, commaList, token = {}, {}, peek() if token.Type == 'Ident' then table.insert(varList, get()) elseif isFuncDecl and (token.Source == '...') then table.insert(varList, get()) end while peek().Source == ',' do table.insert(commaList, get()) if isFuncDecl then token = expect('Ident', nil, 'Symbol', '...') else token = expect('Ident') end table.insert(varList, token) end return varList, commaList end -- Body local function blockbody(terminator) local body = block() local after = peek() if after.Type == 'Keyword' and after.Source == terminator then get() return body, after else print(after.Type, after.Source) error(getTokenStartPosition(after)..": "..terminator.." expected.") end end -- Function declaration local function funcdecl(isAnonymous) local functionKw = get() -- local nameChain, nameChainSeparator -- if not isAnonymous then nameChain = {} nameChainSeparator = {} -- table.insert(nameChain, expect('Ident')) -- while peek().Source == '.' do table.insert(nameChainSeparator, get()) table.insert(nameChain, expect('Ident')) end if peek().Source == ':' then table.insert(nameChainSeparator, get()) table.insert(nameChain, expect('Ident')) end end -- local oparenTk = expect('Symbol', '(') local argList, argCommaList = varlist(true) local cparenTk = expect('Symbol', ')') local fbody, enTk = blockbody('end') -- return MkNode{ Type = (isAnonymous and 'FunctionLiteral' or 'FunctionStat'); NameChain = nameChain; ArgList = argList; Body = fbody; -- Token_Function = functionKw; Token_NameChainSeparator = nameChainSeparator; Token_OpenParen = oparenTk; Token_ArgCommaList = argCommaList; Token_CloseParen = cparenTk; Token_End = enTk; GetFirstToken = function(self) return self.Token_Function end; GetLastToken = function(self) return self.Token_End; end; } end -- Argument list passed to a function local function functionargs() local tk = peek() if tk.Source == '(' then local oparenTk = get() local argList = {} local argCommaList = {} while peek().Source ~= ')' do table.insert(argList, expr()) if peek().Source == ',' then table.insert(argCommaList, get()) else break end end local cparenTk = expect('Symbol', ')') return MkNode{ CallType = 'ArgCall'; ArgList = argList; -- Token_CommaList = argCommaList; Token_OpenParen = oparenTk; Token_CloseParen = cparenTk; GetFirstToken = function(self) return self.Token_OpenParen end; GetLastToken = function(self) return self.Token_CloseParen end; } elseif tk.Source == '{' then return MkNode{ CallType = 'TableCall'; TableExpr = expr(); GetFirstToken = function(self) return self.TableExpr:GetFirstToken() end; GetLastToken = function(self) return self.TableExpr:GetLastToken() end; } elseif tk.Type == 'String' then return MkNode{ CallType = 'StringCall'; Token = get(); } else error("Function arguments expected.") end end local function callexpr(base) return MkNode{ Type = 'CallExpr'; Base = base; FunctionArguments = functionargs(); GetFirstToken = function(self) return self.Base:GetFirstToken() end; GetLastToken = function(self) return self.FunctionArguments:GetLastToken() end; } end local function primaryexpr() local base = prefixexpr() assert(base, "nil prefixexpr") while true do local tk = peek() if tk.Source == '.' then local dotTk = get() local fieldName = expect('Ident') base = MkNode{ Type = 'FieldExpr'; Base = base; Field = fieldName; Token_Dot = dotTk; GetFirstToken = function(self) return self.Base:GetFirstToken() end; GetLastToken = function(self) return self.Field end; } elseif tk.Source == ':' then local colonTk = get() local methodName = expect('Ident') local fargs = functionargs() base = MkNode{ Type = 'MethodExpr'; Base = base; Method = methodName; FunctionArguments = fargs; Token_Colon = colonTk; GetFirstToken = function(self) return self.Base:GetFirstToken() end; GetLastToken = function(self) return self.FunctionArguments:GetLastToken() end; } elseif tk.Source == '[' then local obrac = get() local index = expr() local cbrac = expect('Symbol', ']') base = MkNode{ Type = 'IndexExpr'; Base = base; Index = index; Token_OpenBracket = obrac; Token_CloseBracket = cbrac; GetFirstToken = function(self) return self.Base:GetFirstToken() end; GetLastToken = function(self) return self.Token_CloseBracket end; } elseif tk.Source == '{' then base = callexpr(base) -- TableCall elseif tk.Source == '(' then base = callexpr(base) -- ArgCall elseif tk.Type == 'String' then base = callexpr(base) -- StringCall else return base end end end -- Create a "literal" node with a given type (string) local function literal(_type) return MkNode{ Type = _type; Token = get(); } end local function simpleexpr() local tk = peek() if tk.Type == 'Number' then return literal('NumberLiteral') elseif tk.Type == 'String' then return literal('StringLiteral') elseif tk.Source == 'nil' then return literal('NilLiteral') elseif tk.Source == 'true' or tk.Source == 'false' then return literal('BooleanLiteral') elseif tk.Source == '...' then return literal('VargLiteral') elseif tk.Source == '{' then return tableexpr() elseif tk.Source == 'function' then return funcdecl(true) else return primaryexpr() end end local function subexpr(limit) local curNode -- Initial Base Expression if isUnop() then local opTk = get() local ex = subexpr(UnaryPriority) curNode = MkNode{ Type = 'UnopExpr'; Token_Op = opTk; Rhs = ex; GetFirstToken = function(self) return self.Token_Op end; GetLastToken = function(self) return self.Rhs:GetLastToken() end; } else curNode = simpleexpr() assert(curNode, "nil simpleexpr") end -- Apply Precedence Recursion Chain while isBinop() and BinaryPriority[peek().Source][1] > limit do local opTk = get() local rhs = subexpr(BinaryPriority[opTk.Source][2]) assert(rhs, "RhsNeeded") curNode = MkNode{ Type = 'BinopExpr'; Lhs = curNode; Rhs = rhs; Token_Op = opTk; GetFirstToken = function(self) return self.Lhs:GetFirstToken() end; GetLastToken = function(self) return self.Rhs:GetLastToken() end; } end -- Return result return curNode end -- Expression expr = function() return subexpr(0) end -- Expression statement local function exprstat() local ex = primaryexpr() if ex.Type == 'MethodExpr' or ex.Type == 'CallExpr' then -- all good, calls can be statements return MkNode{ Type = 'CallExprStat'; Expression = ex; GetFirstToken = function(self) return self.Expression:GetFirstToken() end; GetLastToken = function(self) return self.Expression:GetLastToken() end; } else -- Assignment expr local lhs = {ex} local lhsSeparator = {} while peek().Source == ',' do table.insert(lhsSeparator, get()) local lhsPart = primaryexpr() if lhsPart.Type == 'MethodExpr' or lhsPart.Type == 'CallExpr' then error("Bad left hand side of assignment") end table.insert(lhs, lhsPart) end local eq = expect('Symbol', '=') local rhs = {expr()} local rhsSeparator = {} while peek().Source == ',' do table.insert(rhsSeparator, get()) table.insert(rhs, expr()) end return MkNode{ Type = 'AssignmentStat'; Rhs = rhs; Lhs = lhs; Token_Equals = eq; Token_LhsSeparatorList = lhsSeparator; Token_RhsSeparatorList = rhsSeparator; GetFirstToken = function(self) return self.Lhs[1]:GetFirstToken() end; GetLastToken = function(self) return self.Rhs[#self.Rhs]:GetLastToken() end; } end end -- If statement local function ifstat() local ifKw = get() local condition = expr() local thenKw = expect('Keyword', 'then') local ifBody = block() local elseClauses = {} while peek().Source == 'elseif' or peek().Source == 'else' do local elseifKw = get() local elseifCondition, elseifThenKw if elseifKw.Source == 'elseif' then elseifCondition = expr() elseifThenKw = expect('Keyword', 'then') end local elseifBody = block() table.insert(elseClauses, { Condition = elseifCondition; Body = elseifBody; -- ClauseType = elseifKw.Source; Token = elseifKw; Token_Then = elseifThenKw; }) if elseifKw.Source == 'else' then break end end local enKw = expect('Keyword', 'end') return MkNode{ Type = 'IfStat'; Condition = condition; Body = ifBody; ElseClauseList = elseClauses; -- Token_If = ifKw; Token_Then = thenKw; Token_End = enKw; GetFirstToken = function(self) return self.Token_If end; GetLastToken = function(self) return self.Token_End end; } end -- Do statement local function dostat() local doKw = get() local body, enKw = blockbody('end') -- return MkNode{ Type = 'DoStat'; Body = body; -- Token_Do = doKw; Token_End = enKw; GetFirstToken = function(self) return self.Token_Do end; GetLastToken = function(self) return self.Token_End end; } end -- While statement local function whilestat() local whileKw = get() local condition = expr() local doKw = expect('Keyword', 'do') local body, enKw = blockbody('end') -- return MkNode{ Type = 'WhileStat'; Condition = condition; Body = body; -- Token_While = whileKw; Token_Do = doKw; Token_End = enKw; GetFirstToken = function(self) return self.Token_While end; GetLastToken = function(self) return self.Token_End end; } end -- For statement local function forstat() local forKw = get() local loopVars, loopVarCommas = varlist() if peek().Source == '=' then local eqTk = get() local exprList, exprCommaList = exprlist() if #exprList < 2 or #exprList > 3 then error("expected 2 or 3 values for range bounds") end local doTk = expect('Keyword', 'do') local body, enTk = blockbody('end') return MkNode{ Type = 'NumericForStat'; VarList = loopVars; RangeList = exprList; Body = body; -- Token_For = forKw; Token_VarCommaList = loopVarCommas; Token_Equals = eqTk; Token_RangeCommaList = exprCommaList; Token_Do = doTk; Token_End = enTk; GetFirstToken = function(self) return self.Token_For end; GetLastToken = function(self) return self.Token_End end; } elseif peek().Source == 'in' then local inTk = get() local exprList, exprCommaList = exprlist() local doTk = expect('Keyword', 'do') local body, enTk = blockbody('end') return MkNode{ Type = 'GenericForStat'; VarList = loopVars; GeneratorList = exprList; Body = body; -- Token_For = forKw; Token_VarCommaList = loopVarCommas; Token_In = inTk; Token_GeneratorCommaList = exprCommaList; Token_Do = doTk; Token_End = enTk; GetFirstToken = function(self) return self.Token_For end; GetLastToken = function(self) return self.Token_End end; } else error("`=` or in expected") end end -- Repeat statement local function repeatstat() local repeatKw = get() local body, untilTk = blockbody('until') local condition = expr() return MkNode{ Type = 'RepeatStat'; Body = body; Condition = condition; -- Token_Repeat = repeatKw; Token_Until = untilTk; GetFirstToken = function(self) return self.Token_Repeat end; GetLastToken = function(self) return self.Condition:GetLastToken() end; } end -- Local var declaration local function localdecl() local localKw = get() if peek().Source == 'function' then -- Local function def local funcStat = funcdecl(false) if #funcStat.NameChain > 1 then error(getTokenStartPosition(funcStat.Token_NameChainSeparator[1])..": `(` expected.") end return MkNode{ Type = 'LocalFunctionStat'; FunctionStat = funcStat; Token_Local = localKw; GetFirstToken = function(self) return self.Token_Local end; GetLastToken = function(self) return self.FunctionStat:GetLastToken() end; } elseif peek().Type == 'Ident' then -- Local variable declaration local varList, varCommaList = varlist() local exprList, exprCommaList = {}, {} local eqToken if peek().Source == '=' then eqToken = get() exprList, exprCommaList = exprlist() end return MkNode{ Type = 'LocalVarStat'; VarList = varList; ExprList = exprList; Token_Local = localKw; Token_Equals = eqToken; Token_VarCommaList = varCommaList; Token_ExprCommaList = exprCommaList; GetFirstToken = function(self) return self.Token_Local end; GetLastToken = function(self) if #self.ExprList > 0 then return self.ExprList[#self.ExprList]:GetLastToken() else return self.VarList[#self.VarList] end end; } else error("`function` or ident expected") end end -- Return statement local function retstat() local returnKw = get() local exprList, commaList if isBlockFollow() or peek().Source == ';' then exprList = {} commaList = {} else exprList, commaList = exprlist() end return { Type = 'ReturnStat'; ExprList = exprList; Token_Return = returnKw; Token_CommaList = commaList; GetFirstToken = function(self) return self.Token_Return end; GetLastToken = function(self) if #self.ExprList > 0 then return self.ExprList[#self.ExprList]:GetLastToken() else return self.Token_Return end end; } end -- Break statement local function breakstat() local breakKw = get() return { Type = 'BreakStat'; Token_Break = breakKw; GetFirstToken = function(self) return self.Token_Break end; GetLastToken = function(self) return self.Token_Break end; } end -- Expression local function statement() local tok = peek() if tok.Source == 'if' then return false, ifstat() elseif tok.Source == 'while' then return false, whilestat() elseif tok.Source == 'do' then return false, dostat() elseif tok.Source == 'for' then return false, forstat() elseif tok.Source == 'repeat' then return false, repeatstat() elseif tok.Source == 'function' then return false, funcdecl(false) elseif tok.Source == 'local' then return false, localdecl() elseif tok.Source == 'return' then return true, retstat() elseif tok.Source == 'break' then return true, breakstat() else return false, exprstat() end end -- Chunk block = function() local statements = {} local semicolons = {} local isLast = false while not isLast and not isBlockFollow() do -- Parse statement local stat isLast, stat = statement() table.insert(statements, stat) local next = peek() if next.Type == 'Symbol' and next.Source == ';' then semicolons[#statements] = get() end end return { Type = 'StatList'; StatementList = statements; SemicolonList = semicolons; GetFirstToken = function(self) if #self.StatementList == 0 then return nil else return self.StatementList[1]:GetFirstToken() end end; GetLastToken = function(self) if #self.StatementList == 0 then return nil elseif self.SemicolonList[#self.StatementList] then -- Last token may be one of the semicolon separators return self.SemicolonList[#self.StatementList] else return self.StatementList[#self.StatementList]:GetLastToken() end end; } end return block() end local function VisitAst(ast, visitors) local ExprType = lookupify{ 'BinopExpr'; 'UnopExpr'; 'NumberLiteral'; 'StringLiteral'; 'NilLiteral'; 'BooleanLiteral'; 'VargLiteral'; 'FieldExpr'; 'IndexExpr'; 'MethodExpr'; 'CallExpr'; 'FunctionLiteral'; 'VariableExpr'; 'ParenExpr'; 'TableLiteral'; } local StatType = lookupify{ 'StatList'; 'BreakStat'; 'ReturnStat'; 'LocalVarStat'; 'LocalFunctionStat'; 'FunctionStat'; 'RepeatStat'; 'GenericForStat'; 'NumericForStat'; 'WhileStat'; 'DoStat'; 'IfStat'; 'CallExprStat'; 'AssignmentStat'; } -- Check for typos in visitor construction for visitorSubject, _ in pairs(visitors) do if not StatType[visitorSubject] and not ExprType[visitorSubject] then error("Invalid visitor target: `"..visitorSubject.."`") end end -- Helpers to call visitors on a node local function preVisit(exprOrStat) local visitor = visitors[exprOrStat.Type] if type(visitor) == 'function' then return visitor(exprOrStat) elseif visitor and visitor.Pre then return visitor.Pre(exprOrStat) end end local function postVisit(exprOrStat) local visitor = visitors[exprOrStat.Type] if visitor and type(visitor) == 'table' and visitor.Post then return visitor.Post(exprOrStat) end end local visitExpr, visitStat visitExpr = function(expr) if preVisit(expr) then -- Handler did custom child iteration or blocked child iteration return end if expr.Type == 'BinopExpr' then visitExpr(expr.Lhs) visitExpr(expr.Rhs) elseif expr.Type == 'UnopExpr' then visitExpr(expr.Rhs) elseif expr.Type == 'NumberLiteral' or expr.Type == 'StringLiteral' or expr.Type == 'NilLiteral' or expr.Type == 'BooleanLiteral' or expr.Type == 'VargLiteral' then do_nothing() -- No children to visit, single token literals elseif expr.Type == 'FieldExpr' then visitExpr(expr.Base) elseif expr.Type == 'IndexExpr' then visitExpr(expr.Base) visitExpr(expr.Index) elseif expr.Type == 'MethodExpr' or expr.Type == 'CallExpr' then visitExpr(expr.Base) if expr.FunctionArguments.CallType == 'ArgCall' then for _, argExpr in pairs(expr.FunctionArguments.ArgList) do visitExpr(argExpr) end elseif expr.FunctionArguments.CallType == 'TableCall' then visitExpr(expr.FunctionArguments.TableExpr) end elseif expr.Type == 'FunctionLiteral' then visitStat(expr.Body) elseif expr.Type == 'VariableExpr' then do_nothing() -- No children to visit elseif expr.Type == 'ParenExpr' then visitExpr(expr.Expression) elseif expr.Type == 'TableLiteral' then for _, entry in pairs(expr.EntryList) do if entry.EntryType == 'Field' then visitExpr(entry.Value) elseif entry.EntryType == 'Index' then visitExpr(entry.Index) visitExpr(entry.Value) elseif entry.EntryType == 'Value' then visitExpr(entry.Value) else assert(false, "unreachable") end end else assert(false, "unreachable, type: "..expr.Type..":"..FormatTable(expr)) end postVisit(expr) end visitStat = function(stat) if preVisit(stat) then -- Handler did custom child iteration or blocked child iteration return end if stat.Type == 'StatList' then for _, ch in pairs(stat.StatementList) do visitStat(ch) end elseif stat.Type == 'BreakStat' then do_nothing() -- No children to visit elseif stat.Type == 'ReturnStat' then for _, expr in pairs(stat.ExprList) do visitExpr(expr) end elseif stat.Type == 'LocalVarStat' then if stat.Token_Equals then for _, expr in pairs(stat.ExprList) do visitExpr(expr) end end elseif stat.Type == 'LocalFunctionStat' then visitStat(stat.FunctionStat.Body) elseif stat.Type == 'FunctionStat' then visitStat(stat.Body) elseif stat.Type == 'RepeatStat' then visitStat(stat.Body) visitExpr(stat.Condition) elseif stat.Type == 'GenericForStat' then for _, expr in pairs(stat.GeneratorList) do visitExpr(expr) end visitStat(stat.Body) elseif stat.Type == 'NumericForStat' then for _, expr in pairs(stat.RangeList) do visitExpr(expr) end visitStat(stat.Body) elseif stat.Type == 'WhileStat' then visitExpr(stat.Condition) visitStat(stat.Body) elseif stat.Type == 'DoStat' then visitStat(stat.Body) elseif stat.Type == 'IfStat' then visitExpr(stat.Condition) visitStat(stat.Body) for _, clause in pairs(stat.ElseClauseList) do if clause.Condition then visitExpr(clause.Condition) end visitStat(clause.Body) end elseif stat.Type == 'CallExprStat' then visitExpr(stat.Expression) elseif stat.Type == 'AssignmentStat' then for _, ex in pairs(stat.Lhs) do visitExpr(ex) end for _, ex in pairs(stat.Rhs) do visitExpr(ex) end else assert(false, "unreachable") end postVisit(stat) end if StatType[ast.Type] then visitStat(ast) else visitExpr(ast) end end local function AddVariableInfo(ast) local globalVars = {} local currentScope = nil -- Numbering generator for variable lifetimes local locationGenerator = 0 local function markLocation() locationGenerator = locationGenerator + 1 return locationGenerator end -- Scope management local function pushScope() currentScope = { ParentScope = currentScope; ChildScopeList = {}; VariableList = {}; BeginLocation = markLocation(); } if currentScope.ParentScope then currentScope.Depth = currentScope.ParentScope.Depth + 1 table.insert(currentScope.ParentScope.ChildScopeList, currentScope) else currentScope.Depth = 1 end function currentScope:GetVar(varName) for _, var in pairs(self.VariableList) do if var.Name == varName then return var end end if self.ParentScope then return self.ParentScope:GetVar(varName) else for _, var in pairs(globalVars) do if var.Name == varName then return var end end end end end local function popScope() local scope = currentScope -- Mark where this scope ends scope.EndLocation = markLocation() -- Mark all of the variables in the scope as ending there for _, var in pairs(scope.VariableList) do var.ScopeEndLocation = scope.EndLocation end -- Move to the parent scope currentScope = scope.ParentScope return scope end pushScope() -- push initial scope -- Add / reference variables local function addLocalVar(name, setNameFunc, localInfo) assert(localInfo, "Missing localInfo") assert(name, "Missing local var name") local var = { Type = 'Local'; Name = name; RenameList = {setNameFunc}; AssignedTo = false; Info = localInfo; UseCount = 0; Scope = currentScope; BeginLocation = markLocation(); EndLocation = markLocation(); ReferenceLocationList = {markLocation()}; } function var:Rename(newName) self.Name = newName for _, renameFunc in pairs(self.RenameList) do renameFunc(newName) end end function var:Reference() self.UseCount = self.UseCount + 1 end table.insert(currentScope.VariableList, var) return var end local function getGlobalVar(name) for _, var in pairs(globalVars) do if var.Name == name then return var end end local var = { Type = 'Global'; Name = name; RenameList = {}; AssignedTo = false; UseCount = 0; Scope = nil; -- Globals have no scope BeginLocation = markLocation(); EndLocation = markLocation(); ReferenceLocationList = {}; } function var:Rename(newName) self.Name = newName for _, renameFunc in pairs(self.RenameList) do renameFunc(newName) end end function var:Reference() self.UseCount = self.UseCount + 1 end table.insert(globalVars, var) return var end local function addGlobalReference(name, setNameFunc) assert(name, "Missing var name") local var = getGlobalVar(name) table.insert(var.RenameList, setNameFunc) return var end local function getLocalVar(scope, name) -- First search this scope -- Note: Reverse iterate here because Lua does allow shadowing a local -- within the same scope, and the later defined variable should -- be the one referenced. for i = #scope.VariableList, 1, -1 do if scope.VariableList[i].Name == name then return scope.VariableList[i] end end -- Then search parent scope if scope.ParentScope then local var = getLocalVar(scope.ParentScope, name) if var then return var end end -- Then return nil end local function referenceVariable(name, setNameFunc) assert(name, "Missing var name") local var = getLocalVar(currentScope, name) if var then table.insert(var.RenameList, setNameFunc) else var = addGlobalReference(name, setNameFunc) end -- Update the end location of where this variable is used, and -- add this location to the list of references to this variable. local curLocation = markLocation() var.EndLocation = curLocation table.insert(var.ReferenceLocationList, var.EndLocation) return var end local visitor = {} visitor.FunctionLiteral = { -- Function literal adds a new scope and adds the function literal arguments -- as local variables in the scope. Pre = function(expr) pushScope() for index, ident in pairs(expr.ArgList) do -- Note: Beware ident.Type == 'Symbol', it may be "..." here! if ident.Type == 'Ident' then addLocalVar(ident.Source, function(name) ident.Source = name end, { Type = 'Argument'; Index = index; }) end end end; Post = function() popScope() end; } visitor.VariableExpr = function(expr) -- Variable expression references from existing local variables -- in the current scope, annotating the variable usage with variable -- information. expr.Variable = referenceVariable(expr.Token.Source, function(newName) expr.Token.Source = newName end) end visitor.StatList = { -- StatList adds a new scope Pre = function() pushScope() end; Post = function() popScope() end; } visitor.LocalVarStat = { Post = function(stat) -- Local var stat adds the local variables to the current scope as locals -- We need to visit the subexpressions first, because these new locals -- will not be in scope for the initialization value expressions. That is: -- `local bar = bar + 1` -- Is valid code for varNum, ident in pairs(stat.VarList) do addLocalVar(ident.Source, function(name) stat.VarList[varNum].Source = name end, { Type = 'Local'; }) end end; } visitor.LocalFunctionStat = { Pre = function(stat) -- Local function stat adds the function itself to the current scope as -- a local variable, and creates a new scope with the function arguments -- as local variables. addLocalVar(stat.FunctionStat.NameChain[1].Source, function(name) stat.FunctionStat.NameChain[1].Source = name end, { Type = 'LocalFunction'; }) pushScope() for index, ident in pairs(stat.FunctionStat.ArgList) do -- Note: Beware ident.Type == 'Symbol', it may be "..." here! if ident.Type == 'Ident' then addLocalVar(ident.Source, function(name) ident.Source = name end, { Type = 'Argument'; Index = index; }) end end end; Post = function() popScope() end; } visitor.FunctionStat = { Pre = function(stat) -- Function stat adds a new scope containing the function arguments -- as local variables. -- A function stat may also assign to a global variable if it is in -- the form `function foo()` with no additional dots/colons in the -- name chain. local nameChain = stat.NameChain local var if #nameChain == 1 then -- If there is only one item in the name chain, then the first item -- is a reference to a global variable. var = addGlobalReference(nameChain[1].Source, function(name) nameChain[1].Source = name end) else var = referenceVariable(nameChain[1].Source, function(name) nameChain[1].Source = name end) end var.AssignedTo = true pushScope() for index, ident in pairs(stat.ArgList) do -- Note: Beware ident.Type == 'Symbol', it may be "..." here! if ident.Type == 'Ident' then addLocalVar(ident.Source, function(name) ident.Source = name end, { Type = 'Argument'; Index = index; }) end end end; Post = function() popScope() end; } visitor.GenericForStat = { Pre = function(stat) -- Generic fors need an extra scope holding the range variables -- Need a custom visitor so that the generator expressions can be -- visited before we push a scope, but the body can be visited -- after we push a scope. for _, ex in pairs(stat.GeneratorList) do VisitAst(ex, visitor) end pushScope() for index, ident in pairs(stat.VarList) do addLocalVar(ident.Source, function(name) ident.Source = name end, { Type = 'ForRange'; Index = index; }) end VisitAst(stat.Body, visitor) popScope() return true -- Custom visit end; } visitor.NumericForStat = { Pre = function(stat) -- Numeric fors need an extra scope holding the range variables -- Need a custom visitor so that the generator expressions can be -- visited before we push a scope, but the body can be visited -- after we push a scope. for _, ex in pairs(stat.RangeList) do VisitAst(ex, visitor) end pushScope() for index, ident in pairs(stat.VarList) do addLocalVar(ident.Source, function(name) ident.Source = name end, { Type = 'ForRange'; Index = index; }) end VisitAst(stat.Body, visitor) popScope() return true -- Custom visit end; } visitor.AssignmentStat = { Post = function(stat) -- For an assignment statement we need to mark the -- "assigned to" flag on variables. for _, ex in pairs(stat.Lhs) do if ex.Variable then ex.Variable.AssignedTo = true end end end; } VisitAst(ast, visitor) return globalVars, popScope() end -- Prints out an AST to stdout, or emits it by appending to a table local function PrintAst(ast, tbl_out) local printStat, printExpr local function printt(tk) if not tk.LeadingWhite or not tk.Source then error("Bad token: "..FormatTable(tk)) end if tbl_out then table.insert(tbl_out, tk.LeadingWhite) table.insert(tbl_out, tk.Source) else io.write(tk.LeadingWhite) io.write(tk.Source) end end printExpr = function(expr) if expr.Type == 'BinopExpr' then printExpr(expr.Lhs) printt(expr.Token_Op) printExpr(expr.Rhs) elseif expr.Type == 'UnopExpr' then printt(expr.Token_Op) printExpr(expr.Rhs) elseif expr.Type == 'NumberLiteral' or expr.Type == 'StringLiteral' or expr.Type == 'NilLiteral' or expr.Type == 'BooleanLiteral' or expr.Type == 'VargLiteral' then -- Just print the token printt(expr.Token) elseif expr.Type == 'FieldExpr' then printExpr(expr.Base) printt(expr.Token_Dot) printt(expr.Field) elseif expr.Type == 'IndexExpr' then printExpr(expr.Base) printt(expr.Token_OpenBracket) printExpr(expr.Index) printt(expr.Token_CloseBracket) elseif expr.Type == 'MethodExpr' or expr.Type == 'CallExpr' then printExpr(expr.Base) if expr.Type == 'MethodExpr' then printt(expr.Token_Colon) printt(expr.Method) end if expr.FunctionArguments.CallType == 'StringCall' then printt(expr.FunctionArguments.Token) elseif expr.FunctionArguments.CallType == 'ArgCall' then printt(expr.FunctionArguments.Token_OpenParen) for index, argExpr in pairs(expr.FunctionArguments.ArgList) do printExpr(argExpr) local sep = expr.FunctionArguments.Token_CommaList[index] if sep then printt(sep) end end printt(expr.FunctionArguments.Token_CloseParen) elseif expr.FunctionArguments.CallType == 'TableCall' then printExpr(expr.FunctionArguments.TableExpr) end elseif expr.Type == 'FunctionLiteral' then printt(expr.Token_Function) printt(expr.Token_OpenParen) for index, arg in pairs(expr.ArgList) do printt(arg) local comma = expr.Token_ArgCommaList[index] if comma then printt(comma) end end printt(expr.Token_CloseParen) printStat(expr.Body) printt(expr.Token_End) elseif expr.Type == 'VariableExpr' then printt(expr.Token) elseif expr.Type == 'ParenExpr' then printt(expr.Token_OpenParen) printExpr(expr.Expression) printt(expr.Token_CloseParen) elseif expr.Type == 'TableLiteral' then printt(expr.Token_OpenBrace) for index, entry in pairs(expr.EntryList) do if entry.EntryType == 'Field' then printt(entry.Field) printt(entry.Token_Equals) printExpr(entry.Value) elseif entry.EntryType == 'Index' then printt(entry.Token_OpenBracket) printExpr(entry.Index) printt(entry.Token_CloseBracket) printt(entry.Token_Equals) printExpr(entry.Value) elseif entry.EntryType == 'Value' then printExpr(entry.Value) else assert(false, "unreachable") end local sep = expr.Token_SeparatorList[index] if sep then printt(sep) end end printt(expr.Token_CloseBrace) else assert(false, "unreachable, type: "..expr.Type..":"..FormatTable(expr)) end end printStat = function(stat) if stat.Type == 'StatList' then for index, ch in pairs(stat.StatementList) do printStat(ch) if stat.SemicolonList[index] then printt(stat.SemicolonList[index]) end end elseif stat.Type == 'BreakStat' then printt(stat.Token_Break) elseif stat.Type == 'ReturnStat' then printt(stat.Token_Return) for index, expr in pairs(stat.ExprList) do printExpr(expr) if stat.Token_CommaList[index] then printt(stat.Token_CommaList[index]) end end elseif stat.Type == 'LocalVarStat' then printt(stat.Token_Local) for index, var in pairs(stat.VarList) do printt(var) local comma = stat.Token_VarCommaList[index] if comma then printt(comma) end end if stat.Token_Equals then printt(stat.Token_Equals) for index, expr in pairs(stat.ExprList) do printExpr(expr) local comma = stat.Token_ExprCommaList[index] if comma then printt(comma) end end end elseif stat.Type == 'LocalFunctionStat' then printt(stat.Token_Local) printt(stat.FunctionStat.Token_Function) printt(stat.FunctionStat.NameChain[1]) printt(stat.FunctionStat.Token_OpenParen) for index, arg in pairs(stat.FunctionStat.ArgList) do printt(arg) local comma = stat.FunctionStat.Token_ArgCommaList[index] if comma then printt(comma) end end printt(stat.FunctionStat.Token_CloseParen) printStat(stat.FunctionStat.Body) printt(stat.FunctionStat.Token_End) elseif stat.Type == 'FunctionStat' then printt(stat.Token_Function) for index, part in pairs(stat.NameChain) do printt(part) local sep = stat.Token_NameChainSeparator[index] if sep then printt(sep) end end printt(stat.Token_OpenParen) for index, arg in pairs(stat.ArgList) do printt(arg) local comma = stat.Token_ArgCommaList[index] if comma then printt(comma) end end printt(stat.Token_CloseParen) printStat(stat.Body) printt(stat.Token_End) elseif stat.Type == 'RepeatStat' then printt(stat.Token_Repeat) printStat(stat.Body) printt(stat.Token_Until) printExpr(stat.Condition) elseif stat.Type == 'GenericForStat' then printt(stat.Token_For) for index, var in pairs(stat.VarList) do printt(var) local sep = stat.Token_VarCommaList[index] if sep then printt(sep) end end printt(stat.Token_In) for index, expr in pairs(stat.GeneratorList) do printExpr(expr) local sep = stat.Token_GeneratorCommaList[index] if sep then printt(sep) end end printt(stat.Token_Do) printStat(stat.Body) printt(stat.Token_End) elseif stat.Type == 'NumericForStat' then printt(stat.Token_For) for index, var in pairs(stat.VarList) do printt(var) local sep = stat.Token_VarCommaList[index] if sep then printt(sep) end end printt(stat.Token_Equals) for index, expr in pairs(stat.RangeList) do printExpr(expr) local sep = stat.Token_RangeCommaList[index] if sep then printt(sep) end end printt(stat.Token_Do) printStat(stat.Body) printt(stat.Token_End) elseif stat.Type == 'WhileStat' then printt(stat.Token_While) printExpr(stat.Condition) printt(stat.Token_Do) printStat(stat.Body) printt(stat.Token_End) elseif stat.Type == 'DoStat' then printt(stat.Token_Do) printStat(stat.Body) printt(stat.Token_End) elseif stat.Type == 'IfStat' then printt(stat.Token_If) printExpr(stat.Condition) printt(stat.Token_Then) printStat(stat.Body) for _, clause in pairs(stat.ElseClauseList) do printt(clause.Token) if clause.Condition then printExpr(clause.Condition) printt(clause.Token_Then) end printStat(clause.Body) end printt(stat.Token_End) elseif stat.Type == 'CallExprStat' then printExpr(stat.Expression) elseif stat.Type == 'AssignmentStat' then for index, ex in pairs(stat.Lhs) do printExpr(ex) local sep = stat.Token_LhsSeparatorList[index] if sep then printt(sep) end end printt(stat.Token_Equals) for index, ex in pairs(stat.Rhs) do printExpr(ex) local sep = stat.Token_RhsSeparatorList[index] if sep then printt(sep) end end else assert(false, "unreachable") end end printStat(ast) end -- Get an actual string representation of the AST local function AstToString(ast) local output = {} PrintAst(ast, output) return table.concat(output) end -- Adds / removes whitespace in an AST to put it into a "standard formatting" local function FormatAst(ast) local formatStat, formatExpr local currentIndent = 0 local function applyIndent(token) local indentString = '\n'..('\t'):rep(currentIndent) if token.LeadingWhite == '' or (token.LeadingWhite:sub(-#indentString, -1) ~= indentString) then -- Trim existing trailing whitespace on LeadingWhite -- Trim trailing tabs and spaces, and up to one newline token.LeadingWhite = token.LeadingWhite:gsub("\n?[\t ]*$", "") token.LeadingWhite = token.LeadingWhite..indentString end end local function indent() currentIndent = currentIndent + 1 end local function undent() currentIndent = currentIndent - 1 assert(currentIndent >= 0, "Undented too far") end local function leadingChar(tk) if #tk.LeadingWhite > 0 then return tk.LeadingWhite:sub(1,1) else return tk.Source:sub(1,1) end end local function padToken(tk) if not WhiteChars[leadingChar(tk)] then tk.LeadingWhite = ' '..tk.LeadingWhite end end local function padExpr(expr) padToken(expr:GetFirstToken()) end local function formatBody(openToken, bodyStat, closeToken) -- luacheck: ignore 212 indent() formatStat(bodyStat) undent() applyIndent(closeToken) end formatExpr = function(expr) if expr.Type == 'BinopExpr' then formatExpr(expr.Lhs) formatExpr(expr.Rhs) if expr.Token_Op.Source ~= '..' then -- No padding on .. padExpr(expr.Rhs) padToken(expr.Token_Op) end elseif expr.Type == 'UnopExpr' then formatExpr(expr.Rhs) --(expr.Token_Op) elseif expr.Type == 'NumberLiteral' or expr.Type == 'StringLiteral' or expr.Type == 'NilLiteral' or expr.Type == 'BooleanLiteral' or expr.Type == 'VargLiteral' then do_nothing() --(expr.Token) elseif expr.Type == 'FieldExpr' then formatExpr(expr.Base) --(expr.Token_Dot) --(expr.Field) elseif expr.Type == 'IndexExpr' then formatExpr(expr.Base) formatExpr(expr.Index) --(expr.Token_OpenBracket) --(expr.Token_CloseBracket) elseif expr.Type == 'MethodExpr' or expr.Type == 'CallExpr' then formatExpr(expr.Base) if expr.Type == 'MethodExpr' then do_nothing() --(expr.Token_Colon) --(expr.Method) end if expr.FunctionArguments.CallType == 'StringCall' then do_nothing() --(expr.FunctionArguments.Token) elseif expr.FunctionArguments.CallType == 'ArgCall' then --(expr.FunctionArguments.Token_OpenParen) for index, argExpr in pairs(expr.FunctionArguments.ArgList) do formatExpr(argExpr) if index > 1 then padExpr(argExpr) end --[[ local sep = expr.FunctionArguments.Token_CommaList[index] if sep then --(sep) end --]] end --(expr.FunctionArguments.Token_CloseParen) elseif expr.FunctionArguments.CallType == 'TableCall' then formatExpr(expr.FunctionArguments.TableExpr) end elseif expr.Type == 'FunctionLiteral' then --(expr.Token_Function) --(expr.Token_OpenParen) for index, arg in pairs(expr.ArgList) do --(arg) if index > 1 then padToken(arg) end --[[ local comma = expr.Token_ArgCommaList[index] if comma then --(comma) end --]] end --(expr.Token_CloseParen) formatBody(expr.Token_CloseParen, expr.Body, expr.Token_End) elseif expr.Type == 'VariableExpr' then do_nothing() --(expr.Token) elseif expr.Type == 'ParenExpr' then formatExpr(expr.Expression) --(expr.Token_OpenParen) --(expr.Token_CloseParen) elseif expr.Type == 'TableLiteral' then --(expr.Token_OpenBrace) if #expr.EntryList > 0 then indent() for _, entry in pairs(expr.EntryList) do if entry.EntryType == 'Field' then applyIndent(entry.Field) padToken(entry.Token_Equals) formatExpr(entry.Value) padExpr(entry.Value) elseif entry.EntryType == 'Index' then applyIndent(entry.Token_OpenBracket) formatExpr(entry.Index) --(entry.Token_CloseBracket) padToken(entry.Token_Equals) formatExpr(entry.Value) padExpr(entry.Value) elseif entry.EntryType == 'Value' then formatExpr(entry.Value) applyIndent(entry.Value:GetFirstToken()) else assert(false, "unreachable") end --[[ local sep = expr.Token_SeparatorList[index] if sep then --(sep) end --]] end undent() applyIndent(expr.Token_CloseBrace) end --(expr.Token_CloseBrace) else assert(false, "unreachable, type: "..expr.Type..":"..FormatTable(expr)) end end formatStat = function(stat) if stat.Type == 'StatList' then for _, _stat in pairs(stat.StatementList) do formatStat(_stat) applyIndent(_stat:GetFirstToken()) end elseif stat.Type == 'BreakStat' then do_nothing() --(stat.Token_Break) elseif stat.Type == 'ReturnStat' then --(stat.Token_Return) for index, expr in pairs(stat.ExprList) do formatExpr(expr) padExpr(expr) if stat.Token_CommaList[index] then do_nothing() --(stat.Token_CommaList[index]) end end elseif stat.Type == 'LocalVarStat' then --(stat.Token_Local) for _, var in pairs(stat.VarList) do padToken(var) --[[ local comma = stat.Token_VarCommaList[index] if comma then --(comma) end --]] end if stat.Token_Equals then padToken(stat.Token_Equals) for _, expr in pairs(stat.ExprList) do formatExpr(expr) padExpr(expr) --[[ local comma = stat.Token_ExprCommaList[index] if comma then --(comma) end --]] end end elseif stat.Type == 'LocalFunctionStat' then --(stat.Token_Local) padToken(stat.FunctionStat.Token_Function) padToken(stat.FunctionStat.NameChain[1]) --(stat.FunctionStat.Token_OpenParen) for index, arg in pairs(stat.FunctionStat.ArgList) do if index > 1 then padToken(arg) end --[[ local comma = stat.FunctionStat.Token_ArgCommaList[index] if comma then --(comma) end --]] end --(stat.FunctionStat.Token_CloseParen) formatBody(stat.FunctionStat.Token_CloseParen, stat.FunctionStat.Body, stat.FunctionStat.Token_End) elseif stat.Type == 'FunctionStat' then --(stat.Token_Function) for index, part in pairs(stat.NameChain) do if index == 1 then padToken(part) end --[[ local sep = stat.Token_NameChainSeparator[index] if sep then --(sep) end --]] end --(stat.Token_OpenParen) for index, arg in pairs(stat.ArgList) do if index > 1 then padToken(arg) end --[[ local comma = stat.Token_ArgCommaList[index] if comma then --(comma) end --]] end --(stat.Token_CloseParen) formatBody(stat.Token_CloseParen, stat.Body, stat.Token_End) elseif stat.Type == 'RepeatStat' then --(stat.Token_Repeat) formatBody(stat.Token_Repeat, stat.Body, stat.Token_Until) formatExpr(stat.Condition) padExpr(stat.Condition) elseif stat.Type == 'GenericForStat' then --(stat.Token_For) for _, var in pairs(stat.VarList) do padToken(var) --[[ local sep = stat.Token_VarCommaList[index] if sep then --(sep) end --]] end padToken(stat.Token_In) for _, expr in pairs(stat.GeneratorList) do formatExpr(expr) padExpr(expr) --[[ local sep = stat.Token_GeneratorCommaList[index] if sep then --(sep) end --]] end padToken(stat.Token_Do) formatBody(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'NumericForStat' then --(stat.Token_For) for _, var in pairs(stat.VarList) do padToken(var) --[[ local sep = stat.Token_VarCommaList[index] if sep then --(sep) end --]] end padToken(stat.Token_Equals) for _, expr in pairs(stat.RangeList) do formatExpr(expr) padExpr(expr) --[[ local sep = stat.Token_RangeCommaList[index] if sep then --(sep) end --]] end padToken(stat.Token_Do) formatBody(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'WhileStat' then --(stat.Token_While) formatExpr(stat.Condition) padExpr(stat.Condition) padToken(stat.Token_Do) formatBody(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'DoStat' then --(stat.Token_Do) formatBody(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'IfStat' then --(stat.Token_If) formatExpr(stat.Condition) padExpr(stat.Condition) padToken(stat.Token_Then) -- local lastBodyOpen = stat.Token_Then local lastBody = stat.Body -- for _, clause in pairs(stat.ElseClauseList) do formatBody(lastBodyOpen, lastBody, clause.Token) lastBodyOpen = clause.Token -- if clause.Condition then formatExpr(clause.Condition) padExpr(clause.Condition) padToken(clause.Token_Then) lastBodyOpen = clause.Token_Then end lastBody = clause.Body end -- formatBody(lastBodyOpen, lastBody, stat.Token_End) elseif stat.Type == 'CallExprStat' then formatExpr(stat.Expression) elseif stat.Type == 'AssignmentStat' then for index, ex in pairs(stat.Lhs) do formatExpr(ex) if index > 1 then padExpr(ex) end --[[ local sep = stat.Token_LhsSeparatorList[index] if sep then --(sep) end --]] end padToken(stat.Token_Equals) for _, ex in pairs(stat.Rhs) do formatExpr(ex) padExpr(ex) --[[ local sep = stat.Token_RhsSeparatorList[index] if sep then --(sep) end --]] end else assert(false, "unreachable") end end formatStat(ast) end -- Strips as much whitespace off of tokens in an AST as possible without causing problems local function StripAst(ast) local stripStat, stripExpr local function stript(token) token.LeadingWhite = '' end -- Make to adjacent tokens as close as possible local function joint(tokenA, tokenB) -- Strip the second token's whitespace stript(tokenB) -- Get the trailing A <-> leading B character pair local lastCh = tokenA.Source:sub(-1, -1) local firstCh = tokenB.Source:sub(1, 1) -- Cases to consider: -- Touching minus signs -> comment: `- -42` -> `--42' is invalid -- Touching words: `a b` -> `ab` is invalid -- Touching digits: `2 3`, can't occur in the Lua syntax as number literals aren't a primary expression -- Ambiguous syntax: `f(x)\n(x)()` is already disallowed, we can't cause a problem by removing newlines -- Figure out what separation is needed if (lastCh == '-' and firstCh == '-') or (AllIdentChars[lastCh] and AllIdentChars[firstCh]) then tokenB.LeadingWhite = ' ' -- Use a separator else tokenB.LeadingWhite = '' -- Don't use a separator end end -- Join up a statement body and it's opening / closing tokens local function bodyjoint(open, body, close) stripStat(body) stript(close) local bodyFirst = body:GetFirstToken() local bodyLast = body:GetLastToken() if bodyFirst then -- Body is non-empty, join body to open / close joint(open, bodyFirst) joint(bodyLast, close) else -- Body is empty, just join open and close token together joint(open, close) end end stripExpr = function(expr) if expr.Type == 'BinopExpr' then stripExpr(expr.Lhs) stript(expr.Token_Op) stripExpr(expr.Rhs) -- Handle the `a - -b` -/-> `a--b` case which would otherwise incorrectly generate a comment -- Also handles operators "or" / "and" which definitely need joining logic in a bunch of cases joint(expr.Token_Op, expr.Rhs:GetFirstToken()) joint(expr.Lhs:GetLastToken(), expr.Token_Op) elseif expr.Type == 'UnopExpr' then stript(expr.Token_Op) stripExpr(expr.Rhs) -- Handle the `- -b` -/-> `--b` case which would otherwise incorrectly generate a comment joint(expr.Token_Op, expr.Rhs:GetFirstToken()) elseif expr.Type == 'NumberLiteral' or expr.Type == 'StringLiteral' or expr.Type == 'NilLiteral' or expr.Type == 'BooleanLiteral' or expr.Type == 'VargLiteral' then -- Just print the token stript(expr.Token) elseif expr.Type == 'FieldExpr' then stripExpr(expr.Base) stript(expr.Token_Dot) stript(expr.Field) elseif expr.Type == 'IndexExpr' then stripExpr(expr.Base) stript(expr.Token_OpenBracket) stripExpr(expr.Index) stript(expr.Token_CloseBracket) elseif expr.Type == 'MethodExpr' or expr.Type == 'CallExpr' then stripExpr(expr.Base) if expr.Type == 'MethodExpr' then stript(expr.Token_Colon) stript(expr.Method) end if expr.FunctionArguments.CallType == 'StringCall' then stript(expr.FunctionArguments.Token) elseif expr.FunctionArguments.CallType == 'ArgCall' then stript(expr.FunctionArguments.Token_OpenParen) for index, argExpr in pairs(expr.FunctionArguments.ArgList) do stripExpr(argExpr) local sep = expr.FunctionArguments.Token_CommaList[index] if sep then stript(sep) end end stript(expr.FunctionArguments.Token_CloseParen) elseif expr.FunctionArguments.CallType == 'TableCall' then stripExpr(expr.FunctionArguments.TableExpr) end elseif expr.Type == 'FunctionLiteral' then stript(expr.Token_Function) stript(expr.Token_OpenParen) for index, arg in pairs(expr.ArgList) do stript(arg) local comma = expr.Token_ArgCommaList[index] if comma then stript(comma) end end stript(expr.Token_CloseParen) bodyjoint(expr.Token_CloseParen, expr.Body, expr.Token_End) elseif expr.Type == 'VariableExpr' then stript(expr.Token) elseif expr.Type == 'ParenExpr' then stript(expr.Token_OpenParen) stripExpr(expr.Expression) stript(expr.Token_CloseParen) elseif expr.Type == 'TableLiteral' then stript(expr.Token_OpenBrace) for index, entry in pairs(expr.EntryList) do if entry.EntryType == 'Field' then stript(entry.Field) stript(entry.Token_Equals) stripExpr(entry.Value) elseif entry.EntryType == 'Index' then stript(entry.Token_OpenBracket) stripExpr(entry.Index) stript(entry.Token_CloseBracket) stript(entry.Token_Equals) stripExpr(entry.Value) elseif entry.EntryType == 'Value' then stripExpr(entry.Value) else assert(false, "unreachable") end local sep = expr.Token_SeparatorList[index] if sep then stript(sep) end end stript(expr.Token_CloseBrace) else assert(false, "unreachable, type: "..expr.Type..":"..FormatTable(expr)) end end stripStat = function(stat) if stat.Type == 'StatList' then -- Strip all surrounding whitespace on statement lists along with separating whitespace for i = 1, #stat.StatementList do local chStat = stat.StatementList[i] -- Strip the statement and it's whitespace stripStat(chStat) stript(chStat:GetFirstToken()) -- If there was a last statement, join them appropriately local lastChStat = stat.StatementList[i-1] if lastChStat then -- See if we can remove a semi-colon, the only case where we can't is if -- this and the last statement have a `);(` pair, where removing the semi-colon -- would introduce ambiguous syntax. if stat.SemicolonList[i-1] and (lastChStat:GetLastToken().Source ~= ')' or chStat:GetFirstToken().Source ~= '(') then stat.SemicolonList[i-1] = nil end -- If there isn't a semi-colon, we should safely join the two statements -- (If there is one, then no whitespace leading chStat is always okay) if not stat.SemicolonList[i-1] then joint(lastChStat:GetLastToken(), chStat:GetFirstToken()) end end end -- A semi-colon is never needed on the last stat in a statlist: stat.SemicolonList[#stat.StatementList] = nil -- The leading whitespace on the statlist should be stripped if #stat.StatementList > 0 then stript(stat.StatementList[1]:GetFirstToken()) end elseif stat.Type == 'BreakStat' then stript(stat.Token_Break) elseif stat.Type == 'ReturnStat' then stript(stat.Token_Return) for index, expr in pairs(stat.ExprList) do stripExpr(expr) if stat.Token_CommaList[index] then stript(stat.Token_CommaList[index]) end end if #stat.ExprList > 0 then joint(stat.Token_Return, stat.ExprList[1]:GetFirstToken()) end elseif stat.Type == 'LocalVarStat' then stript(stat.Token_Local) for index, var in pairs(stat.VarList) do if index == 1 then joint(stat.Token_Local, var) else stript(var) end local comma = stat.Token_VarCommaList[index] if comma then stript(comma) end end if stat.Token_Equals then stript(stat.Token_Equals) for index, expr in pairs(stat.ExprList) do stripExpr(expr) local comma = stat.Token_ExprCommaList[index] if comma then stript(comma) end end end elseif stat.Type == 'LocalFunctionStat' then stript(stat.Token_Local) joint(stat.Token_Local, stat.FunctionStat.Token_Function) joint(stat.FunctionStat.Token_Function, stat.FunctionStat.NameChain[1]) joint(stat.FunctionStat.NameChain[1], stat.FunctionStat.Token_OpenParen) for index, arg in pairs(stat.FunctionStat.ArgList) do stript(arg) local comma = stat.FunctionStat.Token_ArgCommaList[index] if comma then stript(comma) end end stript(stat.FunctionStat.Token_CloseParen) bodyjoint(stat.FunctionStat.Token_CloseParen, stat.FunctionStat.Body, stat.FunctionStat.Token_End) elseif stat.Type == 'FunctionStat' then stript(stat.Token_Function) for index, part in pairs(stat.NameChain) do if index == 1 then joint(stat.Token_Function, part) else stript(part) end local sep = stat.Token_NameChainSeparator[index] if sep then stript(sep) end end stript(stat.Token_OpenParen) for index, arg in pairs(stat.ArgList) do stript(arg) local comma = stat.Token_ArgCommaList[index] if comma then stript(comma) end end stript(stat.Token_CloseParen) bodyjoint(stat.Token_CloseParen, stat.Body, stat.Token_End) elseif stat.Type == 'RepeatStat' then stript(stat.Token_Repeat) bodyjoint(stat.Token_Repeat, stat.Body, stat.Token_Until) stripExpr(stat.Condition) joint(stat.Token_Until, stat.Condition:GetFirstToken()) elseif stat.Type == 'GenericForStat' then stript(stat.Token_For) for index, var in pairs(stat.VarList) do if index == 1 then joint(stat.Token_For, var) else stript(var) end local sep = stat.Token_VarCommaList[index] if sep then stript(sep) end end joint(stat.VarList[#stat.VarList], stat.Token_In) for index, expr in pairs(stat.GeneratorList) do stripExpr(expr) if index == 1 then joint(stat.Token_In, expr:GetFirstToken()) end local sep = stat.Token_GeneratorCommaList[index] if sep then stript(sep) end end joint(stat.GeneratorList[#stat.GeneratorList]:GetLastToken(), stat.Token_Do) bodyjoint(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'NumericForStat' then stript(stat.Token_For) for index, var in pairs(stat.VarList) do if index == 1 then joint(stat.Token_For, var) else stript(var) end local sep = stat.Token_VarCommaList[index] if sep then stript(sep) end end joint(stat.VarList[#stat.VarList], stat.Token_Equals) for index, expr in pairs(stat.RangeList) do stripExpr(expr) if index == 1 then joint(stat.Token_Equals, expr:GetFirstToken()) end local sep = stat.Token_RangeCommaList[index] if sep then stript(sep) end end joint(stat.RangeList[#stat.RangeList]:GetLastToken(), stat.Token_Do) bodyjoint(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'WhileStat' then stript(stat.Token_While) stripExpr(stat.Condition) stript(stat.Token_Do) joint(stat.Token_While, stat.Condition:GetFirstToken()) joint(stat.Condition:GetLastToken(), stat.Token_Do) bodyjoint(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'DoStat' then stript(stat.Token_Do) stript(stat.Token_End) bodyjoint(stat.Token_Do, stat.Body, stat.Token_End) elseif stat.Type == 'IfStat' then stript(stat.Token_If) stripExpr(stat.Condition) joint(stat.Token_If, stat.Condition:GetFirstToken()) joint(stat.Condition:GetLastToken(), stat.Token_Then) -- local lastBodyOpen = stat.Token_Then local lastBody = stat.Body -- for _, clause in pairs(stat.ElseClauseList) do bodyjoint(lastBodyOpen, lastBody, clause.Token) lastBodyOpen = clause.Token -- if clause.Condition then stripExpr(clause.Condition) joint(clause.Token, clause.Condition:GetFirstToken()) joint(clause.Condition:GetLastToken(), clause.Token_Then) lastBodyOpen = clause.Token_Then end stripStat(clause.Body) lastBody = clause.Body end -- bodyjoint(lastBodyOpen, lastBody, stat.Token_End) elseif stat.Type == 'CallExprStat' then stripExpr(stat.Expression) elseif stat.Type == 'AssignmentStat' then for index, ex in pairs(stat.Lhs) do stripExpr(ex) local sep = stat.Token_LhsSeparatorList[index] if sep then stript(sep) end end stript(stat.Token_Equals) for index, ex in pairs(stat.Rhs) do stripExpr(ex) local sep = stat.Token_RhsSeparatorList[index] if sep then stript(sep) end end else assert(false, "unreachable") end end stripStat(ast) end local VarDigits = {} for i = ('a'):byte(), ('z'):byte() do table.insert(VarDigits, string.char(i)) end for i = ('A'):byte(), ('Z'):byte() do table.insert(VarDigits, string.char(i)) end for i = ('0'):byte(), ('9'):byte() do table.insert(VarDigits, string.char(i)) end table.insert(VarDigits, '_') local VarStartDigits = {} for i = ('a'):byte(), ('z'):byte() do table.insert(VarStartDigits, string.char(i)) end for i = ('A'):byte(), ('Z'):byte() do table.insert(VarStartDigits, string.char(i)) end local function indexToVarName(index) local id = '' local d = index % #VarStartDigits index = (index - d) / #VarStartDigits id = id..VarStartDigits[d+1] while index > 0 do d = index % #VarDigits index = (index - d) / #VarDigits id = id..VarDigits[d+1] end return id end local function MinifyVariables(globalScope, rootScope) -- externalGlobals is a set of global variables that have not been assigned to, that is -- global variables defined "externally to the script". We are not going to be renaming -- those, and we have to make sure that we don't collide with them when renaming -- things so we keep track of them in this set. local externalGlobals = {} -- First we want to rename all of the variables to unique temporaries, so that we can -- easily use the scope::GetVar function to check whether renames are valid. local temporaryIndex = 0 for _, var in pairs(globalScope) do if var.AssignedTo then var:Rename('_TMP_'..temporaryIndex..'_') temporaryIndex = temporaryIndex + 1 else -- Not assigned to, external global externalGlobals[var.Name] = true end end -- Now we go through renaming, first do globals, we probably want them -- to have shorter names in general. -- TODO: Rename all vars based on frequency patterns, giving variables -- used more shorter names. local nextFreeNameIndex = 0 for _, var in pairs(globalScope) do if var.AssignedTo then local varName repeat varName = indexToVarName(nextFreeNameIndex) nextFreeNameIndex = nextFreeNameIndex + 1 until not Keywords[varName] and not externalGlobals[varName] var:Rename(varName) end end -- Now rename all local vars rootScope.FirstFreeName = nextFreeNameIndex local function doRenameScope(scope) for _, var in pairs(scope.VariableList) do local varName repeat varName = indexToVarName(scope.FirstFreeName) scope.FirstFreeName = scope.FirstFreeName + 1 until not Keywords[varName] and not externalGlobals[varName] var:Rename(varName) end for _, childScope in pairs(scope.ChildScopeList) do childScope.FirstFreeName = scope.FirstFreeName doRenameScope(childScope) end end doRenameScope(rootScope) end --[[ local function MinifyVariables_2(globalScope, rootScope) -- Variable names and other names that are fixed, that we cannot use -- Either these are Lua keywords, or globals that are not assigned to, -- that is environmental globals that are assigned elsewhere beyond our -- control. local globalUsedNames = {} for kw, _ in pairs(Keywords) do globalUsedNames[kw] = true end -- Gather a list of all of the variables that we will rename local allVariables = {} local allLocalVariables = {} do -- Add applicable globals for _, var in pairs(globalScope) do if var.AssignedTo then -- We can try to rename this global since it was assigned to -- (and thus presumably initialized) in the script we are -- minifying. table.insert(allVariables, var) else -- We can't rename this global, mark it as an unusable name -- and don't add it to the rename list globalUsedNames[var.Name] = true end end -- Recursively add locals, we can rename all of those local function addFrom(scope) for _, var in pairs(scope.VariableList) do table.insert(allVariables, var) table.insert(allLocalVariables, var) end for _, childScope in pairs(scope.ChildScopeList) do addFrom(childScope) end end addFrom(rootScope) end -- Add used name arrays to variables for _, var in pairs(allVariables) do var.UsedNameArray = {} end -- Sort the least used variables first table.sort(allVariables, function(a, b) return #a.RenameList < #b.RenameList end) -- Lazy generator for valid names to rename to local nextValidNameIndex = 0 local varNamesLazy = {} local function varIndexToValidVarName(i) local name = varNamesLazy[i] if not name then repeat name = indexToVarName(nextValidNameIndex) nextValidNameIndex = nextValidNameIndex + 1 until not globalUsedNames[name] varNamesLazy[i] = name end return name end -- For each variable, go to rename it for _, var in pairs(allVariables) do -- Lazy... todo: Make this pair a proper for-each-pair-like set of loops -- rather than using a renamed flag. var.Renamed = true -- Find the first unused name local i = 1 while var.UsedNameArray[i] do i = i + 1 end -- Rename the variable to that name var:Rename(varIndexToValidVarName(i)) if var.Scope then -- Now we need to mark the name as unusable by any variables: -- 1) At the same depth that overlap lifetime with this one -- 2) At a deeper level, which have a reference to this variable in their lifetimes -- 3) At a shallower level, which are referenced during this variable's lifetime for _, otherVar in pairs(allVariables) do if not otherVar.Renamed then if not otherVar.Scope or otherVar.Scope.Depth < var.Scope.Depth then -- Check Global variable (Which is always at a shallower level) -- or -- Check case 3 -- The other var is at a shallower depth, is there a reference to it -- during this variable's lifetime? for _, refAt in pairs(otherVar.ReferenceLocationList) do if refAt >= var.BeginLocation and refAt <= var.ScopeEndLocation then -- Collide otherVar.UsedNameArray[i] = true break end end elseif otherVar.Scope.Depth > var.Scope.Depth then -- Check Case 2 -- The other var is at a greater depth, see if any of the references -- to this variable are in the other var's lifetime. for _, refAt in pairs(var.ReferenceLocationList) do if refAt >= otherVar.BeginLocation and refAt <= otherVar.ScopeEndLocation then -- Collide otherVar.UsedNameArray[i] = true break end end else --otherVar.Scope.Depth must be equal to var.Scope.Depth -- Check case 1 -- The two locals are in the same scope -- Just check if the usage lifetimes overlap within that scope. That is, we -- can shadow a local variable within the same scope as long as the usages -- of the two locals do not overlap. if var.BeginLocation < otherVar.EndLocation and var.EndLocation > otherVar.BeginLocation then otherVar.UsedNameArray[i] = true end end end end else -- This is a global var, all other globals can't collide with it, and -- any local variable with a reference to this global in it's lifetime -- can't collide with it. for _, otherVar in pairs(allVariables) do if not otherVar.Renamed then if otherVar.Type == 'Global' then otherVar.UsedNameArray[i] = true elseif otherVar.Type == 'Local' then -- Other var is a local, see if there is a reference to this global within -- that local's lifetime. for _, refAt in pairs(var.ReferenceLocationList) do if refAt >= otherVar.BeginLocation and refAt <= otherVar.ScopeEndLocation then -- Collide otherVar.UsedNameArray[i] = true break end end else assert(false, "unreachable") end end end end end -- -- -- print("Total Variables: "..#allVariables) -- print("Total Range: "..rootScope.BeginLocation.."-"..rootScope.EndLocation) -- print("") -- for _, var in pairs(allVariables) do -- io.write("`"..var.Name.."':\n\t#symbols: "..#var.RenameList.. -- "\n\tassigned to: "..tostring(var.AssignedTo)) -- if var.Type == 'Local' then -- io.write("\n\trange: "..var.BeginLocation.."-"..var.EndLocation) -- io.write("\n\tlocal type: "..var.Info.Type) -- end -- io.write("\n\n") -- end -- -- First we want to rename all of the variables to unique temporaries, so that we can -- -- easily use the scope::GetVar function to check whether renames are valid. -- local temporaryIndex = 0 -- for _, var in pairs(allVariables) do -- var:Rename('_TMP_'..temporaryIndex..'_') -- temporaryIndex = temporaryIndex + 1 -- end -- For each variable, we need to build a list of names that collide with it -- --error() end --]] local function BeautifyVariables(globalScope, rootScope) -- externalGlobals isn't used anywhere, so this setup seems pointless --[[ local externalGlobals = {} for _, var in pairs(globalScope) do if not var.AssignedTo then externalGlobals[var.Name] = true end end --]] local localNumber = 1 local globalNumber = 1 local function setVarName(var, name) var.Name = name for _, setter in pairs(var.RenameList) do setter(name) end end for _, var in pairs(globalScope) do if var.AssignedTo then setVarName(var, 'G_'..globalNumber) globalNumber = globalNumber + 1 end end local function modify(scope) for _, var in pairs(scope.VariableList) do local name = 'L_'..localNumber..'_' if var.Info.Type == 'Argument' then name = name..'arg'..var.Info.Index elseif var.Info.Type == 'LocalFunction' then name = name..'func' elseif var.Info.Type == 'ForRange' then name = name..'forvar'..var.Info.Index end setVarName(var, name) localNumber = localNumber + 1 end for _, child in pairs(scope.ChildScopeList) do modify(child) end end modify(rootScope) end local function usageError() error( "\nusage: minify or unminify \n" .. " The modified code will be printed to the stdout, pipe it to a file, the\n" .. " lua interpreter, or something else as desired EG:\n\n" .. " lua minify.lua minify input.lua > output.lua\n\n" .. " * minify will minify the code in the file.\n" .. " * unminify will beautify the code and replace the variable names with easily\n" .. " find-replacable ones to aide in reverse engineering minified code.\n", 0) end -- Enable this file to be used as a module: -- If it gets loaded via require(), return a module table with exported entities if debug.getinfo(2, "n").name == "require" then return { AddVariableInfo = AddVariableInfo, AstToString = AstToString, BeautifyVariables = BeautifyVariables, CountTable = CountTable, CreateLuaParser = CreateLuaParser, CreateLuaTokenStream = CreateLuaTokenStream, FormatAst = FormatAst, FormatTable = FormatTable, GlobalRenameIgnore = GlobalRenameIgnore, MinifyVariables = MinifyVariables, PrintAst = PrintAst, StripAst = StripAst, } end local args = {...} if #args ~= 2 then usageError() end local sourceFile = io.open(args[2], 'r') if not sourceFile then error("Could not open the input file `" .. args[2] .. "`", 0) end local function minify(ast, global_scope, root_scope) MinifyVariables(global_scope, root_scope) StripAst(ast) PrintAst(ast) end local function beautify(ast, global_scope, root_scope) BeautifyVariables(global_scope, root_scope) FormatAst(ast) PrintAst(ast) end local data = sourceFile:read('*all') local ast = CreateLuaParser(data) local global_scope, root_scope = AddVariableInfo(ast) if args[1] == 'minify' then minify(ast, global_scope, root_scope) elseif args[1] == 'unminify' then beautify(ast, global_scope, root_scope) else usageError() end ================================================ FILE: roblox/README.md ================================================ # Roblox-ECS LINK: https://github.com/nidorx/ecs-lua/blob/master/src/shared/ECSUtil.lua ## Utility Systems and Components ECSUtil provides some basic systems and components, described below ### Components - _ECSUtil._**BasePartComponent** - A component that facilitates access to BasePart - _ECSUtil._**PositionComponent** - Component that works with a position `Vector3` - _ECSUtil._**RotationComponent** - Rotational vectors _(right, up, look)_ that represents the object in the 3d world. To transform into a CFrame use `CFrame.fromMatrix(pos, rot[1], rot[2], rot[3] * -1)` - _ECSUtil._**PositionInterpolationComponent** - Allows to register two last positions (`Vector3`) to allow interpolation - _ECSUtil._**RotationInterpolationComponent** - Allows to record two last rotations (`rightVector`, `upVector`, `lookVector`) to allow interpolation - _ECSUtil._**BasePartToEntitySyncComponent** - Tag, indicates that the `Entity` _(ECS)_ must be synchronized with the data from the `BasePart` _(workspace)_ - _ECSUtil._**EntityToBasePartSyncComponent** - Tag, indicates that the `BasePart` _(workspace)_ must be synchronized with the existing data in the `Entity` _(ECS)_ - _ECSUtil._**MoveForwardComponent** - Tag, indicates that the forward movement system must act on this entity - _ECSUtil._**MoveSpeedComponent** - Allows you to define a movement speed for specialized handling systems ### Systems - _ECSUtil._**BasePartToEntityProcessInSystem** - Synchronizes the `Entity` _(ECS)_ with the data of a `BasePart` _(workspace)_ at the beginning of the `process` step - ```lua step = 'process', order = 10, requireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.BasePartToEntitySyncComponent }, rejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent } ``` - _ECSUtil._**BasePartToEntityTransformSystem** - Synchronizes the `Entity` _(ECS)_ with the data of a `BasePart` _(workspace)_ at the beginning of the `transform` step _(After running the Roblox physics engine)_ - ```lua step = 'transform', order = 10, requireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.BasePartToEntitySyncComponent }, rejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent } ``` - _ECSUtil._**EntityToBasePartProcessOutSystem** - Synchronizes the `BasePart` _(workspace)_ with the `Entity` _(ECS)_ data at the end of the `processOut` step _(before Roblox's physics engine runs)_ - ```lua step = 'process', order = 100, requireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.EntityToBasePartSyncComponent } ``` - _ECSUtil._**EntityToBasePartTransformSystem** - Synchronizes the `BasePart` _(workspace)_ with the `Entity` _(ECS)_ data at the end of the `transform` step _(last step of the current frame in multi-thread execution)_ - ```lua step = 'transform', order = 100, requireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.EntityToBasePartSyncComponent }, rejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent } ``` - _ECSUtil._**EntityToBasePartInterpolationTransformSystem** - Interpolates the position and rotation of a BasePart in the `transform` step. Allows the `process` step to be performed at low frequency with smooth rendering - ```lua step = 'transform', order = 100, requireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent, ECSUtil.EntityToBasePartSyncComponent } ``` - _ECSUtil._**MoveForwardSystem** - Simple forward movement system (position = position + speed * lookVector) - ```lua step = 'process', requireAll = { ECSUtil.MoveSpeedComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.MoveForwardComponent, } ``` ================================================ FILE: roblox/RobloxUtils.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) -- precision local EPSILON = 0.000000001 local function floatEQ(n0, n1) if n0 == n1 then return true end return math.abs(n1 - n0) < EPSILON end local function vectorEQ(v0, v1) if v0 == v1 then return true end if not floatEQ(v0.X, v1.X) or not floatEQ(v0.Y, v1.Y) or not floatEQ(v0.Z, v1.Z) then return false else return true end end ---------------------------------------------------------------------------------------------------------------------- -- UTILITY COMPONENTS & SYSTEMS ---------------------------------------------------------------------------------------------------------------------- local ECSUtil = {} -- A component that facilitates access to BasePart ECSUtil.BasePartComponent = ECS.Component('BasePart', function(object) if object == nil or object['IsA'] == nil or object:IsA('BasePart') == false then error("This component only works with BasePart objects") end return object end) -- Tag, indicates that the entity must be synchronized with the data from the BasePart (workspace) ECSUtil.BasePartToEntitySyncComponent = ECS.Component('BasePartToEntitySync', nil, true) -- Tag, indicates that the BasePart (workspace) must be synchronized with the existing data in the Entity (ECS) ECSUtil.EntityToBasePartSyncComponent = ECS.Component('EntityToBasePartSync', nil, true) -- Component that works with a position Vector3 ECSUtil.PositionComponent = ECS.Component('Position', function(position) if position ~= nil and typeof(position) ~= 'Vector3' then error("This component only works with Vector3 objects") end if position == nil then position = Vector3.new(0, 0, 0) end return position end) -- Allows to register two last positions (Vector3) to allow interpolation ECSUtil.PositionInterpolationComponent = ECS.Component('PositionInterpolation', function(position) if position ~= nil and typeof(position) ~= 'Vector3' then error("This component only works with Vector3 objects") end if position == nil then position = Vector3.new(0, 0, 0) end return {position, position} end) -- {avgDelta, lastUpdate, position, rightVector, upVector, lookVector} ECSUtil.InterpolationCustomComponent = ECS.Component('InterpolationCustom', function(avgDelta, lastUpdate, lastPosition, lastRightVector, lastUpVector, lastLookVector) if avgDelta == nil then return nil end return {avgDelta, lastUpdate, lastPosition, lastRightVector, lastUpVector, lastLookVector} end) local VEC3_R = Vector3.new(1, 0, 0) local VEC3_U = Vector3.new(0, 1, 0) local VEC3_F = Vector3.new(0, 0, 1) --[[ Rotational vectors that represents the object in the 3d world. To transform into a CFrame use CFrame.fromMatrix(pos, rot[1], rot[2], rot[3] * -1) Params lookVector {Vector3} @See CFrame.LookVector rightVector {Vector3} @See CFrame.RightVector upVector {Vector3} @See CFrame.UpVector @See https://devforum.roblox.com/t/understanding-cframe-frommatrix-the-replacement-for-cframe-new/593742 https://devforum.roblox.com/t/handling-the-edge-cases-of-cframe-frommatrix/632465 ]] ECSUtil.RotationComponent = ECS.Component('Rotation', function(rightVector, upVector, lookVector) if rightVector ~= nil and typeof(rightVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=rightVector]") end if upVector ~= nil and typeof(upVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=upVector]") end if lookVector ~= nil and typeof(lookVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=lookVector]") end if rightVector == nil then rightVector = VEC3_R end if upVector == nil then upVector = VEC3_U end if lookVector == nil then lookVector = VEC3_F end return {rightVector, upVector, lookVector} end) -- Allows to record two last rotations (rightVector, upVector, lookVector) to allow interpolation ECSUtil.RotationInterpolationComponent = ECS.Component('RotationInterpolation', function(rightVector, upVector, lookVector) if rightVector ~= nil and typeof(rightVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=rightVector]") end if upVector ~= nil and typeof(upVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=upVector]") end if lookVector ~= nil and typeof(lookVector) ~= 'Vector3' then error("This component only works with Vector3 objects [param=lookVector]") end if rightVector == nil then rightVector = VEC3_R end if upVector == nil then upVector = VEC3_U end if lookVector == nil then lookVector = VEC3_F end return {{rightVector, upVector, lookVector}, {rightVector, upVector, lookVector}} end) -- Tag, indicates that the forward movement system must act on this entity ECSUtil.MoveForwardComponent = ECS.Component('MoveForward', nil, true) -- Allows you to define a movement speed for specialized handling systems ECSUtil.MoveSpeedComponent = ECS.Component('MoveSpeed', function(speed) if speed == nil or typeof(speed) ~= 'number' then error("This component only works with number value") end return speed end) ------------------------------------------ --[[ Utility system that copies the direction and position of a Roblox BasePart to the ECS entity Executed in two moments: At the beginning of the "process" step and at the beginning of the "transform" step ]] ---------------------------------------->> local function BasePartToEntityUpdate(time, world, dirty, entity, index, parts, positions, rotations) local changed = false local part = parts[index] if part ~= nil then local position = positions[index] local basePos = part.CFrame.Position if position == nil or not vectorEQ(basePos, position) then positions[index] = basePos changed = true end local rotation = rotations[index] local rightVector = part.CFrame.RightVector local upVector = part.CFrame.UpVector local lookVector = part.CFrame.LookVector if rotation == nil or not vectorEQ(rightVector, rotation[1]) or not vectorEQ(upVector, rotation[2]) or not vectorEQ(lookVector, rotation[3]) then rotations[index] = {rightVector, upVector, lookVector} changed = true end end return changed end -- copia dados de basepart para entidade no inicio do processamento, ignora entidades marcadas com Interpolation ECSUtil.BasePartToEntityProcessInSystem = ECS.RegisterSystem({ Name = 'BasePartToEntityProcessIn', Step = 'process', Order = 10, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.BasePartToEntitySyncComponent }, RejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent }, Update = BasePartToEntityUpdate }) -- copia dados de um BasePart para entidade no inicio do passo transform ECSUtil.BasePartToEntityTransformSystem = ECS.RegisterSystem({ Name = 'BasePartToEntityTransform', Step = 'transform', Order = 10, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.BasePartToEntitySyncComponent }, RejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent }, Update = BasePartToEntityUpdate }) ----------------------------------------<< ------------------------------------------ --[[ Utility system that copies the direction and position from ECS entity to a Roblox BasePart Executed in two moments: At the end of the "process" step and at the end of the "transform" step ]] ---------------------------------------->> local function EntityToBasePartUpdate(time, world, dirty, entity, index, parts, positions, rotations) if not dirty then return false end local changed = false local part = parts[index] local position = positions[index] local rotation = rotations[index] if part ~= nil then local basePos = part.CFrame.Position local rightVector = part.CFrame.RightVector local upVector = part.CFrame.UpVector local lookVector = part.CFrame.LookVector -- goal cframe, allow interpolation local cframe = part.CFrame if position ~= nil and not vectorEQ(basePos, position) then cframe = CFrame.fromMatrix(position, rightVector, upVector, lookVector * -1) changed = true end if rotation ~= nil then if not vectorEQ(rightVector, rotation[1]) or not vectorEQ(upVector, rotation[2]) or not vectorEQ(lookVector, rotation[3]) then cframe = CFrame.fromMatrix(cframe.Position, rotation[1], rotation[2], rotation[3] * -1) changed = true end end if changed then part.CFrame = cframe end end return changed end -- copia dados da entidade para um BaseParte no fim do processamento ECSUtil.EntityToBasePartProcessOutSystem = ECS.RegisterSystem({ Name = 'EntityToBasePartProcess', Step = 'process', Order = 100, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.EntityToBasePartSyncComponent }, Update = EntityToBasePartUpdate }) -- copia dados de uma entidade para um BsePart no passo de transformação, ignora entidades com interpolação ECSUtil.EntityToBasePartTransformSystem = ECS.RegisterSystem({ Name = 'EntityToBasePartTransform', Step = 'transform', Order = 100, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.EntityToBasePartSyncComponent }, RejectAny = { ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent }, Update = EntityToBasePartUpdate }) -- Interpolates the position and rotation of a BasePart in the transform step. -- Allows the process step to be performed at low frequency and with smooth rendering local interpolationFactor = 1 ECSUtil.EntityToBasePartInterpolationTransformSystem = ECS.RegisterSystem({ Name = 'EntityToBasePartInterpolationTransform', Step = 'transform', Order = 100, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.PositionInterpolationComponent, ECSUtil.RotationInterpolationComponent, ECSUtil.EntityToBasePartSyncComponent }, RejectAny ={ ECSUtil.InterpolationCustomComponent }, BeforeUpdate = function(time, interpolation, world, system) interpolationFactor = interpolation end, Update = function(time, world, dirty, entity, index, parts, positions, rotations, positionsInt, rotationsInt) local part = parts[index] local position = positions[index] local rotation = rotations[index] if part ~= nil then -- goal cframe, allow interpolation local cframe = part.CFrame -- swap old and new position, if changed if position ~= nil then local rightVector = part.CFrame.RightVector local upVector = part.CFrame.UpVector local lookVector = part.CFrame.LookVector if not vectorEQ(positionsInt[index][1], position) then positionsInt[index][2] = positionsInt[index][1] positionsInt[index][1] = position end local oldPosition = positionsInt[index][2] cframe = CFrame.fromMatrix(oldPosition:Lerp(position, interpolationFactor), rightVector, upVector, lookVector * -1) end -- swap old and new rotation, if changed if rotation ~= nil then if not vectorEQ(rotationsInt[index][1][1], rotation[1]) or not vectorEQ(rotationsInt[index][1][2], rotation[2]) or not vectorEQ(rotationsInt[index][1][3], rotation[3]) then rotationsInt[index][2] = rotationsInt[index][1] rotationsInt[index][1] = rotation end local oldRotation = rotationsInt[index][2] cframe = CFrame.fromMatrix( cframe.Position, oldRotation[1]:Lerp(rotation[1], interpolationFactor), oldRotation[2]:Lerp(rotation[2], interpolationFactor), (oldRotation[3] * -1):Lerp((rotation[3] * -1), interpolationFactor) ) end part.CFrame = cframe end -- readonly return false end }) -- Customized interpolation, the developer is responsible for indicating the necessary parameters for calculating the interpolation ECSUtil.EntityToBasePartInterpolationCustomTransformSystem = ECS.RegisterSystem({ Name = 'EntityToBasePartInterpolationCustomTransform', Step = 'transform', Order = 100, RequireAll = { ECSUtil.BasePartComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.InterpolationCustomComponent, ECSUtil.EntityToBasePartSyncComponent }, Update = function(time, world, dirty, entity, index, parts, positions, rotations, interpolations) local part = parts[index] local position = positions[index] local rotation = rotations[index] -- {avgDelta, lastUpdate, position, rightVector, upVector, lookVector} local interp = interpolations[index] if part ~= nil and position ~= nil and rotation ~= nil and interp ~= nil then local avgDelta = interp[1] local lastUpdate = interp[2] local alpha = (time.frame-lastUpdate)/avgDelta local cframe = CFrame.fromMatrix(interp[3], interp[4], interp[5], interp[6] * -1) part.CFrame = cframe:Lerp ( CFrame.fromMatrix(position, rotation[1], rotation[2], rotation[3] * -1), alpha ) end -- readonly return false end }) ----------------------------------------<< -- Simple forward movement system (position = position + speed * lookVector) local moveForwardSpeedFactor = 1 ECSUtil.MoveForwardSystem = ECS.RegisterSystem({ Name = 'MoveForward', Step = 'process', RequireAll = { ECSUtil.MoveSpeedComponent, ECSUtil.PositionComponent, ECSUtil.RotationComponent, ECSUtil.MoveForwardComponent, }, BeforeUpdate = function(time, interpolation, world, system) moveForwardSpeedFactor = world.GetFrequency()/60 end, Update = function (time, world, dirty, entity, index, speeds, positions, rotations, forwards) local position = positions[index] if position ~= nil then local rotation = rotations[index] if rotation ~= nil then local speed = speeds[index] if speed ~= nil then -- speed/2 = 1 studs per second (120 = frequency) positions[index] = position + speed/moveForwardSpeedFactor * rotation[3] return true end end end return false end }) -- Creates an entity related to a BasePart function ECSUtil.NewBasePartEntity(world, part, syncBasePartToEntity, syncEntityToBasePart, interpolate) local entityID = world.Create() world.Set(entityID, ECSUtil.BasePartComponent, part) world.Set(entityID, ECSUtil.PositionComponent, part.CFrame.Position) world.Set(entityID, ECSUtil.RotationComponent, part.CFrame.RightVector, part.CFrame.UpVector, part.CFrame.LookVector) if syncBasePartToEntity then world.Set(entityID, ECSUtil.BasePartToEntitySyncComponent) end if syncEntityToBasePart then world.Set(entityID, ECSUtil.EntityToBasePartSyncComponent) end if interpolate then world.Set(entityID, ECSUtil.PositionInterpolationComponent, part.CFrame.Position) world.Set(entityID, ECSUtil.RotationInterpolationComponent, part.CFrame.RightVector, part.CFrame.UpVector, part.CFrame.LookVector) end return entityID end -- add default systems function ECSUtil.AddDefaultSystems(world) world.AddSystem(ECSUtil.BasePartToEntityProcessInSystem) world.AddSystem(ECSUtil.MoveForwardSystem) world.AddSystem(ECSUtil.EntityToBasePartProcessOutSystem) -- transform world.AddSystem(ECSUtil.BasePartToEntityTransformSystem) world.AddSystem(ECSUtil.EntityToBasePartTransformSystem) world.AddSystem(ECSUtil.EntityToBasePartInterpolationTransformSystem) world.AddSystem(ECSUtil.EntityToBasePartInterpolationCustomTransformSystem) end -- export ECS lib return ECSUtil ================================================ FILE: roblox/tutorial/default.project.json ================================================ { "name": "dat.GUI", "tree": { "$className": "DataModel", "ReplicatedStorage": { "$className": "ReplicatedStorage", "$path": "src/shared" }, "ServerScriptService": { "$className": "ServerScriptService", "$path": "src/server" }, "StarterPlayer": { "$className": "StarterPlayer", "StarterCharacterScripts": { "$className": "StarterCharacterScripts", "$path": "src/client" } } } } ================================================ FILE: roblox/tutorial/src/client/benchmark/init.client.lua ================================================ repeat wait() until game:GetService('Players').LocalPlayer.Character local DISABLED = true --[[ Benchmark, data oriented design ]] -- “Script timeout: exhausted allowed execution time” -- disable the timeout in Studio with the following command -- settings().Studio.ScriptTimeoutLength = -1 local case = require(script:WaitForChild('soa')) spawn(function() if DISABLED then return end wait(5) print('[Benchmark] '..case.name) local sizes = {1000, 2000, 5000, 10000, 50000, 100000, 200000, 300000, 500000} local sufs = {'OOP', 'DOP'} local samples = 20 local groups = {} for it = 1, samples do for _,size in ipairs(sizes) do print('[Benchmark] it '..it, ', size ', size) if groups['t'..size] == nil then groups['t'..size] = {} end for _,suffix in ipairs(sufs) do -- ignoring creation local input = case['gen'..suffix](size) if groups['t'..size][suffix] == nil then groups['t'..size][suffix] = 0 end local start_time = os.clock() -- runing test case['run'..suffix](input) local duration = os.clock() - start_time groups['t'..size][suffix] = groups['t'..size][suffix] + duration end end end -- print raw data, to use in excel print('[Benchmark] '..case.name) for i,size in ipairs(sizes) do if i == 1 then print('size', 'oop_avg_time', 'dop_avg_time') end local oop = groups['t'..size]['OOP'] local dop = groups['t'..size]['DOP'] print(size,'\t', oop/samples,'\t', dop/samples,'\t') end end) ================================================ FILE: roblox/tutorial/src/client/benchmark/soa.lua ================================================ local case = { name = 'Struct of Arrays vs. Array of Structs' } -- produce equal sequences of numbers for both tests local RANDOM_SEED = os.time() local Player = {} Player.__index = Player function Player.new(name, health, location, velocity, acceleration) return setmetatable({ name = name, health = health, location = location, velocity = velocity, acceleration = acceleration, }, Player) end function case.genOOP(size) math.randomseed(RANDOM_SEED) local players = table.create(size) for i = 1, size do players[i] = Player.new( string.format("player_name_%s", i), 100.0, {x = math.random(0, 10.0), y = math.random(0, 10.0)}, {x = math.random(0, 10.0), y = math.random(0, 10.0)}, {x = math.random(0, 10.0), y = math.random(0, 10.0)} ) end return players end function case.genDOP(size) math.randomseed(RANDOM_SEED) local names = table.create(size) local health = table.create(size) local locations = table.create(size) local velocities = table.create(size) local accelerations = table.create(size) for i = 1, size do names[i] = string.format("player_name_%s", i) health[i] = 100.0 locations[i] = {math.random(0, 10.0), math.random(0, 10.0)} velocities[i] = {math.random(0, 10.0), math.random(0, 10.0)} accelerations[i] = {math.random(0, 10.0), math.random(0, 10.0)} end return { names = names, health = health, locations = locations, velocities = velocities, accelerations = accelerations } end function case.runOOP(players) for i, player in ipairs(players) do if player.location.x > 100 or player.location.y > 100 then player.location = {x = 0, y = 0} end player.location = { x = player.location.x + player.velocity.x, y = player.location.y + player.velocity.y } if player.velocity.x > 100 or player.velocity.y > 100 then player.velocity = {x = 0, y = 0} end player.velocity = { x = player.velocity.x + player.acceleration.x, y = player.velocity.y + player.acceleration.y } end end function case.runDOP(world) local locations = world.locations local velocities = world.velocities local accelerations = world.accelerations for i, location in ipairs(locations) do if location[1] > 100 or location[2] > 100 then locations[i] = {0, 0} end locations[i] = { location[1] + velocities[i][1], location[2] + velocities[i][2] } if velocities[i][1] > 100 or velocities[i][2] > 100 then velocities[i] = {0, 0} end velocities[i] = { velocities[i][1] + accelerations[i][1], velocities[i][2] + accelerations[i][2] } end end return case ================================================ FILE: roblox/tutorial/src/client/tutorial/init.client.lua ================================================ repeat wait() until game.Players.LocalPlayer.Character local Players = game:GetService("Players") local Player = Players.LocalPlayer local Character = Player.Character -- services local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local ECSUtil = require(game.ReplicatedStorage:WaitForChild("ECSUtil")) -- Components local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local WeaponComponent = require(Components:WaitForChild("WeaponComponent")) -- Systems local Systems = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("system") local FiringSystem = require(Systems:WaitForChild("FiringSystem")) local PlayerShootingSystem = require(Systems:WaitForChild("PlayerShootingSystem")) local CleanupFiringSystem = require(Systems:WaitForChild("CleanupFiringSystem")) -- Our world local world = ECS.CreateWorld(nil, { Frequency = 10 }) world.AddSystem(FiringSystem) world.AddSystem(PlayerShootingSystem) world.AddSystem(CleanupFiringSystem) ECSUtil.AddDefaultSystems(world) -- Our weapon local rightHand = Character:WaitForChild("RightHand") local weapon = Instance.new("Part", Character) weapon.CanCollide = false weapon.CastShadow = false weapon.Size = Vector3.new(0.2, 0.2, 2) weapon.CFrame = rightHand.CFrame + Vector3.new(0, 0, -1) weapon.Color = Color3.fromRGB(255, 0, 255) local weldWeapon = Instance.new("WeldConstraint", weapon) weldWeapon.Part0 = weapon weldWeapon.Part1 = rightHand -- weapon bullet spawn local BulletSpawnPart = Instance.new("Part", weapon) BulletSpawnPart.CanCollide = false BulletSpawnPart.CastShadow = false BulletSpawnPart.Color = Color3.fromRGB(255, 255, 0) BulletSpawnPart.Size = Vector3.new(0.6, 0.6, 0.6) BulletSpawnPart.Shape = Enum.PartType.Ball BulletSpawnPart.CFrame = weapon.CFrame + Vector3.new(0, 0, -1) local weldBulletSpawn = Instance.new("WeldConstraint", BulletSpawnPart) weldBulletSpawn.Part0 = BulletSpawnPart weldBulletSpawn.Part1 = weapon -- Create our entity local bulletSpawnEntity = ECSUtil.NewBasePartEntity(world, BulletSpawnPart, true, false) -- Mark as weapon world.Set(bulletSpawnEntity, WeaponComponent) ================================================ FILE: roblox/tutorial/src/server/tutorial/init.server.lua ================================================ ================================================ FILE: roblox/tutorial/src/shared/teste.lua ================================================ local ECS = {} local Query, System, Component, = ECS.Query, ECS.System, ECS.Component, local Transform = Component({ Position = Vector3.new(), Rotation = Vector3.new() }) local IsActive = Component(nil, true) local MovementSystem = System('proccess', 100, Query.All(Transform, IsActive)) MovementSystem.After = {OtherSystem} MovementSystem.Before = {TransformSystem} function MovementSystem:Initialize() print("Initialized") end function MovementSystem:ShouldUpdate(Time) return true end function MovementSystem:PreUpdate(Time) return true end function MovementSystem:Update(Time, dirty) self:ForEach(function(entity) end) end function MovementSystem:PostUpdate(Time, interpolation) end function MovementSystem:OnEnter(time) local world = self.world local query = Query.All(IsActive) world:Exec(query):ForEach(function(entity) local transform = Transform() transform.Position = Vector3.new(50, 50, 50) entity:Set(transform) entity:Set(Transform, transform) entity[Transform] = transform local isActive = entity:Get(IsActive) local isActive = entity[IsActive] end) end local world = ECS.World({MovementSystem}, 60, false) world:AddSystem(MovementSystem, 10, { types = 'xto' }) world:SetFrequency(30) world:Destroy() -- ShouldUpdate(time: number, interpolation:number): void -- It allows informing if the update methods of this system should be invoked -- BeforeUpdate(time: number, interpolation:number): void -- Invoked before updating entities available for this system. -- It is only invoked when there are entities with the characteristics -- expected by this system -- Update: function(time, dirty, entity, index, [component_N_items...]) -> boolean -- Invoked in updates, limited to the value set in the "frequency" attribute -- AfterUpdate(time: number, interpolation:number): void -- OnEnter(time, entity, index, [component_N_items...]) -> boolean -- OnExit(time, entity, index, [component_N_items...]) -> boolean -- OnRemove(time, enity, index, [component_N_items...]) local Archetype1 = {} local ComponentName = {} local ComponentTransform = {} local entity1 = {} local entity2 = {} local Entity = {} function Entity.Create(world, ...) local components = {...} end function Entity:Get(cType) return self._data[cType] end function Entity:Set(cType, value) return self._data[cType] end entity1[ComponentName] ================================================ FILE: roblox/tutorial/src/shared/tutorial/component/FiringComponent.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) return ECS.Component('Firing', function(firedAt) if firedAt == nil then error("firedAt is required") end return firedAt end) ================================================ FILE: roblox/tutorial/src/shared/tutorial/component/WeaponComponent.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) return ECS.Component('Weapon') ================================================ FILE: roblox/tutorial/src/shared/tutorial/system/CleanupFiringSystem.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) -- Components local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) return ECS.RegisterSystem({ Name = 'CleanupFiring', Step = 'transform', RequireAll = { FiringComponent }, Update = function (time, world, dirty, entity, index, firings) local firedAt = firings[index] if firedAt ~= nil then if time.frame - firedAt < 0.5 then return false end world.Remove(entity, FiringComponent) return true end return false end }) ================================================ FILE: roblox/tutorial/src/shared/tutorial/system/FiringSystem.lua ================================================ local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) local ECSUtil = require(game.ReplicatedStorage:WaitForChild("ECSUtil")) -- Components local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) return ECS.RegisterSystem({ Name = 'Firing', Step = 'process0', RequireAll = { ECSUtil.PositionComponent, ECSUtil.RotationComponent, FiringComponent }, OnEnter = function(time, world, entity, index, positions, rotations, firings) local position = positions[index] local rotation = rotations[index] if position ~= nil and rotation ~= nil then -- can be made in a utility script, or clone a preexistece model local bulletPart = Instance.new("Part") bulletPart.Anchored = true bulletPart.CanCollide = false bulletPart.Position = position bulletPart.CastShadow = false bulletPart.Shape = Enum.PartType.Ball bulletPart.Size = Vector3.new(0.6, 0.6, 0.6) bulletPart.CFrame = CFrame.fromMatrix(position, rotation[1], rotation[2], rotation[3] * -1) bulletPart.Parent = game.Workspace local bulletEntity = ECSUtil.NewBasePartEntity(world, bulletPart, false, true, true) world.Set(bulletEntity, ECSUtil.MoveForwardComponent) world.Set(bulletEntity, ECSUtil.MoveSpeedComponent, 1.0) end return false end }) ================================================ FILE: roblox/tutorial/src/shared/tutorial/system/PlayerShootingSystem.lua ================================================ local UserInputService = game:GetService("UserInputService") local ECS = require(game.ReplicatedStorage:WaitForChild("ECS")) -- Components local Components = game.ReplicatedStorage:WaitForChild("tutorial"):WaitForChild("component") local FiringComponent = require(Components:WaitForChild("FiringComponent")) local WeaponComponent = require(Components:WaitForChild("WeaponComponent")) return ECS.RegisterSystem({ Name = 'PlayerShooting', Step = 'process', Order = 1, RequireAll = { WeaponComponent }, RejectAny = { FiringComponent }, Update = function (time, world, dirty, entity, index, weapons) local isFiring = UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) if isFiring then world.Set(entity, FiringComponent, time.frame) return true end return false end }) ================================================ FILE: src/Archetype.lua ================================================ local archetypes = {} local CACHE_WITH = {} local CACHE_WITHOUT = {} -- Version of the last registered archetype. Used to cache the systems execution plan local Version = 0 --[[ An Archetype is a unique combination of component types. The EntityRepository uses the archetype to group all entities that have the same sets of components. An entity can change archetype fluidly over its lifespan. For example, when you add or remove components, the archetype of the affected entity changes. An archetype object is not a container; rather it is an identifier to each unique combination of component types that an application has created at run time, either directly or implicitly. You can create archetypes directly using ECS.Archetype.Of(Components[]). You also implicitly create archetypes whenever you add or remove a component from an entity. An Archetype object is an immutable singleton; creating an archetype with the same set of components, either directly or implicitly, results in the same archetype. The ECS framework uses archetypes to group entities that have the same structure together. The ECS framework stores component data in blocks of memory called chunks. A given chunk stores only entities having the same archetype. You can get the Archetype object for a chunk from its Archetype property. Use ECS.Archetype.Of(Components[]) to get a Archetype reference. ]] local Archetype = {} Archetype.__index = Archetype --[[ Gets the reference to an archetype from the informed components @param componentClasses {ComponentClass[]} Component that define this archetype @return Archetype ]] function Archetype.Of(componentClasses) local ids = {} local cTypes = {} for _, cType in ipairs(componentClasses) do if (cType.IsCType and not cType.isComponent) then if cType.IsQualifier then if cTypes[cType] == nil then cTypes[cType] = true table.insert(ids, cType.Id) end cType = cType.SuperClass end if cTypes[cType] == nil then cTypes[cType] = true table.insert(ids, cType.Id) end end end table.sort(ids) local id = "_" .. table.concat(ids, "_") if archetypes[id] == nil then archetypes[id] = setmetatable({ id = id, _components = cTypes }, Archetype) Version = Version + 1 end return archetypes[id] end --[[ Get the version of archetype definitions @return number ]] function Archetype.Version() return Version end --[[ Checks whether this archetype has the informed component @param componentClass {ComponentClass} @return bool ]] function Archetype:Has(componentClass) return (self._components[componentClass] == true) end --[[ Gets the reference to an archetype that has the current components + the informed component @param componentClass {ComponentClass} @return Archetype ]] function Archetype:With(componentClass) if self._components[componentClass] == true then -- component exists in that list, returns the archetype itself return self end local cache = CACHE_WITH[self] if not cache then cache = {} CACHE_WITH[self] = cache end local other = cache[componentClass] if other == nil then local componentTs = {componentClass} for component,_ in pairs(self._components) do table.insert(componentTs, component) end other = Archetype.Of(componentTs) cache[componentClass] = other end return other end --[[ Gets the reference to an archetype that has the current components + the informed components @param componentClasses {ComponentClass[]} @return Archetype ]] function Archetype:WithAll(componentClasses) local cTypes = {} for component,_ in pairs(self._components) do table.insert(cTypes, component) end for _,component in ipairs(componentClasses) do if self._components[component] == nil then table.insert(cTypes, component) end end return Archetype.Of(cTypes) end --[[ Gets the reference to an archetype that has the current components - the informed component @param componentClass {ComponentClass} @return Archetype ]] function Archetype:Without(componentClass) if self._components[componentClass] == nil then -- component does not exist in this list, returns the archetype itself return self end local cache = CACHE_WITHOUT[self] if not cache then cache = {} CACHE_WITHOUT[self] = cache end local other = cache[componentClass] if other == nil then local componentTs = {} for component,_ in pairs(self._components) do if component ~= componentClass then table.insert(componentTs, component) end end other = Archetype.Of(componentTs) cache[componentClass] = other end return other end --[[ Gets the reference to an archetype that has the current components - the informed components @param componentClasses {ComponentClass[]} @return Archetype ]] function Archetype:WithoutAll(componentClasses) local toIgnoreIdx = {} for _,component in ipairs(componentClasses) do toIgnoreIdx[component] = true end local cTypes = {} for component,_ in pairs(self._components) do if toIgnoreIdx[component] == nil then table.insert(cTypes, component) end end return Archetype.Of(cTypes) end -- Generic archetype, for entities that do not have components Archetype.EMPTY = Archetype.Of({}) return Archetype ================================================ FILE: src/Component.lua ================================================ local Utility = require("Utility") local ComponentFSM = require("ComponentFSM") local copyDeep = Utility.copyDeep local mergeDeep = Utility.mergeDeep local CLASS_SEQ = 0 --[[ @param initializer {function(table) => table} @param superClass {ComponentClass} @return ComponentClass ]] local function createComponentClass(initializer, superClass) CLASS_SEQ = CLASS_SEQ + 1 local ComponentClass = { Id = CLASS_SEQ, IsCType = true, -- Primary component SuperClass = superClass } ComponentClass.__index = ComponentClass if superClass == nil then superClass = ComponentClass superClass._Qualifiers = { ["Primary"] = ComponentClass } superClass._QualifiersArr = { ComponentClass } superClass._Initializers = {} else superClass.HasQualifier = true ComponentClass.IsQualifier = true ComponentClass.HasQualifier = true end local Qualifiers = superClass._Qualifiers local QualifiersArr = superClass._QualifiersArr setmetatable(ComponentClass, { __call = function(t, value) return ComponentClass.New(value) end, __index = function(t, key) if (key == "States") then return superClass.__States end if (key == "Case" or key == "StateInitial") then return rawget(superClass, key) end end, __newindex = function(t, key, value) if (key == "Case" or key == "States" or key == "StateInitial") then -- (FMS) Finite State Machine if ComponentClass == superClass then if (key == "States") then if not superClass.IsFSM then ComponentFSM.AddCapability(superClass, value) for _, qualifiedClass in pairs(Qualifiers) do if qualifiedClass ~= superClass then ComponentFSM.AddMethods(superClass, qualifiedClass) end end end else rawset(t, key, value) end end else rawset(t, key, value) end end }) if superClass.IsFSM then ComponentFSM.AddMethods(superClass, ComponentClass) end --[[ Gets a qualifier for this type of component. If the qualifier does not exist, a new class will be created, otherwise it brings the already registered class qualifier reference with the same name. @param qualifier {string|ComponentClass} @return ComponentClass ]] function ComponentClass.Qualifier(qualifier) if type(qualifier) ~= "string" then for _, qualifiedClass in ipairs(QualifiersArr) do if qualifiedClass == qualifier then return qualifier end end return nil end local qualifiedClass = Qualifiers[qualifier] if qualifiedClass == nil then qualifiedClass = createComponentClass(initializer, superClass) Qualifiers[qualifier] = qualifiedClass table.insert(QualifiersArr, qualifiedClass) end return qualifiedClass end --[[ Get all qualified class @param ... {string|ComponentClass} (Optional) Allows to filter the specific qualifiers @return ComponentClass[] ]] function ComponentClass.Qualifiers(...) local filter = {...} if #filter == 0 then return QualifiersArr else local qualifiers = {} local cTypes = {} for _,qualifier in ipairs({...}) do local qualifiedClass = ComponentClass.Qualifier(qualifier) if qualifiedClass and cTypes[qualifiedClass] == nil then cTypes[qualifiedClass] = true table.insert(qualifiers, qualifiedClass) end end return qualifiers end end --[[ Constructor @param value {any} If the value is not a table, it will be converted to the format "{ value = value}" @return Component ]] function ComponentClass.New(value) if (value ~= nil and type(value) ~= "table") then -- local MyComponent = Component({ value = Vector3.new(0, 0, 0) }) -- local component = MyComponent(Vector3.new(10, 10, 10)) value = { value = value } end local component = setmetatable(initializer(value) or {}, ComponentClass) for _, fn in ipairs(superClass._Initializers) do fn(component) end component.isComponent = true component._qualifiers = { [ComponentClass] = component } return component end --[[ Get this component's class @return ComponentClass ]] function ComponentClass:GetType() return ComponentClass end --[[ Check if this component is of the type informed @param componentClass {ComponentClass} @return bool ]] function ComponentClass:Is(componentClass) return componentClass == ComponentClass or componentClass == superClass end --[[ Get the instance for the primary qualifier of this class @return Component|nil ]] function ComponentClass:Primary() return self._qualifiers[superClass] end --[[ Get the instance for the given qualifier of this class @param name {string|ComponentClass} @return Component|nil ]] function ComponentClass:Qualified(qualifier) return self._qualifiers[ComponentClass.Qualifier(qualifier)] end --[[ Get all instances for all qualifiers of that class @return Component[] ]] function ComponentClass:QualifiedAll() local qualifiedAll = {} for name, qualifiedClass in pairs(Qualifiers) do qualifiedAll[name] = self._qualifiers[qualifiedClass] end return qualifiedAll end --[[ Merges data from the other component into the current component. This method should not be invoked, it is used by the entity to ensure correct retrieval of a component's qualifiers. @param other {Component} ]] function ComponentClass:Merge(other) if superClass.HasQualifier then if self == other then return end if self._qualifiers == other._qualifiers then return end if not other:Is(superClass) then return end local selfClass = ComponentClass local otherClass = other:GetType() -- does anyone know the reference to the primary entity? local primaryQualifiers if selfClass == superClass then primaryQualifiers = self._qualifiers elseif otherClass == superClass then primaryQualifiers = other._qualifiers elseif self._qualifiers[superClass] ~= nil then primaryQualifiers = self._qualifiers[superClass]._qualifiers elseif other._qualifiers[superClass] ~= nil then primaryQualifiers = other._qualifiers[superClass]._qualifiers end if primaryQualifiers ~= nil then if self._qualifiers ~= primaryQualifiers then for qualifiedClass, component in pairs(self._qualifiers) do if superClass ~= qualifiedClass then primaryQualifiers[qualifiedClass] = component component._qualifiers = primaryQualifiers end end end if other._qualifiers ~= primaryQualifiers then for qualifiedClass, component in pairs(other._qualifiers) do if superClass ~= qualifiedClass then primaryQualifiers[qualifiedClass] = component component._qualifiers = primaryQualifiers end end end else -- none of the instances know the Primary, use the current object reference for qualifiedClass, component in pairs(other._qualifiers) do if selfClass ~= qualifiedClass then self._qualifiers[qualifiedClass] = component component._qualifiers = self._qualifiers end end end end end --[[ Unlink this component with the other qualifiers ]] function ComponentClass:Detach() if not superClass.HasQualifier then return end -- remove old unlink self._qualifiers[ComponentClass] = nil -- new link self._qualifiers = { [ComponentClass] = self } end return ComponentClass end local function defaultInitializer(value) return value or {} end --[[ A Component is an object that can store data but should have not behaviour (As that should be handled by systems). ]] local Component = {} --[[ Register a new ComponentClass @param template {table|function(table?) -> table} When `table`, this template will be used for creating component instances When it's a `function`, it will be invoked when a new component is instantiated. The creation parameter of the component is passed to template function If the template type is different from `table` and `function`, **ECS Lua** will generate a template in the format `{ value = template }`. @return ComponentClass ]] function Component.Create(template) local initializer = defaultInitializer if template ~= nil then local ttype = type(template) if (ttype == "function") then initializer = template else if (ttype ~= "table") then template = { value = template } end initializer = function(value) local data = copyDeep(template) if (value ~= nil) then mergeDeep(data, value) end return data end end end return createComponentClass(initializer, nil) end return Component ================================================ FILE: src/ComponentFSM.lua ================================================ --[[ Facilitate the construction and use of a Finite State Machine (FSM) using ECS Example: local Movement = ECS.Component({ Speed = 0 }) Movement.States = { Standing = "*", Walking = {"Standing", "Running"}, Running = {"Walking"} } Movement.StateInitial = "Standing" Movement.Case = { Standing = function(self, previous) self.Speed = 0 end, Walking = function(self, previous) self.Speed = 5 end, Running = function(self, previous) self.Speed = 10 end } local movement = entity[Movement] print(movement:GetState()) -- "Standing" movement:SetState("Walking") print(movement:GetPrevState()) -- "Standing" movement:GetStateTime() if (movement:GetState() == "Standing") then movement.Speed = 0 end ]] local Query = require("Query") --[[ Filter used in Query and QueryResult @see QueryResult.lua Ex. ECS.Query.All(Movement.In("Standing", "Walking")) ]] local queryFilterCTypeStateIn = Query.Filter(function(entity, config) local states = config.States local isSuperClass = config.IsSuperClass local componentClass = config.ComponentClass if isSuperClass then local qualifiers = componentClass.Qualifiers() for _, qualifier in ipairs(qualifiers) do local component = entity[qualifier] if (component ~= nil and states[component:GetState()] == true) then return true end end return false else local component = entity[componentClass] if component == nil then return false end return states[component:GetState()] == true end end) local ComponentFSM = {} --[[ Adds FSM capability to a ComponentClass @param superClass {ComonentClass} @param states { {[key=string] => string|string[]}} @see Component.lua - createComponentClass() - ComponentClass__newindex ]] function ComponentFSM.AddCapability(superClass, states) superClass.IsFSM = true local cTypeStates = setmetatable({}, { __newindex = function(states, newState, value) if (type(value) ~= "table") then value = {value} end if table.find(value, "*") then rawset(states, newState, "*") else local idxSelf = table.find(value, newState) if idxSelf ~= nil then table.remove(value, idxSelf) if #value == 0 then value = "*" end end rawset(states, newState, value) end end }) rawset(superClass, "__States", cTypeStates) for state,value in pairs(states) do if superClass.StateInitial == nil then superClass.StateInitial = state end cTypeStates[state] = value end ComponentFSM.AddMethods(superClass, superClass) table.insert(superClass._Initializers, function(component) component:SetState(superClass.StateInitial) end) end --[[ Adds FSM state change methods to a ComponentClass @param superClass {ComponentClass} @param componentClass {ComponentClass} ]] function ComponentFSM.AddMethods(superClass, componentClass) componentClass.IsFSM = true local cTypeStates = superClass.States --[[ Creates a clause used to filter repository entities in a Query or QueryResult @param ... {string[]} @return Clause Ex. ECS.Query.All(Movement.In("Walking", "Running")) ]] function componentClass.In(...) local states = {} local count = 0 for _,state in ipairs({...}) do if (cTypeStates[state] ~= nil and states[state] == nil) then count = count + 1 states[state] = true end end if count == 0 then -- In any state return {} end return queryFilterCTypeStateIn({ States = states, IsSuperClass = (componentClass == superClass), ComponentClass = componentClass, }) end --[[ Defines the current state of the FSM @param newState {string} ]] function componentClass:SetState(newState) if (newState == nil or cTypeStates[newState] == nil) then return end local actual = self:GetState() if (actual == newState) then return end if (actual ~= nil ) then local transtions = cTypeStates[actual] if (transtions ~= "*" and table.find(transtions, newState) == nil) then -- not allowed return end end self._state = newState self._statePrev = actual self._stateTime = os.clock() local action = superClass.Case and superClass.Case[newState] if action then action(self, actual) end end --[[ Get the current state of the FSM @return string ]] function componentClass:GetState() return self._state or superClass.StateInitial end --[[ Get the previous state of the FSM @return string|nil ]] function componentClass:GetPrevState() return self._statePrev or nil end --[[ Gets the time it changed to the current state ]] function componentClass:GetStateTime() return self._stateTime or 0 end end return ComponentFSM ================================================ FILE: src/ECS.lua ================================================ --[[ ECS Lua v2.2.0 ECS Lua is a fast and easy to use ECS (Entity Component System) engine for game development. https://github.com/nidorx/ecs-lua Discussions about this script are at https://devforum.roblox.com/t/841175 ------------------------------------------------------------------------------ MIT License Copyright (c) 2020 Alex Rodin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Query = require("Query") local World = require("World") local System = require("System") local Archetype = require("Archetype") local Component = require("Component") local function setLoopManager(manager) World.LoopManager = manager end pcall(function() if (game and game.ClassName == "DataModel") then -- is roblox setLoopManager(require("RobloxLoopManager")()) end end) --[[ @TODO - Server entities - Client - Server sincronization (snapshot, delta, spatial index, grid manhatham distance) - Table pool (avoid GC) - System readonly? Paralel execution - Debugging? - Benchmark (Local Script vs ECS implementation) - Basic physics (managed) - SharedComponent? - Serializaton - world:Serialize() - world:Serialize(entity) - entity:Serialize() - component:Serialize() ]] local ECS = { Query = Query, World = World.New, System = System.Create, Archetype = Archetype, Component = Component.Create, SetLoopManager = setLoopManager } if _G.ECS == nil then _G.ECS = ECS else local warn = _G.warn or print warn("ECS Lua was not registered in the global variables, there is already another object registered.") end return ECS ================================================ FILE: src/Entity.lua ================================================ --[[ The entity is a fundamental part of the Entity Component System. Everything in your game that has data or an identity of its own is an entity. However, an entity does not contain either data or behavior itself. Instead, the data is stored in the components and the behavior is provided by the systems that process those components. ]] local Archetype = require("Archetype") local SEQ = 0 --[[ [GET] 01) comp1 = entity[CompType1] 02) comp1 = entity:Get(CompType1) 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) ]] local function getComponent(entity, ...) local values = {...} local data = entity._data if (#values == 1) then local cType = values[1] if (cType.IsCType and not cType.isComponent) then -- 01) comp1 = entity[CompType1] -- 02) comp1 = entity:Get(CompType1) return data[cType] else return nil end end -- 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) local components = {} for i,cType in ipairs(values) do if (cType.IsCType and not cType.isComponent) then table.insert(components, data[cType]) end end return table.unpack(components) end --[[ Merges the qualifiers of a new added component. @param entity {Entity} @param newComponent {Component} @param newComponentClass {ComponentClass} ]] local function mergeComponents(entity, newComponent, newComponentClass) local data = entity._data local otherComponent -- get first instance for _,oCType in ipairs(newComponentClass.Qualifiers()) do if oCType ~= newComponentClass then otherComponent = data[oCType] if otherComponent then break end end end if otherComponent then otherComponent:Merge(newComponent) end end --[[ [SET] 01) entity[CompType1] = nil 02) entity[CompType1] = value 03) entity:Set(CompType1, nil) 04) entity:Set(CompType1, value) 05) entity:Set(comp1) 06) entity:Set(comp1, comp2, ...) ]] local function setComponent(entity, ...) local values = {...} local data = entity._data local archetypeOld = entity.archetype local archetypeNew = archetypeOld local toMerge = {} local cType = values[1] if (cType and cType.IsCType and not cType.isComponent) then local value = values[2] local component -- 01) entity[CompType1] = nil -- 02) entity[CompType1] = value -- 03) entity:Set(CompType1, nil) -- 04) entity:Set(CompType1, value) if value == nil then local old = data[cType] if old then old:Detach() end data[cType] = nil archetypeNew = archetypeNew:Without(cType) elseif (type(value) == "table" and value.isComponent) then local old = data[cType] if (old ~= value) then if old then old:Detach() end cType = value:GetType() data[cType] = value archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, value, cType) end end else local old = data[cType] if old then old:Detach() end local component = cType(value) data[cType] = component archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, component, cType) end end else -- 05) entity:Set(comp1) -- 06) entity:Set(comp1, comp2, ...) for i,component in ipairs(values) do if (component.isComponent) then local cType = component:GetType() local old = data[cType] if (old ~= component) then if old then old:Detach() end data[cType] = component archetypeNew = archetypeNew:With(cType) -- merge components if (cType.HasQualifier or cType.IsQualifier) then mergeComponents(entity, component, cType) end end end end end if (archetypeOld ~= archetypeNew) then entity.archetype = archetypeNew entity._onChange:Fire(entity, archetypeOld) end end --[[ [UNSET] 01) enity:Unset(comp1) 02) entity[CompType1] = nil 03) enity:Unset(CompType1) 04) enity:Unset(comp1, comp1, ...) 05) enity:Unset(CompType1, CompType2, ...) ]] local function unsetComponent(entity, ...) local data = entity._data local archetypeOld = entity.archetype local archetypeNew = archetypeOld for _,value in ipairs({...}) do if value.isComponent then -- 01) enity:Unset(comp1) -- 04) enity:Unset(comp1, comp1, ...) local cType = value:GetType() local old = data[cType] if old then old:Detach() end data[cType] = nil archetypeNew = archetypeNew:Without(cType) elseif value.IsCType then -- 02) entity[CompType1] = nil -- 03) enity:Unset(CompType1) -- 05) enity:Unset(CompType1, CompType2, ...) local old = data[value] if old then old:Detach() end data[value] = nil archetypeNew = archetypeNew:Without(value) end end if entity.archetype ~= archetypeNew then entity.archetype = archetypeNew entity._onChange:Fire(entity, archetypeOld) end end --[[ 01) comps = entity:GetAll() 01) qualifiers = entity:GetAll(PrimaryClass) ]] local function getAll(entity, qualifier) local data = entity._data local components = {} if (qualifier ~= nil and qualifier.IsCType and not qualifier.isComponent) then local ctypes = qualifier.Qualifiers() for _,cType in ipairs(ctypes) do local component = data[cType] if component then table.insert(components, component) end end else for _, component in pairs(data) do table.insert(components, component) end end return components end --[[ 01) comp = entity:GetAny(PrimaryClass) ]] local function getAny(entity, qualifier) if (qualifier ~= nil and qualifier.IsCType and not qualifier.isComponent) then local data = entity._data local ctypes = qualifier.Qualifiers() for _,cType in ipairs(ctypes) do local component = data[cType] if component then return component end end end end local Entity = { __index = function(e, key) if (type(key) == "table") then -- 01) local comp1 = entity[CompType1] -- 01) local comps = entity[{CompType1, CompType2, ...}] return getComponent(e, key) end end, __newindex = function(e, key, value) local isComponentSet = true if (type(key) == "table" and (key.IsCType and not key.isComponent)) then -- 01) entity[CompType1] = nil -- 02) entity[CompType1] = value setComponent(e, key, value) else rawset(e, key, value) end end } --[[ Creates an entity having components of the specified types. @param onChange {Event} @param components {Component[]} (Optional) ]] function Entity.New(onChange, components) local archetype = Archetype.EMPTY local data = {} if (components ~= nil and #components > 0) then local cTypes = {} for _, component in ipairs(components) do local cType = component:GetType() table.insert(cTypes, cType) data[cType] = component end archetype = Archetype.Of(cTypes) end SEQ = SEQ + 1 return setmetatable({ _data = data, _onChange = onChange, id = SEQ, isAlive = false, archetype = archetype, Get = getComponent, Set = setComponent, Unset = unsetComponent, GetAll = getAll, GetAny = getAny, }, Entity) end return Entity ================================================ FILE: src/EntityRepository.lua ================================================ local Event = require("Event") --[[ The repository (database) of entities in a world. The repository indexes entities by archetype. Whenever the entity's archetype is changed, the entity is transported to the correct storage. ]] local EntityRepository = {} EntityRepository.__index = EntityRepository --[[ Create a new repository @return EntityRepository ]] function EntityRepository.New() return setmetatable({ _archetypes = {}, _entitiesArchetype = {}, }, EntityRepository) end --[[ Insert an entity into this repository @param entity {Entity} ]] function EntityRepository:Insert(entity) if (self._entitiesArchetype[entity] == nil) then local archetype = entity.archetype local storage = self._archetypes[archetype] if (storage == nil) then storage = { count = 0, entities = {} } self._archetypes[archetype] = storage end storage.entities[entity] = true storage.count = storage.count + 1 self._entitiesArchetype[entity] = archetype else self:Update(entity) end end --[[ Remove an entity from this repository @param entity {Entity} ]] function EntityRepository:Remove(entity) local archetypeOld = self._entitiesArchetype[entity] if archetypeOld == nil then return end self._entitiesArchetype[entity] = nil local storage = self._archetypes[archetypeOld] if (storage ~= nil and storage.entities[entity] == true) then storage.entities[entity] = nil storage.count = storage.count - 1 if (storage.count == 0) then self._archetypes[archetypeOld] = nil end end end --[[ Updates the entity in the repository, if necessary, moves the entity from one storage to another @param entity {Entity} ]] function EntityRepository:Update(entity) local archetypeOld = self._entitiesArchetype[entity] if (archetypeOld == nil or archetypeOld == entity.archetype) then return end self:Remove(entity) self:Insert(entity) end --[[ Execute the query entered in this repository @param query {Query} @return QueryResult ]] function EntityRepository:Query(query) local chunks = {} for archetype, storage in pairs(self._archetypes) do if query:Match(archetype) then table.insert(chunks, storage.entities) end end return query:Result(chunks), #chunks > 0 end --[[ Quick check to find out if a query is applicable. @param query {Query} @return bool ]] function EntityRepository:FastCheck(query) for archetype, storage in pairs(self._archetypes) do if query:Match(archetype) then return true end end return false end return EntityRepository ================================================ FILE: src/Event.lua ================================================ --[[ Subscription ]] local Connection = {} Connection.__index = Connection function Connection.New(event, handler) return setmetatable({ _event = event, _handler = handler }, Connection) end -- Unsubscribe function Connection:Disconnect() local event = self._event if (event and not event.destroyed) then local idx = table.find(event._handlers, self._handler) if idx ~= nil then table.remove(event._handlers, idx) end end setmetatable(self, nil) end --[[ Observer Pattern Allows the application to fire events of a particular type. ]] local Event = {} Event.__index = Event function Event.New() return setmetatable({ _handlers = {} }, Event) end function Event:Connect(handler) if (type(handler) == "function") then table.insert(self._handlers, handler) return Connection.New(self, handler) end error(("Event:Connect(%s)"):format(typeof(handler)), 2) end function Event:Fire(...) if not self.destroyed then for i,handler in ipairs(self._handlers) do handler(table.unpack({...})) end end end function Event:Destroy() setmetatable(self, nil) self._handlers = nil self.destroyed = true end return Event ================================================ FILE: src/Query.lua ================================================ local QueryResult = require("QueryResult") --[[ Global cache result. The validated components are always the same (reference in memory, except within the archetypes), in this way, you can save the result of a query in an archetype, reducing the overall execution time (since we don't need to iterate all the time) @type KEY string = concat(Array, "_") @Type { [Archetype] = { Any = { [KEY] = bool }, All = { [KEY] = bool }, None = { [KEY] = bool }, } } ]] local CACHE = {} --[[ Interface for creating filters for existing entities in the ECS world ]] local Query = {} Query.__index = Query setmetatable(Query, { __call = function(t, all, any, none) return Query.New(all, any, none) end, }) local function parseFilters(list, clauseGroup, clauses) local indexed = {} local cTypes = {} local cTypeIds = {} for i,item in ipairs(list) do if (indexed[item] == nil) then if (item.IsCType and not item.isComponent) then indexed[item] = true table.insert(cTypes, item) table.insert(cTypeIds, item.Id) else -- clauses if item.Filter then indexed[item] = true item[clauseGroup] = true table.insert(clauses, item) end end end end if #cTypes > 0 then table.sort(cTypeIds) local cTypesKey = "_" .. table.concat(cTypeIds, "_") return cTypes, cTypesKey end end --[[ Create a new Query used to filter entities in the world. It makes use of local and global cache in order to decrease the validation time (avoids looping in runtime of systems) @param all {Array} Optional All component types in this array must exist in the archetype @param any {Array} Optional At least one of the component types in this array must exist in the archetype @param none {Array} Optional None of the component types in this array can exist in the archetype ]] function Query.New(all, any, none) -- used by QueryResult local clauses = {} local anyKey, allKey, noneKey if (any ~= nil) then any, anyKey = parseFilters(any, "IsAnyFilter", clauses) end if (all ~= nil) then all, allKey = parseFilters(all, "IsAllFilter", clauses) end if (none ~= nil) then none, noneKey = parseFilters(none, "IsNoneFilter", clauses) end return setmetatable({ isQuery = true, _any = any, _all = all, _none = none, _anyKey = anyKey, _allKey = allKey, _noneKey = noneKey, _cache = {}, -- local cache (L1) _clauses = #clauses > 0 and clauses or nil }, Query) end --[[ Generate a QueryResult with the chunks entered and the clauses of the current query @param chunks {Chunk} @return QueryResult ]] function Query:Result(chunks) return QueryResult.New(chunks, self._clauses) end --[[ Checks if the entered archetype is valid by the query definition @param archetype {Archetype} @return bool ]] function Query:Match(archetype) -- cache L1 local localCache = self._cache -- check local cache (L1) local cacheResult = localCache[archetype] if cacheResult ~= nil then return cacheResult else -- check global cache (executed by other filter instance) local globalCache = CACHE[archetype] if (globalCache == nil) then globalCache = { Any = {}, All = {}, None = {} } CACHE[archetype] = globalCache end -- check if these combinations exist in this component array local noneKey = self._noneKey if noneKey then local isNoneValid = globalCache.None[noneKey] if (isNoneValid == nil) then isNoneValid = true for _, cType in ipairs(self._none) do if archetype:Has(cType) then isNoneValid = false break end end globalCache.None[noneKey] = isNoneValid end if (isNoneValid == false) then localCache[archetype] = false return false end end local anyKey = self._anyKey if anyKey then local isAnyValid = globalCache.Any[anyKey] if (isAnyValid == nil) then isAnyValid = false if (globalCache.All[anyKey] == true) then isAnyValid = true else for _, cType in ipairs(self._any) do if archetype:Has(cType) then isAnyValid = true break end end end globalCache.Any[anyKey] = isAnyValid end if (isAnyValid == false) then localCache[archetype] = false return false end end local allKey = self._allKey if allKey then local isAllValid = globalCache.All[allKey] if (isAllValid == nil) then local haveAll = true for _, cType in ipairs(self._all) do if (not archetype:Has(cType)) then haveAll = false break end end if haveAll then isAllValid = true else isAllValid = false end globalCache.All[allKey] = isAllValid end localCache[archetype] = isAllValid return isAllValid end -- empty query = SELECT * FROM localCache[archetype] = true return true end end local function builder() local builder = { isQueryBuilder = true } local query function builder.All(...) query = nil builder._all = {...} return builder end function builder.Any(...) query = nil builder._any = {...} return builder end function builder.None(...) query = nil builder._none = {...} return builder end function builder.Build() if query == nil then query = Query.New(builder._all, builder._any, builder._none) end return query end return builder end function Query.All(...) return builder().All(...) end function Query.Any(...) return builder().Any(...) end function Query.None(...) return builder().None(...) end --[[ Create custom filters that can be used in Queries. Its execution is delayed, invoked only in QueryResult methods The result of executing the clause depends on how it was used in the query. Ex. If used in Query.All() the result is the inverse of using the same clause in Query.None() local Player = ECS.Component({ health = 100 }) local HealthPlayerFilter = ECS.Query.Filter(function(entity, config) local player = entity[Player] return player.health >= config.minHealth and player.health <= config.maxHealth end) local healthyClause = HealthPlayerFilter({ minHealth = 80, maxHealth = 100, }) local healthyQuery = ECS.Query.All(Player, healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is very healthy end) local notHealthyQuery = ECS.Query.All(Player).None(healthyClause) world:Exec(healthyQuery):ForEach(function(entity) -- this player is NOT very healthy end) local dyingClause = HealthPlayerClause({ minHealth = 1, maxHealth = 20, }) local dyingQuery = ECS.Query.All(Player, dyingClause) world:Exec(dyingQuery):ForEach(function(entity) -- this player is about to die end) local notDyingQuery = ECS.Query.All(Player).None(dyingClause) world:Exec(notDyingQuery):ForEach(function(entity) -- this player is NOT about to die end) @param filter {function(entity, config):bool} @return function(config):Clause ]] function Query.Filter(filter) return function (config) return { Filter = filter, Config = config } end end return Query ================================================ FILE: src/QueryResult.lua ================================================ --[[ OperatorFunction = function(param, value, count) => newValue, acceptItem, mustContinue ]] local function operatorFilter(predicate, value, count) return value, (predicate(value) == true), true end local function operatorMap(mapper, value, count) return mapper(value), true, true end local function operatorLimit(limit, value, count) local accept = (count <= limit) return value, accept, accept end local function operatorClauseNone(clauses, value, count) local acceptItem = true for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == true) then acceptItem = false break end end return value, acceptItem, true end local function operatorClauseAll(clauses, value, count) local acceptItem = true for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == false) then acceptItem = false break end end return value, acceptItem, true end local function operatorClauseAny(clauses, value, count) local acceptItem = false for _,clause in ipairs(clauses) do if (clause.Filter(value, clause.Config) == true) then acceptItem = true break end end return value, acceptItem, true end local EMPTY_OBJECT = {} --[[ The result of a Query that was executed on an EntityStorage. QueryResult provides several methods to facilitate the filtering of entities resulting from the execution of the query. ]] local QueryResult = {} QueryResult.__index = QueryResult --[[ Build a new QueryResult @param chunks { Array<{ [Entity] = true }> } @clauses {Clause[]} @see Query.lua @see EntityRepository:Query(query) @return QueryResult ]] function QueryResult.New(chunks, clauses) local pipeline = EMPTY_OBJECT if (clauses and #clauses > 0) then local all = {} local any = {} local none = {} pipeline = {} for i,clause in ipairs(clauses) do if clause.IsNoneFilter then table.insert(none, clause) elseif clause.IsAnyFilter then table.insert(any, clause) else table.insert(all, clause) end end if (#none > 0) then table.insert(pipeline, {operatorClauseNone, none}) end if (#all > 0) then table.insert(pipeline, {operatorClauseAll, all}) end if (#any > 0) then table.insert(pipeline, {operatorClauseAny, any}) end end return setmetatable({ chunks = chunks, _pipeline = pipeline, }, QueryResult) end --[[ ------------------------------------------------------------------------------------------------------------------- Intermediate Operations Intermediate operations return a new QueryResult. They are always lazy; executing an intermediate operation such as QueryResult:Filter() does not actually perform any filtering, but instead creates a new QueryResult that, when traversed, contains the elements of the initial QueryResult that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed. ]] --------------------------------------------------------------------------------------------------------------------- --[[ Returns a QueryResult consisting of the elements of this QueryResult with a new pipeline operation @param operation {function(param, value, count) -> newValue, accept, continues} @param param {any} @return the new QueryResult ]] function QueryResult:With(operation, param) local pipeline = {} for _,operator in ipairs(self._pipeline) do table.insert(pipeline, operator) end table.insert(pipeline, { operation, param }) return setmetatable({ chunks = self.chunks, _pipeline = pipeline, }, QueryResult) end --[[ Returns a QueryResult consisting of the elements of this QueryResult that match the given predicate. @param predicate {function(value) -> bool} a predicate to apply to each element to determine if it should be included @return the new QueryResult ]] function QueryResult:Filter(predicate) return self:With(operatorFilter, predicate) end --[[ Returns a QueryResult consisting of the results of applying the given function to the elements of this QueryResult. @param mapper {function(value) -> newValue} a function to apply to each element @return the new QueryResult ]] function QueryResult:Map(mapper) return self:With(operatorMap, mapper) end --[[ Returns a QueryResult consisting of the elements of this QueryResult, truncated to be no longer than maxSize in length. This is a short-circuiting stateful intermediate operation. @param maxSize {number} @return the new QueryResult ]] function QueryResult:Limit(maxSize) return self:With(operatorLimit, maxSize) end --[[ ------------------------------------------------------------------------------------------------------------------- Terminal Operations Terminal operations, such as QueryResult:ForEach or QueryResult.AllMatch, may traverse the QueryResult to produce a result or a side-effect. After the terminal operation is performed, the pipeline is considered consumed, and can no longer be used; if you need to traverse the same data source again, you must return to the data source to get a new QueryResult. ]] --------------------------------------------------------------------------------------------------------------------- --[[ Returns whether any elements of this result match the provided predicate. @param predicate { function(value) -> bool} a predicate to apply to elements of this result @returns true if any elements of the result match the provided predicate, otherwise false ]] function QueryResult:AnyMatch(predicate) local anyMatch = false self:ForEach(function(value) if predicate(value) then anyMatch = true end -- break if true return anyMatch end) return anyMatch end --[[ Returns whether all elements of this result match the provided predicate. @param predicate { function(value) -> bool} a predicate to apply to elements of this result @returns true if either all elements of the result match the provided predicate or the result is empty, otherwise false ]] function QueryResult:AllMatch(predicate) local allMatch = true self:ForEach(function(value) if (not predicate(value)) then allMatch = false end -- break if false return allMatch == false end) return allMatch end --[[ Returns some element of the result, or nil if the result is empty. This is a short-circuiting terminal operation. The behavior of this operation is explicitly nondeterministic; it is free to select any element in the result. Multiple invocations on the same result may not return the same value. @return {any} ]] function QueryResult:FindAny() local out self:ForEach(function(value) out = value -- break return true end) return out end --[[ Returns an array containing the elements of this QueryResult. This is a terminal operation. ]] function QueryResult:ToArray() local array = {} self:ForEach(function(value) table.insert(array, value) end) return array end --[[ Returns an Iterator, to use in for loop for count, entity in result:Iterator() do print(entity.id) end ]] function QueryResult:Iterator() local thread = coroutine.create(function() self:ForEach(function(value, count) -- These will be passed back again next iteration coroutine.yield(value, count) end) end) return function() local success, item, index = coroutine.resume(thread) return index, item end end --[[ Performs an action for each element of this QueryResult. This is a terminal operation. The behavior of this operation is explicitly nondeterministic. This operation does not guarantee to respect the encounter order of the QueryResult. @param action {function(value, count) -> bool} A action to perform on the elements, breaks execution case returns true ]] function QueryResult:ForEach(action) local count = 1 local pipeline = self._pipeline local hasPipeline = #pipeline > 0 if (not hasPipeline) then -- faster for _, entities in ipairs(self.chunks) do for entity, _ in pairs(entities) do if (action(entity, count) == true) then return end count = count + 1 end end else -- Pipeline this QueryResult, applying callback to each value for i, entities in ipairs(self.chunks) do for entity,_ in pairs(entities) do local mustStop = false local itemAccepted = true local value = entity if (itemAccepted and hasPipeline) then for _, operator in ipairs(pipeline) do local newValue, acceptItem, canContinue = operator[1](operator[2], value, count) if (not canContinue) then mustStop = true end if acceptItem then value = newValue else itemAccepted = false break end end end if itemAccepted then if (action(value, count) == true) then return end count = count + 1 end if mustStop then return end end end end end return QueryResult ================================================ FILE: src/RobloxLoopManager.lua ================================================ local function InitManager() local RunService = game:GetService("RunService") return { Register = function(world) -- if not RunService:IsRunning() then -- return -- end local beforePhysics = RunService.Stepped:Connect(function() world:Update("process", os.clock()) end) local afterPhysics = RunService.Heartbeat:Connect(function() world:Update("transform", os.clock()) end) local beforeRender if (not RunService:IsServer()) then beforeRender = RunService.RenderStepped:Connect(function() world:Update("render", os.clock()) end) end return function() beforePhysics:Disconnect() afterPhysics:Disconnect() if beforeRender then beforeRender:Disconnect() end end end } end return InitManager ================================================ FILE: src/System.lua ================================================ local STEPS = { "task", "render", "process", "transform" } local System = {} --[[ Create new System Class @param step {process|transform|render|task} @param order {number} (Optional) Allows you to set an execution order (for systems that are not `task`). Default 50 @param query {Query|QueryBuilder} (Optional) Filters the entities that will be processed by this system @param updateFn {function(self, Time)} (Optional) A shortcut for creating systems that only have the Update method @return SystemClass ]] function System.Create(step, order, query, updateFn) if (step == nil or not table.find(STEPS, step)) then error("The step parameter must one of ", table.concat(STEPS, ", ")) end if (order and type(order) == "function") then updateFn = order order = nil elseif query and type(query) == "function" then updateFn = query query = nil end if (order and type(order) == "table" and (order.isQuery or order.isQueryBuilder)) then query = order order = nil end if (order == nil or order < 0) then order = 50 end if type(query) == "function" then updateFn = query query = nil end if (query and query.isQueryBuilder) then query = query.Build() end local SystemClass = { Step = step, -- Allows you to define the execution priority level for this system Order = order, Query = query, -- After = {SystemC, SystemD}, When the system is a task, it allows you to define that this system should run AFTER other specific systems. -- Before = {SystemA, SystemB}, When the system is a task, it allows you to define that this system should run BEFORE other specific systems. --[[ ShouldUpdate(Time) -> bool - Invoked before 'Update', allows you to control the execution of the update Update(Time) [QuerySystem] OnRemove(Time, enity) OnExit(Time, entity) OnEnter(Time, entity) ]] } SystemClass.__index = SystemClass --[[ Create an instance of this system @param world {World} @param config {table} ]] function SystemClass.New(world, config) local system = setmetatable({ version = 0, _world = world, _config = config, }, SystemClass) if system.Initialize then system:Initialize(config) end return system end --[[ Get this system class @return SystemClass ]] function SystemClass:GetType() return SystemClass end --[[ Run a query in the world. A shortcut to `self._world:Exec(query)` @query {Query|QueryBuilder} Optional If nil, use default query @return QueryResult ]] function SystemClass:Result(query) return self._world:Exec(query or SystemClass.Query) end --[[ destroy this instance ]] function SystemClass:Destroy() if self.OnDestroy then self.OnDestroy() end setmetatable(self, nil) for k,v in pairs(self) do self[k] = nil end end if updateFn and type(updateFn) == "function" then SystemClass.Update = updateFn end return SystemClass end return System ================================================ FILE: src/SystemExecutor.lua ================================================ --[[ After = {SystemC, SystemD}, An update order that requests ECS update this system after it updates another specified system. Before = {SystemA, SystemB}, An update order that requests ECS update this system before it updates another specified system. ]] local function mapTaskDependencies(systems) local nodes = {} local nodesByType = {} for i,system in ipairs(systems) do local sType = system:GetType() if (system._TaskState == nil) then -- suspended, scheduled, running system._TaskState = "suspended" end if not nodesByType[sType] then local node = { Type = sType, System = system, -- @type {[Node]=true} Depends = {} } nodesByType[sType] = node table.insert(nodes, node) end end for _, node in ipairs(nodes) do -- this system will update Before another specified system local before = node.Type.Before if (before ~= nil and #before > 0) then for _,sTypeOther in ipairs(before) do local otherNode = nodesByType[sTypeOther] if otherNode then otherNode.Depends[node] = true end end end -- this system will update After another specified system local after = node.Type.After if (after ~= nil and #after > 0) then for _,sTypeOther in ipairs(after) do local otherNode = nodesByType[sTypeOther] if otherNode then node.Depends[otherNode] = true end end end end return nodes end local function orderSystems(a, b) return a.Order < b.Order end --[[ Responsible for coordinating and executing the systems methods ]] local SystemExecutor = {} SystemExecutor.__index = SystemExecutor function SystemExecutor.New(world) local executor = setmetatable({ _world = world, _onExit = {}, _onEnter = {}, _onRemove = {}, _task = {}, _render = {}, _process = {}, _transform = {}, _schedulers = {}, _lastFrameMatchQueries = {}, _currentFrameMatchQueries = {}, }, SystemExecutor) world:OnQueryMatch(function(query) executor._currentFrameMatchQueries[query] = true end) return executor end function SystemExecutor:SetSystems(systems) local onExit = {} local onEnter = {} local onRemove = {} -- system:Update() local updateTask = {} local updateRender = {} local updateProcess = {} local updateTransform = {} for _, system in pairs(systems) do local step = system.Step if system.Update then if step == "task" then table.insert(updateTask, system) elseif step == "process" then table.insert(updateProcess, system) elseif step == "transform" then table.insert(updateTransform, system) elseif step == "render" then table.insert(updateRender, system) end end if (system.Query and system.Query.isQuery and step ~= "task") then if system.OnExit then table.insert(onExit, system) end if system.OnEnter then table.insert(onEnter, system) end if system.OnRemove then table.insert(onRemove, system) end end end updateTask = mapTaskDependencies(updateTask) table.sort(onExit, orderSystems) table.sort(onEnter, orderSystems) table.sort(onRemove, orderSystems) table.sort(updateRender, orderSystems) table.sort(updateProcess, orderSystems) table.sort(updateTransform, orderSystems) self._onExit = onExit self._onEnter = onEnter self._onRemove = onRemove self._task = updateTask self._render = updateRender self._process = updateProcess self._transform = updateTransform end --[[ @param Time @param changedEntities { { [Entity] = Old } } ]] function SystemExecutor:ExecOnExitEnter(Time, changedEntities) local isEmpty = true -- { [Old] = { [New] = {Entity, Entity, ...} } } local oldIndexed = {} for entity, archetypeOld in pairs(changedEntities) do local newIndexed = oldIndexed[archetypeOld] if not newIndexed then newIndexed = {} oldIndexed[archetypeOld] = newIndexed end local archetypeNew = entity.archetype local entities = newIndexed[archetypeNew] if not entities then entities = {} newIndexed[archetypeNew] = entities end table.insert(entities, entity) isEmpty = false end if isEmpty then return end self:_ExecOnEnter(Time, oldIndexed) self:_ExecOnExit(Time, oldIndexed) end --[[ Executes the systems' OnEnter method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:_ExecOnEnter(Time, oldIndexed) local world = self._world for _, system in ipairs(self._onEnter) do local query = system.Query for archetypeOld, newIndexed in pairs(oldIndexed) do if not query:Match(archetypeOld) then for archetypeNew, entities in pairs(newIndexed) do if query:Match(archetypeNew) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnEnter(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end end end --[[ Executes the systems' OnExit method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:_ExecOnExit(Time, oldIndexed) local world = self._world for _, system in ipairs(self._onExit) do local query = system.Query for archetypeOld, newIndexed in pairs(oldIndexed) do if query:Match(archetypeOld) then for archetypeNew, entities in pairs(newIndexed) do if not query:Match(archetypeNew) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnExit(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end end end --[[ Executes the systems' OnRemove method @param Time {Time} @param entities {{[Key=Entity] => Archetype}} ]] function SystemExecutor:ExecOnRemove(Time, removedEntities) local isEmpty = true local oldIndexed = {} for entity, archetypeOld in pairs(removedEntities) do local entities = oldIndexed[archetypeOld] if not entities then entities = {} oldIndexed[archetypeOld] = entities end table.insert(entities, entity) isEmpty = false end if isEmpty then return end local world = self._world for _, system in ipairs(self._onRemove) do for archetypeOld, entities in pairs(oldIndexed) do if system.Query:Match(archetypeOld) then for i,entity in ipairs(entities) do world.version = world.version + 1 -- increment Global System Version (GSV) system:OnRemove(Time, entity) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end end local function execUpdate(executor, systems, Time) local world = executor._world local lastFrameMatchQueries = executor._lastFrameMatchQueries local currentFrameMatchQueries = executor._currentFrameMatchQueries for j, system in ipairs(systems) do local canExec = true if system.Query then local query = system.Query if lastFrameMatchQueries[query] == true or currentFrameMatchQueries[query] == true then -- If the query ran in the last frame, it is likely to run successfully on this canExec = true else -- Always revalidates, the repository undergoes constant change canExec = world:FastCheck(query) currentFrameMatchQueries[query] = canExec end end if canExec then if (system.ShouldUpdate == nil or system.ShouldUpdate(Time)) then world.version = world.version + 1 -- increment Global System Version (GSV) system:Update(Time) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end end end end function SystemExecutor:ExecProcess(Time) self._currentFrameMatchQueries = {} execUpdate(self, self._process, Time) end function SystemExecutor:ExecTransform(Time) execUpdate(self, self._transform, Time) end function SystemExecutor:ExecRender(Time) execUpdate(self, self._render, Time) self._lastFrameMatchQueries = self._currentFrameMatchQueries end --[[ Starts the execution of Jobs. Each Job is performed in an individual coroutine @param maxExecTime {number} limits the amount of time jobs can run ]] function SystemExecutor:ExecTasks(maxExecTime) while maxExecTime > 0 do local hasMore = false -- https://github.com/wahern/cqueues/issues/231#issuecomment-562838785 local i, len = 0, #self._schedulers-1 while i <= len do i = i + 1 local scheduler = self._schedulers[i] local tasksTime, hasMoreTask = scheduler.Resume(maxExecTime) if hasMoreTask then hasMore = true end maxExecTime = maxExecTime - (tasksTime + 0.00001) if (maxExecTime <= 0) then break end end if not hasMore then return end end end local function execTask(node, Time, world, onComplete) local system = node.System system._TaskState = "running" if (system.ShouldUpdate == nil or system.ShouldUpdate(Time)) then world.version = world.version + 1 -- increment Global System Version (GSV) system:Update(Time) -- local dirty = entity.version > system.version system.version = world.version -- update last system version with GSV end system._TaskState = "suspended" onComplete(node) end --[[ Invoked at the beginning of each frame, it schedules the execution of the next tasks ]] function SystemExecutor:ScheduleTasks(Time) local world = self._world local rootNodes = {} -- Node[] local runningNodes = {} -- Node[] local scheduled = {} -- { [Node] = true } local completed = {} -- { [Node] = true } local dependents = {} -- { [Node] = Node[] } local i, len = 0, #self._task-1 while i <= len do i = i + 1 local node = self._task[i] if (node.System._TaskState == "suspended") then -- will be executed node.System._TaskState = "scheduled" local hasDependencies = false for other,_ in pairs(node.Depends) do hasDependencies = true if dependents[other] == nil then dependents[other] = {} end table.insert(dependents[other], node) end if (not hasDependencies) then table.insert(rootNodes, node) end scheduled[node] = true end end -- suspended, scheduled, running local function onComplete(node) node.Thread = nil node.LastExecTime = nil completed[node] = true -- alguma outra tarefa depende da execucao deste no para executar? if dependents[node] then local dependentsFromNode = dependents[node] local i, len = 0, #dependentsFromNode-1 while i <= len do i = i + 1 local dependent = dependentsFromNode[i] if scheduled[dependent] then local allDependenciesCompleted = true for otherNode,_ in pairs(dependent.Depends) do if completed[otherNode] ~= true then allDependenciesCompleted = false break end end if allDependenciesCompleted then scheduled[dependent] = nil dependent.LastExecTime = 0 dependent.Thread = coroutine.create(execTask) table.insert(runningNodes, dependent) end end end end end if #rootNodes > 0 then local i, len = 0, #rootNodes-1 while i <= len do i = i + 1 local node = rootNodes[i] scheduled[node] = nil node.LastExecTime = 0 node.Thread = coroutine.create(execTask) table.insert(runningNodes, node) end local scheduler scheduler = { Resume = function(maxExecTime) -- orders the threads, executing the ones with the least execution time first this prevents long tasks -- from taking up all the processing time table.sort(runningNodes, function(nodeA, nodeB) return nodeA.LastExecTime < nodeB.LastExecTime end) local totalTime = 0 -- https://github.com/wahern/cqueues/issues/231#issuecomment-562838785 local i, len = 0, #runningNodes-1 while i <= len do i = i + 1 local node = runningNodes[i] if node.Thread ~= nil then local execTime = os.clock() node.LastExecTime = execTime coroutine.resume(node.Thread, node, Time, world, onComplete) totalTime = totalTime + (os.clock() - execTime) if (totalTime > maxExecTime) then break end end end -- remove completed for i,node in ipairs(runningNodes) do if node.Thread == nil then local idx = table.find(runningNodes, node) if idx ~= nil then table.remove(runningNodes, idx) end end end local hasMore = #runningNodes > 0 if (not hasMore) then local idx = table.find(self._schedulers, scheduler) if idx ~= nil then table.remove(self._schedulers, idx) end end return totalTime, hasMore end } table.insert(self._schedulers, scheduler) end end return SystemExecutor ================================================ FILE: src/Timer.lua ================================================ -- if execution is slow, perform a maximum of 4 simultaneous updates in order to keep the fixrate local MAX_SKIP_FRAMES = 4 local function loop(Time) local accumulator = 0.0 local lastStepTime = 0.0 return function (newTime, stepName, beforeUpdateFn, updateFn) local dtFixed = Time.DeltaFixed local stepTime = newTime - lastStepTime if stepTime > 0.25 then stepTime = 0.25 end lastStepTime = newTime Time.Now = newTime -- 1000/30/1000 = 0.03333333333333333 accumulator = accumulator + stepTime --[[ Adjusting the framerate, the world must run on the same frequency, this ensures determinism in the execution of the scripts Each system in "transform" step is executed at a predetermined frequency (in Hz). Ex. If the game is running on the client at 30FPS but a system needs to be run at 120Hz or 240Hz, this logic will ensure that this frequency is reached @see https://gafferongames.com/post/fix_your_timestep/ @see https://gameprogrammingpatterns.com/game-loop.html @see https://bell0bytes.eu/the-game-loop/ ]] if stepName == "process" then if accumulator >= dtFixed then Time.Interpolation = 1 beforeUpdateFn(Time) local nLoops = 0 while (accumulator >= dtFixed and nLoops < MAX_SKIP_FRAMES) do updateFn(Time) nLoops = nLoops + 1 Time.Process = Time.Process + dtFixed accumulator = accumulator - dtFixed end end else Time.Interpolation = math.min(math.max(accumulator/dtFixed, 0), 1) beforeUpdateFn(Time) updateFn(Time) end end end local Timer = {} Timer.__index = Timer function Timer.New(frequency) local Time = { Now = 0, -- The time at the beginning of this frame. The world receives the current time at the beginning -- of each frame, with the value increasing per frame. Frame = 0, Process = 0, -- The time the latest process step has started. Delta = 0, -- The completion time in seconds since the last frame. DeltaFixed = 0, -- INTERPOLATION: The proportion of time since the previous transform relative to processDeltaTime Interpolation = 0 } local timer = setmetatable({ -- Public, visible by systems Time = Time, Frequency = 0, _update = loop(Time) }, Timer) timer:SetFrequency(frequency) return timer end --[[ Changes the frequency of execution of the "process" step @param frequency {number} ]] function Timer:SetFrequency(frequency) -- frequency: number, -- The maximum times per second this system should be updated. Defaults 30 if frequency == nil then frequency = 30 end local safeFrequency = math.floor(math.abs(frequency)/2)*2 if safeFrequency < 2 then safeFrequency = 2 end if frequency ~= safeFrequency then frequency = safeFrequency end self.Frequency = frequency self.Time.DeltaFixed = 1000/frequency/1000 end function Timer:Update(now, step, beforeUpdate, update) self._update(now, step, beforeUpdate, update) end return Timer ================================================ FILE: src/Utility.lua ================================================ --[[ Utility library. ]] local Utility = {} if table.unpack == nil then table.unpack = unpack end if table.find == nil then --[[ Within the given array-like table haystack, find the first occurrence of value needle, starting from index init or the beginning if not provided. If the value is not found, nil is returned. A linear search algorithm is performed. ]] table.find = function(haystack, needle, init) local len = #haystack for i = init or 1, len, 1 do if haystack[i] == needle then return i end end return nil end end local function copyDeep(src) local copy = {} for k, v in pairs(src) do if type(v) == "table" then v = copyDeep(v) end copy[k] = v end return copy end Utility.copyDeep = copyDeep --[[ Faz o merge dos atributos src com o dest Quando o um atributo do segundo é um "table", faz uma copia do valor ]] local function mergeDeep(dest, src) for k,valueSrc in pairs(src) do if (type(valueSrc) == "table") then local valueDest = dest[k] if (valueDest == nil or type(valueDest) ~= "table") then dest[k] = copyDeep(valueSrc) else dest[k] = mergeDeep(valueDest, valueSrc) end else dest[k] = valueSrc end end return dest end Utility.mergeDeep = mergeDeep return Utility ================================================ FILE: src/World.lua ================================================ local Timer = require("Timer") local Event = require("Event") local Entity = require("Entity") local Archetype = require("Archetype") local SystemExecutor = require("SystemExecutor") local EntityRepository = require("EntityRepository") local World = {} World.__index = World --[[ Create a new world instance @param systemClasses {SystemClass[]} (Optional) Array of system classes @param frequency {number} (Optional) define the frequency that the `process` step will be executed. Default 30 @param disableAutoUpdate {bool} (Optional) when `~= false`, the world automatically registers in the `LoopManager`, receiving the `World:Update()` method from it. Default false ]] function World.New(systemClasses, frequency, disableAutoUpdate) local world = setmetatable({ --[[ Global System Version (GSV). Before executing the Update method of each system, the world version is incremented, so at this point, the world version will always be higher than the running system version. Whenever an entity archetype is changed (received or lost component) the entity's version is updated to the current version of the world. After executing the System Update method, the version of this system is updated to the current world version. This mechanism allows a system to know if an entity has been modified after the last execution of this same system, as the entity's version is superior to the version of the last system execution. Thus, a system can contain logic if it only operates on "dirty" entities, which have undergone changes. The code for this validation on a system is `local isDirty = entity.version > self.version` ]] version = 0, --[[ Allows you to define the maximum time that the JobSystem can operate in each frame. The default value is 0.011666666666666665 = ((1000/60/1000)*0.7) A game that runs at 30fps has 0.0333 seconds to do all the processing for each frame, including rendering - 30FPS = ((1000/30/1000)*0.7)/3 = 0.007777777777777777 A game that runs at 60fps has 0.0166 seconds to do all the processing for each frame, including rendering - 60FPS = ((1000/60/1000)*0.7)/3 = 0.0038888888888888883 ]] maxTasksExecTime = 0.013333333333333334, _dirty = false, -- True when create/remove entity, add/remove entity component (change archetype) _timer = Timer.New(frequency), _systems = {}, -- systems in this world _repository = EntityRepository.New(), _entitiesCreated = {}, -- created during the execution of the Update _entitiesRemoved = {}, -- removed during execution (only removed after the last execution step) _entitiesUpdated = {}, -- changed during execution (received or lost components, therefore, changed the archetype) _onQueryMatch = Event.New(), _onChangeArchetypeEvent = Event.New(), }, World) -- System execution plan world._executor = SystemExecutor.New(world) world._onChangeArchetypeEvent:Connect(function(entity, archetypeOld, archetypeNew) world:_OnChangeArchetype(entity, archetypeOld, archetypeNew) end) -- add systems if (systemClasses ~= nil) then for _, systemClass in ipairs(systemClasses) do world:AddSystem(systemClass) end end if (not disableAutoUpdate and World.LoopManager) then world._loopCancel = World.LoopManager.Register(world) end return world end --[[ Changes the frequency of execution of the "process" step @param frequency {number} ]] function World:SetFrequency(frequency) frequency = self._timer:SetFrequency(frequency) end --[[ Get the frequency of execution of the "process" step @return number ]] function World:GetFrequency(frequency) return self._timer.Frequency end --[[ Add a new system to the world. Only one instance per type is accepted. If there is already another instance of this system in the world, any new invocation of this method will be ignored. @param systemClass {SystemClass} The system to be added in the world @param config {table} (Optional) System instance configuration ]] function World:AddSystem(systemClass, config) if systemClass then if config == nil then config = {} end if self._systems[systemClass] == nil then self._systems[systemClass] = systemClass.New(self, config) self._executor:SetSystems(self._systems) end end end --[[ Create a new entity. The entity is created in DEAD state (entity.isAlive == false) and will only be visible for queries after the cleaning step (OnRemove, OnEnter, OnExit) of the current step @param args {Component[]} @return Entity ]] function World:Entity(...) local entity = Entity.New(self._onChangeArchetypeEvent, {...}) self._dirty = true self._entitiesCreated[entity] = true entity.version = self.version -- update entity version using current Global System Version (GSV) entity.isAlive = false return entity end --[[ Performs immediate removal of an entity. If the entity was created in this step and the cleanup process has not happened yet (therefore the entity is inactive, entity.isAlive == false), the `OnRemove` event will never be fired. If the entity is alive (entity.isAlive == true), even though it is removed immediately, the `OnRemove` event will be fired at the end of the current step. @param entity {Entity} ]] function World:Remove(entity) if self._entitiesRemoved[entity] == true then return end if self._entitiesCreated[entity] == true then self._entitiesCreated[entity] = nil else self._repository:Remove(entity) self._entitiesRemoved[entity] = true if self._entitiesUpdated[entity] == nil then self._entitiesUpdated[entity] = entity.archetype end end self._dirty = true entity.isAlive = false end --[[ Run a query in this world @param query {Query|QueryBuilder} @return QueryResult ]] function World:Exec(query) if (query.isQueryBuilder) then query = query.Build() end local result, match = self._repository:Query(query) if match then self._onQueryMatch:Fire(query) end return result end --[[ Quick check to find out if a query is applicable. @param query {Query|QueryBuilder} @return QueryResult ]] function World:FastCheck(query) if (query.isQueryBuilder) then query = query.Build() end return self._repository:FastCheck(query) end --[[ Add a callback that is reported whenever a query has been successfully executed. Used internally to quickly find out if a QuerySystem will run. ]] function World:OnQueryMatch(callback) return self._onQueryMatch:Connect(callback) end --[[ Perform world update. When registered, LoopManager will invoke World Update for each step in the sequence. - process At the beginning of each frame - transform After the game engine's physics engine runs - render Before rendering the current frame @param step {"process"|"transform"|"render"} @param now {number} Usually os.clock() ]] function World:Update(step, now) self._timer:Update( now, step, function(Time) --[[ JobSystem .------------------. | pipeline | |------------------| | s:ShouldUpdate() | | s:Update() | '------------------' ]] if step == "process" then self._executor:ScheduleTasks(Time) end -- run suspended Tasks self._executor:ExecTasks(self.maxTasksExecTime) end, function(Time) --[[ .------------------. | pipeline | |------------------| | s:ShouldUpdate() | | s:Update() | | | |-- CLEAR ---------| | s:OnRemove() | | s:OnExit() | | s:OnEnter() | '------------------' ]] if step == "process" then self._executor:ExecProcess(Time) elseif step == "transform" then self._executor:ExecTransform(Time) else self._executor:ExecRender(Time) end -- cleans up after running scripts while self._dirty do self._dirty = false -- 1: remove entities local entitiesRemoved = {} for entity,_ in pairs(self._entitiesRemoved) do entitiesRemoved[entity] = self._entitiesUpdated[entity] self._entitiesUpdated[entity] = nil end self._entitiesRemoved = {} self._executor:ExecOnRemove(Time, entitiesRemoved) entitiesRemoved = nil local changed = {} local hasChange = false -- 2: Update entities in memory for entity, archetypeOld in pairs(self._entitiesUpdated) do if (archetypeOld ~= entity.archetype) then hasChange = true changed[entity] = archetypeOld end end self._entitiesUpdated = {} -- 3: Add new entities for entity, _ in pairs(self._entitiesCreated) do hasChange = true changed[entity] = Archetype.EMPTY entity.isAlive = true self._repository:Insert(entity) end self._entitiesCreated = {} if hasChange then self._executor:ExecOnExitEnter(Time, changed) changed = nil end end end ) end --[[ Destroy this instance, removing all entities, systems and events ]] function World:Destroy() if self._loopCancel then self._loopCancel() self._loopCancel = nil end if self._onChangeArchetypeEvent then self._onChangeArchetypeEvent:Destroy() self._onChangeArchetypeEvent = nil end self._repository = nil if self._systems then for _,system in pairs(self._systems) do system:Destroy() end self._systems = nil end self._timer = nil self._ExecPlan = nil self._entitiesCreated = nil self._entitiesUpdated = nil self._entitiesRemoved = nil setmetatable(self, nil) end function World:_OnChangeArchetype(entity, archetypeOld, archetypeNew) if entity.isAlive then if self._entitiesUpdated[entity] == nil then self._dirty = true self._entitiesUpdated[entity] = archetypeOld end self._repository:Update(entity) -- update entity version using current Global System Version (GSV) entity.version = self.version end end return World ================================================ FILE: test/README.md ================================================ see [CONTRIBUTING.md](/CONTRIBUTING.md) ================================================ FILE: test/test_Archetype.lua ================================================ local lu = require('luaunit') local Archetype = require('Archetype') TestArchetype = {} local SEQ = 1 local function newCompId() SEQ = SEQ+1 return SEQ end function TestArchetype:test_Should_ReturnSame() local version = Archetype.Version() local ComponentA = { Id = newCompId(), IsCType = true } local ComponentB = { Id = newCompId(), IsCType = true } local ComponentC = { Id = newCompId(), } local ComponentS = { Id = newCompId(), IsCType = true } local ComponentQ = { Id = newCompId(), IsCType = true, IsQualifier = true, SuperClass = ComponentS } local archetypeA = Archetype.Of({ ComponentA, ComponentQ, ComponentB, ComponentC }) local archetypeB = Archetype.Of({ ComponentC, ComponentA, ComponentQ, ComponentB }) lu.assertNotIsNil(archetypeA) lu.assertNotIsNil(archetypeB) lu.assertEquals(archetypeA, archetypeB) lu.assertIsTrue(archetypeA:Has(ComponentA)) lu.assertIsTrue(archetypeA:Has(ComponentB)) lu.assertIsTrue(archetypeA:Has(ComponentS)) lu.assertIsTrue(archetypeA:Has(ComponentQ)) lu.assertIsFalse(archetypeA:Has(ComponentC)) lu.assertNotEquals(version, Archetype.Version()) end function TestArchetype:test_Should_Create_With_Component() local ComponentA = { Id = newCompId(), IsCType = true } local ComponentB = { Id = newCompId(), IsCType = true } local ComponentC = { Id = newCompId(), } local ComponentD = { Id = newCompId(), IsCType = true } local archetypeA = Archetype.Of({ ComponentA, ComponentB, ComponentC }) local archetypeB = Archetype.Of({ ComponentA, ComponentB, ComponentC, ComponentD }) local archetypeC = archetypeA:With(ComponentD) local archetypeD = archetypeB:With(ComponentD) lu.assertNotIsNil(archetypeA) lu.assertNotIsNil(archetypeB) lu.assertNotIsNil(archetypeC) lu.assertNotIsNil(archetypeD) lu.assertEquals(archetypeB, archetypeC) lu.assertEquals(archetypeB, archetypeD) lu.assertIsTrue(archetypeB:Has(ComponentD)) lu.assertIsFalse(archetypeA:Has(ComponentD)) end function TestArchetype:test_Should_Create_WithAll_Components() local ComponentA = { Id = newCompId(), IsCType = true } local ComponentB = { Id = newCompId(), IsCType = true } local ComponentC = { Id = newCompId(), } local ComponentD = { Id = newCompId(), IsCType = true } local ComponentE = { Id = newCompId(), IsCType = true } local archetypeA = Archetype.Of({ ComponentA, ComponentB, ComponentC }) local archetypeB = Archetype.Of({ ComponentA, ComponentB, ComponentC, ComponentD, ComponentE }) local archetypeC = archetypeA:WithAll({ ComponentD, ComponentE }) local archetypeD = archetypeB:WithAll({ ComponentD, ComponentE }) lu.assertNotIsNil(archetypeA) lu.assertNotIsNil(archetypeB) lu.assertNotIsNil(archetypeC) lu.assertNotIsNil(archetypeD) lu.assertEquals(archetypeB, archetypeC) lu.assertEquals(archetypeB, archetypeD) lu.assertIsTrue(archetypeB:Has(ComponentD)) lu.assertIsTrue(archetypeB:Has(ComponentE)) lu.assertIsFalse(archetypeA:Has(ComponentD)) lu.assertIsFalse(archetypeA:Has(ComponentE)) end function TestArchetype:test_Should_Create_Without_Component() local ComponentA = { Id = newCompId(), IsCType = true } local ComponentB = { Id = newCompId(), IsCType = true } local ComponentC = { Id = newCompId(), } local ComponentD = { Id = newCompId(), IsCType = true } local archetypeA = Archetype.Of({ ComponentA, ComponentB, ComponentC }) local archetypeB = Archetype.Of({ ComponentA, ComponentB, ComponentC, ComponentD }) local archetypeC = archetypeB:Without(ComponentD) local archetypeD = archetypeA:Without(ComponentD) lu.assertNotIsNil(archetypeA) lu.assertNotIsNil(archetypeB) lu.assertNotIsNil(archetypeC) lu.assertNotIsNil(archetypeD) lu.assertEquals(archetypeA, archetypeC) lu.assertEquals(archetypeA, archetypeD) lu.assertIsTrue(archetypeB:Has(ComponentD)) lu.assertIsFalse(archetypeA:Has(ComponentD)) end function TestArchetype:test_Should_Create_WithoutAll_Components() local ComponentA = { Id = newCompId(), IsCType = true } local ComponentB = { Id = newCompId(), IsCType = true } local ComponentC = { Id = newCompId(), } local ComponentD = { Id = newCompId(), IsCType = true } local ComponentE = { Id = newCompId(), IsCType = true } local archetypeA = Archetype.Of({ ComponentA, ComponentB, ComponentC }) local archetypeB = Archetype.Of({ ComponentA, ComponentB, ComponentC, ComponentD, ComponentE }) local archetypeC = archetypeB:WithoutAll({ ComponentD, ComponentE }) local archetypeD = archetypeA:WithoutAll({ ComponentD, ComponentE }) lu.assertNotIsNil(archetypeA) lu.assertNotIsNil(archetypeB) lu.assertNotIsNil(archetypeC) lu.assertNotIsNil(archetypeD) lu.assertEquals(archetypeA, archetypeC) lu.assertEquals(archetypeA, archetypeD) lu.assertIsTrue(archetypeB:Has(ComponentD)) lu.assertIsTrue(archetypeB:Has(ComponentE)) lu.assertIsFalse(archetypeA:Has(ComponentD)) lu.assertIsFalse(archetypeA:Has(ComponentE)) end ================================================ FILE: test/test_Component.lua ================================================ local lu = require('luaunit') function sleep(a) local sec = tonumber(os.clock() + a); while (os.clock() < sec) do end end local Component = require('Component') TestComponent = {} function TestComponent:test_GetType() local Comp_A = Component.Create() local Comp_B = Component.Create() local Comp_C = Component.Create() local comp_a = Comp_A() local comp_b = Comp_B() local comp_c = Comp_C() lu.assertEquals(comp_a:GetType(), Comp_A) lu.assertEquals(comp_b:GetType(), Comp_B) lu.assertEquals(comp_c:GetType(), Comp_C) end function TestComponent:test_Value() local ComponentClass = Component.Create("foo") local component1 = ComponentClass() local component2 = ComponentClass("bar") lu.assertEquals(component1.value, "foo") lu.assertEquals(component2.value, "bar") end function TestComponent:test_Should_UseTemplate_Table() local ComponentClass = Component.Create({ Level1 = { Level2 = { Level3 = { value = 00 } } } }) local component1 = ComponentClass() local component2 = ComponentClass() lu.assertEquals(component1.Level1.Level2.Level3.value, 00) lu.assertEquals(component2.Level1.Level2.Level3.value, 00) component1.Level1.Level2.Level3.value = 11 component2.Level1.Level2.Level3.value = 22 lu.assertEquals(component1.Level1.Level2.Level3.value, 11) lu.assertEquals(component2.Level1.Level2.Level3.value, 22) lu.assertEquals(component1:GetType(), ComponentClass) end function TestComponent:test_Should_UseTemplate_Function() local ComponentClass = Component.Create(function() return { Level1 = { Level2 = { Level3 = { value = 00 } } } } end) local component1 = ComponentClass() local component2 = ComponentClass() lu.assertEquals(component1.Level1.Level2.Level3.value, 00) lu.assertEquals(component2.Level1.Level2.Level3.value, 00) component1.Level1.Level2.Level3.value = 11 component2.Level1.Level2.Level3.value = 22 lu.assertEquals(component1.Level1.Level2.Level3.value, 11) lu.assertEquals(component2.Level1.Level2.Level3.value, 22) end function TestComponent:test_Should_CreateQualifier() local HealthBuff = Component.Create({ Percent = 0 }) local HealthBuffLevel = HealthBuff.Qualifier("Level") local HealthBuffMission = HealthBuff.Qualifier("Mission") -- same object local HealthBuffMissionCopy1 = HealthBuff.Qualifier("Mission") local HealthBuffMissionCopy2 = HealthBuffMission.Qualifier("Mission") local HealthBuffMissionCopy3 = HealthBuff.Qualifier(HealthBuffMissionCopy1) lu.assertEquals(HealthBuffMission, HealthBuffMissionCopy1) lu.assertEquals(HealthBuffMission, HealthBuffMissionCopy2) lu.assertEquals(HealthBuffMission, HealthBuffMissionCopy3) local OtherComponent = Component.Create({ Percent = 0 }) lu.assertIsNil(HealthBuff.Qualifier(OtherComponent)) lu.assertIsNil(HealthBuff.Qualifier({})) lu.assertItemsEquals(HealthBuff.Qualifiers(), {HealthBuff, HealthBuffLevel, HealthBuffMission}) lu.assertItemsEquals(HealthBuffLevel.Qualifiers(), {HealthBuff, HealthBuffLevel, HealthBuffMission}) lu.assertItemsEquals(HealthBuffMission.Qualifiers(), {HealthBuffLevel, HealthBuffMission, HealthBuff }) lu.assertItemsEquals(HealthBuff.Qualifiers("Level", "Mission"), {HealthBuffLevel, HealthBuffMission}) lu.assertItemsEquals(HealthBuffLevel.Qualifiers("Primary", "Mission"), {HealthBuff, HealthBuffMission}) lu.assertItemsEquals(HealthBuffMission.Qualifiers("Primary", "Level"), {HealthBuff, HealthBuffLevel }) local function mergeCase(mergeFn) local buff = HealthBuff() local buffLevel = HealthBuffLevel() local buffMission = HealthBuffMission() lu.assertEquals(buff:GetType(), HealthBuff) lu.assertEquals(buffLevel:GetType(), HealthBuffLevel) lu.assertEquals(buffMission:GetType(), HealthBuffMission) lu.assertIsTrue(buff:Is(HealthBuff)) lu.assertIsTrue(buffLevel:Is(HealthBuff)) lu.assertIsTrue(buffLevel:Is(HealthBuffLevel)) lu.assertIsTrue(buffMission:Is(HealthBuff)) lu.assertIsTrue(buffMission:Is(HealthBuffMission)) -- merge all mergeFn(buff, buffLevel, buffMission) -- tem de ignorar esse merge local OtherComponent = Component.Create({ value = 0 }) local other = OtherComponent() buff:Merge(other) -- get primary lu.assertEquals(buff:Primary(), buff) lu.assertEquals(buffLevel:Primary(), buff) lu.assertEquals(buffMission:Primary(), buff) -- get qualified lu.assertEquals(buff:Qualified("Primary"), buff) lu.assertEquals(buffLevel:Qualified("Primary"), buff) lu.assertEquals(buffMission:Qualified("Primary"), buff) lu.assertEquals(buff:Qualified("Level"), buffLevel) lu.assertEquals(buffLevel:Qualified("Level"), buffLevel) lu.assertEquals(buffMission:Qualified("Level"), buffLevel) lu.assertEquals(buff:Qualified("Mission"), buffMission) lu.assertEquals(buffLevel:Qualified("Mission"), buffMission) lu.assertEquals(buffMission:Qualified("Mission"), buffMission) -- all lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffMission:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) end mergeCase(function(buff, buffLevel, buffMission) buff:Merge(buff) buff:Merge(buffLevel) buff:Merge(buffMission) end) mergeCase(function(buff, buffLevel, buffMission) buffLevel:Merge(buff) buffMission:Merge(buffLevel) buff:Merge(buffMission) end) mergeCase(function(buff, buffLevel, buffMission) buffLevel:Merge(buffMission) buffLevel:Merge(buff) end) mergeCase(function(buff, buffLevel, buffMission) buffLevel:Merge(buff) buffLevel:Merge(buffMission) end) end ---------------------------------------------------------------------------- -- FSM - Finite State Machine ---------------------------------------------------------------------------- function TestComponent:test_Should_CreateFSM() local Movement = Component.Create({ Speed = 0 }) local MovementB = Movement.Qualifier("Sub") -- [Standing] <---> [Walking] <---> [Running] Movement.States = { Standing = {"Walking"}, Walking = "*", Running = {"Walking", "Running"}, Other = {"Other"} } -- ignored MovementB.States = { Standing = {"Walking"} } lu.assertEquals(Movement.States, { Standing = {"Walking"}, Walking = "*", Running = {"Walking"}, Other = "*" }) lu.assertEquals(MovementB.States, Movement.States) Movement.StateInitial = "Standing" local CountCall Movement.Case = { Standing = function(self, previous) CountCall.Standing = CountCall.Standing + 1 CountCall.From[previous] = CountCall.From[previous] + 1 end, Walking = function(self, previous) CountCall.Walking = CountCall.Walking + 1 CountCall.From[previous] = CountCall.From[previous] + 1 end, Running = function(self, previous) CountCall.Running = CountCall.Running + 1 CountCall.From[previous] = CountCall.From[previous] + 1 end } local function resetCountCaseCall() CountCall = { Standing = 0, Walking = 0, Running = 0, From = { Standing = 0, Walking = 0, Running = 0 } } sleep(0.1) end -- ECS.Query.All(Movement.In("Standing")) local movement = Movement() lu.assertEquals(movement:GetState(), "Standing") lu.assertEquals(movement:GetPrevState(), nil) local oldStateTime = 0 resetCountCaseCall() movement:SetState("Walking") lu.assertEquals(movement:GetState(), "Walking") lu.assertEquals(movement:GetPrevState(), "Standing") local newStateTime = movement:GetStateTime() lu.assertNotEquals(oldStateTime, newStateTime) oldStateTime = newStateTime lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 1) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 1) lu.assertEquals(CountCall.From.Walking, 0) lu.assertEquals(CountCall.From.Running, 0) resetCountCaseCall() movement:SetState("Running") lu.assertEquals(movement:GetState(), "Running") lu.assertEquals(movement:GetPrevState(), "Walking") newStateTime = movement:GetStateTime() lu.assertNotEquals(oldStateTime, newStateTime) oldStateTime = newStateTime lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 0) lu.assertEquals(CountCall.Running, 1) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 1) lu.assertEquals(CountCall.From.Running, 0) resetCountCaseCall() movement:SetState("Walking") lu.assertEquals(movement:GetState(), "Walking") lu.assertEquals(movement:GetPrevState(), "Running") newStateTime = movement:GetStateTime() lu.assertNotEquals(oldStateTime, newStateTime) oldStateTime = newStateTime lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 1) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 0) lu.assertEquals(CountCall.From.Running, 1) resetCountCaseCall() movement:SetState("Standing") lu.assertEquals(movement:GetState(), "Standing") lu.assertEquals(movement:GetPrevState(), "Walking") newStateTime = movement:GetStateTime() lu.assertNotEquals(oldStateTime, newStateTime) oldStateTime = newStateTime lu.assertEquals(CountCall.Standing, 1) lu.assertEquals(CountCall.Walking, 0) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 1) lu.assertEquals(CountCall.From.Running, 0) resetCountCaseCall() movement:SetState("Running") lu.assertEquals(movement:GetState(), "Standing") lu.assertEquals(movement:GetPrevState(), "Walking") lu.assertEquals(oldStateTime, movement:GetStateTime()) lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 0) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 0) lu.assertEquals(CountCall.From.Running, 0) resetCountCaseCall() movement:SetState(nil) lu.assertEquals(movement:GetState(), "Standing") lu.assertEquals(movement:GetPrevState(), "Walking") lu.assertEquals(oldStateTime, movement:GetStateTime()) lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 0) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 0) lu.assertEquals(CountCall.From.Running, 0) resetCountCaseCall() movement:SetState("INVALID_STATE") lu.assertEquals(movement:GetState(), "Standing") lu.assertEquals(movement:GetPrevState(), "Walking") lu.assertEquals(oldStateTime, movement:GetStateTime()) lu.assertEquals(CountCall.Standing, 0) lu.assertEquals(CountCall.Walking, 0) lu.assertEquals(CountCall.Running, 0) lu.assertEquals(CountCall.From.Standing, 0) lu.assertEquals(CountCall.From.Walking, 0) lu.assertEquals(CountCall.From.Running, 0) end function TestComponent:test_Should_QueryFSM_InState() local function execClause(clause, entity) return clause.Filter(entity, clause.Config) end local Movement = Component.Create({ Speed = 0 }) Movement.States = { Standing = "*", Walking = "*", Running = "*" } local MovementB = Movement.Qualifier("Specialized") lu.assertNotEquals(Movement, MovementB) local ett_Standing = { [Movement] = Movement() } ett_Standing[Movement]:SetState("Standing") local ett_Standing_Walking = { [Movement] = Movement(), [MovementB] = MovementB(), } ett_Standing_Walking[Movement]:Merge(ett_Standing_Walking[MovementB]) ett_Standing_Walking[Movement]:SetState("Standing") ett_Standing_Walking[MovementB]:SetState("Walking") local ett_Running = { [Movement] = Movement(), } ett_Running[Movement]:SetState("Running") -- clause superClass local clause_Walking = Movement.In("Walking") local clause_Running = Movement.In("Running") local clause_Walking_Running = Movement.In("Walking", "Running") lu.assertIsTrue(execClause(clause_Walking, ett_Standing_Walking)) lu.assertIsTrue(execClause(clause_Running, ett_Running)) lu.assertIsTrue(execClause(clause_Walking_Running, ett_Running)) lu.assertIsFalse(execClause(clause_Walking, ett_Standing)) lu.assertIsFalse(execClause(clause_Walking, ett_Running)) lu.assertIsFalse(execClause(clause_Walking_Running, ett_Standing)) -- clause qualifiedClass local clause_b_Walking = MovementB.In("Walking") local clause_b_Running = MovementB.In("Running") local clause_b_Walking_Running = MovementB.In("Walking", "Running") lu.assertIsTrue(execClause(clause_b_Walking, ett_Standing_Walking)) lu.assertIsTrue(execClause(clause_b_Walking_Running, ett_Standing_Walking)) lu.assertIsFalse(execClause(clause_b_Running, ett_Running)) lu.assertIsFalse(execClause(clause_b_Walking_Running, ett_Running)) lu.assertIsFalse(execClause(clause_b_Walking, ett_Standing)) lu.assertIsFalse(execClause(clause_b_Walking, ett_Running)) lu.assertIsFalse(execClause(clause_b_Walking_Running, ett_Standing)) -- clause all lu.assertEquals(Movement.In(), {}) lu.assertEquals(MovementB.In(), {}) -- ignores OtherComponent local OtherComponent = Component.Create({ Percent = 0 }) OtherComponent.States = { Standing = "*", Walking = "*", Running = "*" } local ett_other_co = { [OtherComponent] = OtherComponent(), } ett_other_co[OtherComponent]:SetState("Walking") lu.assertIsFalse(execClause(clause_b_Walking, ett_other_co)) lu.assertIsFalse(execClause(clause_b_Walking_Running, ett_other_co)) end -- local Movement = ECS.Component({ Speed = 0 }) -- Movement.States = { -- Standing = "*", -- Walking = {"Standing", "Running"}, -- Running = {"Walking"} -- } -- Movement.Case = { -- Standing = function(self, previous) -- self.Speed = 0 -- end, -- Walking = function(self, previous) -- self.Speed = 5 -- end, -- Running = function(self, previous) -- self.Speed = 10 -- end -- } -- ECS.Query.All(Movement.In("Standing")) -- local movement = entity[Movement] -- movement:GetState() -> "Running" -- movement:SetState("Walking") -- movement:GetPrevState() -- movement:GetStateTime() -- if movement:GetState() == "Standing" then -- movement.Speed = 0 -- end ================================================ FILE: test/test_Entity.lua ================================================ local lu = require('luaunit') local Event = require('Event') local Entity = require('Entity') local Component = require('Component') local Archetype = require('Archetype') TestEntity = {} local Comp_A = Component.Create({ Name = 'a' }) Comp_A.Name = "A" local Comp_B = Component.Create({ Name = 'b' }) Comp_B.Name = "B" local Comp_C = Component.Create({ Name = 'c' }) Comp_C.Name = "C" local comp_a = Comp_A() local comp_b = Comp_B() local comp_c = Comp_C() local archetype_A = Archetype.Of({ Comp_A }) local archetype_B = Archetype.Of({ Comp_B }) local archetype_C = Archetype.Of({ Comp_C }) local archetype_A_B = archetype_A:With(Comp_B) local archetype_A_C = archetype_A:With(Comp_C) local archetype_B_C = archetype_B:With(Comp_C) local archetype_A_B_C = archetype_A_B:With(Comp_C) function TestEntity:test_Constructor() lu.assertEquals(Entity.New(nil).archetype, Archetype.EMPTY) lu.assertEquals(Entity.New(nil, {comp_a}).archetype, archetype_A) lu.assertEquals(Entity.New(nil, {comp_b}).archetype, archetype_B) lu.assertEquals(Entity.New(nil, {comp_c}).archetype, archetype_C) lu.assertEquals(Entity.New(nil, {comp_a, comp_b}).archetype, archetype_A_B) lu.assertEquals(Entity.New(nil, {comp_a, comp_c}).archetype, archetype_A_C) lu.assertEquals(Entity.New(nil, {comp_b, comp_c}).archetype, archetype_B_C) lu.assertEquals(Entity.New(nil, {comp_a, comp_b, comp_c}).archetype, archetype_A_B_C) end --[[ [GET] 01) comp1 = entity[CompType1] 02) comp1 = entity:Get(CompType1) 03) comps = entity[{CompType1, CompType2, ...}] 04) comps = entity:Get({CompType1, CompType2, ...}) ]] function TestEntity:test_Get() local Object = {} local entity = Entity.New(nil, {comp_a, comp_b, comp_c}) -- 01) comp1 = entity[CompType1] lu.assertEquals(entity[Comp_A], comp_a) lu.assertEquals(entity[Comp_B], comp_b) lu.assertEquals(entity[Comp_C], comp_c) -- 02) comp1 = entity:Get(CompType1) lu.assertEquals(entity:Get(Comp_A), comp_a) lu.assertEquals(entity:Get(Comp_B), comp_b) lu.assertEquals(entity:Get(Comp_C), comp_c) -- 03) comp1, comp2, comp3 = entity:Get(CompType1, CompType2, CompType3) lu.assertEquals(entity:Get({}), nil) lu.assertEquals(entity:Get(Object, "XPTO"), nil) lu.assertEquals({entity:Get(Comp_A)}, {comp_a}) lu.assertEquals({entity:Get(Comp_B)}, {comp_b}) lu.assertEquals({entity:Get(Comp_C)}, {comp_c}) lu.assertEquals({entity:Get(Comp_A, Comp_B)}, {comp_a, comp_b}) lu.assertEquals({entity:Get(Comp_A, Comp_C)}, {comp_a, comp_c}) lu.assertEquals({entity:Get(Comp_B, Comp_C)}, {comp_b, comp_c}) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) lu.assertEquals({entity:Get(Comp_A, Comp_C, Comp_B)}, {comp_a, comp_c, comp_b}) lu.assertEquals({entity:Get(Comp_A, Comp_C, Comp_B)}, {comp_a, comp_c, comp_b}) lu.assertEquals({entity:Get(Comp_A, Comp_C, Comp_B, Object)}, {comp_a, comp_c, comp_b}) lu.assertEquals({entity:Get(Comp_A, Comp_C, Comp_B, "XPTO")}, {comp_a, comp_c, comp_b}) end --[[ [UNSET] 01) enity:Unset(comp1) 02) entity[CompType1] = nil 03) enity:Unset(CompType1) 04) enity:Unset(comp1, comp1, ...) 05) enity:Unset(CompType1, CompType2, ...) ]] function TestEntity:test_Unset() local Object = {} local event = Event.New() local eventEntity = nil local eventArchetypeOld = nil event:Connect(function(entity, old) eventEntity = entity eventArchetypeOld = old end) -- 01) enity:Unset(comp1) local entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity:Unset(Object) lu.assertEquals(eventEntity, nil) lu.assertEquals(eventArchetypeOld, nil) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity:Unset(comp_a) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B_C) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity:Unset(comp_b) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity:Unset(comp_c) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) -- 02) entity[CompType1] = nil entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity[Object] = nil lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity[Comp_A] = nil lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B_C) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity[Comp_B] = nil lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity[Comp_C] = nil lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) -- 03) enity:Unset(CompType1) entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity:Unset(Object) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity:Unset(Comp_A) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B_C) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity:Unset(Comp_B) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity:Unset(Comp_C) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) -- 04) enity:Unset(comp1, comp1, ...) entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity:Unset(Object) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity:Unset(comp_a) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B_C) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity:Unset(comp_b) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity:Unset(comp_c) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) -- 05) enity:Unset(CompType1, CompType2, ...) entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity:Unset(Comp_A) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity:Unset(Comp_B) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity[Comp_C] = nil lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) end --[[ [SET] 01) entity[CompType1] = nil 02) entity[CompType1] = value 03) entity:Set(CompType1, nil) 04) entity:Set(CompType1, value) 05) entity:Set(comp1) 06) entity:Set(comp1, comp2, ...) ]] function TestEntity:test_Set() local Object = {} local event = Event.New() local eventEntity = nil local eventArchetypeOld = nil event:Connect(function(entity, old) eventEntity = entity eventArchetypeOld = old end) -- 05) entity:Set(comp1) local entity = Entity.New(event) entity:Set(Object) lu.assertEquals(eventEntity, nil) lu.assertEquals(eventArchetypeOld, nil) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) entity:Set(comp_a) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, Archetype.EMPTY) lu.assertEquals(entity.archetype, archetype_A) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a}) entity:Set(comp_b) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A) lu.assertEquals(entity.archetype, archetype_A_B) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b}) entity:Set(comp_c) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) -- 01) entity[CompType1] = nil -- @see TestEntity:test_Unset() -- 03) entity:Set(CompType1, nil) entity = Entity.New(event, {comp_a, comp_b, comp_c}) entity:Set(Object, nil) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity:Set(Comp_A, nil) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B_C) lu.assertEquals(entity.archetype, archetype_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_b, comp_c}) entity:Set(Comp_B, nil) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_B_C) lu.assertEquals(entity.archetype, archetype_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_c}) entity:Set(Comp_C, nil) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_C) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) -- 02) entity[CompType1] = value entity = Entity.New(event) entity[Object] = "XPTO" lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) entity[Comp_A] = comp_a lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, Archetype.EMPTY) lu.assertEquals(entity.archetype, archetype_A) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a}) entity[Comp_B] = comp_b lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A) lu.assertEquals(entity.archetype, archetype_A_B) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b}) entity[Comp_C] = comp_c lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity[Comp_C] = { Name = 'NEW C' } lu.assertEquals({entity:Get(Comp_A, Comp_B)}, {comp_a, comp_b}) lu.assertEquals(entity[Comp_C]:GetType(), Comp_C) lu.assertEquals(entity[Comp_C].Name, 'NEW C') lu.assertEquals(entity.archetype, archetype_A_B_C) -- NO EVENT CALL (SAME ARCHETYPE archetype_A_B_C) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B) -- 04) entity:Set(CompType1, value) entity = Entity.New(event) entity:Set(Object, "XPTO") lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) entity:Set(Comp_A, comp_a) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, Archetype.EMPTY) lu.assertEquals(entity.archetype, archetype_A) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a}) entity:Set(Comp_B, comp_b) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A) lu.assertEquals(entity.archetype, archetype_A_B) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b}) entity:Set(Comp_C, comp_c) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) entity:Set(Comp_C, { Name = 'NEW C' }) lu.assertEquals({entity:Get(Comp_A, Comp_B)}, {comp_a, comp_b}) lu.assertEquals(entity[Comp_C]:GetType(), Comp_C) lu.assertEquals(entity[Comp_C].Name, 'NEW C') lu.assertEquals(entity.archetype, archetype_A_B_C) -- NO EVENT CALL (SAME ARCHETYPE archetype_A_B_C) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A_B) -- 06) entity:Set(comp1, comp2, ...) entity = Entity.New(event) entity:Set(Object) lu.assertEquals(entity.archetype, Archetype.EMPTY) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {}) entity:Set(comp_a) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, Archetype.EMPTY) lu.assertEquals(entity.archetype, archetype_A) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a}) entity:Set(comp_b, comp_c) lu.assertEquals(eventEntity, entity) lu.assertEquals(eventArchetypeOld, archetype_A) lu.assertEquals(entity.archetype, archetype_A_B_C) lu.assertEquals({entity:Get(Comp_A, Comp_B, Comp_C)}, {comp_a, comp_b, comp_c}) end function TestEntity:test_RawSet() local Object = {} local event = Event.New() -- 05) entity:Set(comp1) local entity = Entity.New(event) entity.Name = "Player" entity["NetworkId"] = 33 entity[Object] = "XPTO" lu.assertEquals(entity.Name, "Player") lu.assertEquals(entity.NetworkId, 33) lu.assertEquals(entity[Object], "XPTO") lu.assertEquals(entity.archetype, Archetype.EMPTY) end function TestEntity:test_Qualifiers() local event = Event.New() local HealthBuff = Component.Create({ percent = 0 }) local HealthBuffLevel = HealthBuff.Qualifier("Level") local HealthBuffMission = HealthBuff.Qualifier("Mission") local OtherComp = Component.Create({ value = 0 }) local function doTest(instanciate) local entity = Entity.New(event) local buff, buffLevel, buffMission, other = instanciate(entity) lu.assertItemsEquals(entity:GetAll(HealthBuff), {buff, buffLevel, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffLevel), {buff, buffLevel, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffMission), {buff, buffLevel, buffMission}) lu.assertItemsEquals(entity:GetAll(OtherComp), {other}) lu.assertItemsEquals(entity:GetAll(), {other, buff, buffLevel, buffMission}) -- get primary lu.assertEquals(buff:Primary(), buff) lu.assertEquals(buffLevel:Primary(), buff) lu.assertEquals(buffMission:Primary(), buff) -- get qualified lu.assertEquals(buff:Qualified("Primary"), buff) lu.assertEquals(buffLevel:Qualified("Primary"), buff) lu.assertEquals(buffMission:Qualified("Primary"), buff) lu.assertEquals(buff:Qualified("Level"), buffLevel) lu.assertEquals(buffLevel:Qualified("Level"), buffLevel) lu.assertEquals(buffMission:Qualified("Level"), buffLevel) lu.assertEquals(buff:Qualified("Mission"), buffMission) lu.assertEquals(buffLevel:Qualified("Mission"), buffMission) lu.assertEquals(buffMission:Qualified("Mission"), buffMission) -- all lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffMission:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) -- unset entity[HealthBuffLevel] = nil lu.assertItemsEquals(entity:GetAll(HealthBuff), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffLevel), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffMission), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(OtherComp), {other}) lu.assertItemsEquals(entity:GetAll(), {other, buff, buffMission}) lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff, ["Mission"] = buffMission }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Level"] = buffLevel }) lu.assertEquals(buffMission:QualifiedAll(), { ["Primary"] = buff, ["Mission"] = buffMission }) -- unset entity:Unset(HealthBuff) lu.assertItemsEquals(entity:GetAll(HealthBuff), {buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffLevel), {buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffMission), {buffMission}) lu.assertItemsEquals(entity:GetAll(OtherComp), {other}) lu.assertItemsEquals(entity:GetAll(), {other, buffMission}) lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Level"] = buffLevel }) lu.assertEquals(buffMission:QualifiedAll(), { ["Mission"] = buffMission }) -- again local buff, buffLevel, buffMission, other = instanciate(entity) -- all lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) lu.assertEquals(buffMission:QualifiedAll(), { ["Primary"] = buff, ["Level"] = buffLevel, ["Mission"] = buffMission }) -- unset entity[HealthBuffLevel] = nil lu.assertItemsEquals(entity:GetAll(HealthBuff), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffLevel), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffMission), {buff, buffMission}) lu.assertItemsEquals(entity:GetAll(OtherComp), {other}) lu.assertItemsEquals(entity:GetAll(), {other, buff, buffMission}) lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff, ["Mission"] = buffMission }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Level"] = buffLevel }) lu.assertEquals(buffMission:QualifiedAll(), { ["Primary"] = buff, ["Mission"] = buffMission }) -- unset entity:Unset(HealthBuff) lu.assertItemsEquals(entity:GetAll(HealthBuff), {buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffLevel), {buffMission}) lu.assertItemsEquals(entity:GetAll(HealthBuffMission), {buffMission}) lu.assertItemsEquals(entity:GetAll(OtherComp), {other}) lu.assertItemsEquals(entity:GetAll(), {other, buffMission}) lu.assertEquals(buff:QualifiedAll(), { ["Primary"] = buff }) lu.assertEquals(buffLevel:QualifiedAll(), { ["Level"] = buffLevel }) lu.assertEquals(buffMission:QualifiedAll(), { ["Mission"] = buffMission }) end doTest(function(entity) local buff = HealthBuff() local buffLevel = HealthBuffLevel() local buffMission = HealthBuffMission() local other = OtherComp() entity:Set(buff) entity:Set(buffLevel) entity:Set(buffMission) entity:Set(other) return buff, buffLevel, buffMission, other end) doTest(function(entity) local buff = HealthBuff() local buffLevel = HealthBuffLevel() local buffMission = HealthBuffMission() local other = OtherComp() entity[HealthBuff] = buff entity[HealthBuffLevel] = buffLevel entity[HealthBuffMission] = buffMission entity[OtherComp] = other return buff, buffLevel, buffMission, other end) doTest(function(entity) entity[HealthBuff] = {} entity[HealthBuffLevel] = {} entity[HealthBuffMission] = {} entity[OtherComp] = {} return entity[HealthBuff], entity[HealthBuffLevel] , entity[HealthBuffMission], entity[OtherComp] end) end ================================================ FILE: test/test_EntityRepository.lua ================================================ local lu = require('luaunit') local EntityRepository = require('EntityRepository') local function query(...) local archetypes = {...} local Query = {} function Query:Match(archetype) return table.find(archetypes, archetype) ~= nil end function Query:Result(chunks) return chunks end return Query end local function result(...) -- { ARCHETYPE_STORAGE<{[ENTITY]=true}>, ... } -- { { [ENTITY]=true } } local result = {} local archetypes = {} for i, entity in ipairs({...}) do if archetypes[entity.archetype] == nil then archetypes[entity.archetype] = {} table.insert(result, archetypes[entity.archetype]) end archetypes[entity.archetype][entity] = true end return result end TestEntityRepository = {} function TestEntityRepository:test_InsertRemoveUpdateQuery() local repo = EntityRepository.New() local ett_Foo_1 = { archetype = 'foo' } local ett_Foo_2 = { archetype = 'foo' } local ett_Bar_1 = { archetype = 'bar' } local ett_Bar_2 = { archetype = 'bar' } local ett_Baz_1 = { archetype = 'baz' } local ett_Baz_2 = { archetype = 'baz' } local q_Foo = query('foo') local q_Bar = query('bar') local q_Baz = query('baz') local q_Foo_Bar = query('foo', 'bar') local q_Foo_Baz = query('foo', 'baz') local q_Bar_Baz = query('bar', 'baz') local q_Foo_Bar_Baz = query('foo', 'bar', 'baz') repo:Insert(ett_Foo_1) repo:Insert(ett_Bar_1) repo:Insert(ett_Bar_2) repo:Insert(ett_Baz_1) lu.assertItemsEquals(repo:Query(q_Foo), result(ett_Foo_1)) lu.assertItemsEquals(repo:Query(q_Bar), result(ett_Bar_1, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Baz), result(ett_Baz_1)) lu.assertItemsEquals(repo:Query(q_Foo_Bar), result(ett_Foo_1, ett_Bar_1, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Baz), result(ett_Foo_1, ett_Baz_1)) lu.assertItemsEquals(repo:Query(q_Bar_Baz), result(ett_Bar_1, ett_Bar_2, ett_Baz_1)) lu.assertItemsEquals(repo:Query(q_Foo_Bar_Baz), result(ett_Foo_1, ett_Bar_1, ett_Bar_2, ett_Baz_1)) repo:Insert(ett_Foo_1) repo:Insert(ett_Foo_2) repo:Insert(ett_Bar_1) repo:Insert(ett_Bar_2) repo:Insert(ett_Baz_1) repo:Insert(ett_Baz_2) lu.assertItemsEquals(repo:Query(q_Foo), result(ett_Foo_1, ett_Foo_2)) lu.assertItemsEquals(repo:Query(q_Bar), result(ett_Bar_1, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Baz), result(ett_Baz_1, ett_Baz_2)) lu.assertItemsEquals(repo:Query(q_Foo_Bar), result(ett_Foo_1, ett_Foo_2, ett_Bar_1, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Baz), result(ett_Foo_1, ett_Foo_2, ett_Baz_1, ett_Baz_2)) lu.assertItemsEquals(repo:Query(q_Bar_Baz), result(ett_Bar_1, ett_Bar_2, ett_Baz_1, ett_Baz_2)) lu.assertItemsEquals(repo:Query(q_Foo_Bar_Baz), result(ett_Foo_1, ett_Foo_2, ett_Bar_1, ett_Bar_2, ett_Baz_1, ett_Baz_2)) repo:Remove(ett_Foo_1) repo:Remove(ett_Bar_1) repo:Remove(ett_Baz_1) repo:Remove(ett_Baz_2) repo:Remove('XPTO') lu.assertItemsEquals(repo:Query(q_Foo), result(ett_Foo_2)) lu.assertItemsEquals(repo:Query(q_Bar), result(ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Baz), result()) lu.assertItemsEquals(repo:Query(q_Foo_Bar), result(ett_Foo_2, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Baz), result(ett_Foo_2)) lu.assertItemsEquals(repo:Query(q_Bar_Baz), result(ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Bar_Baz), result(ett_Foo_2, ett_Bar_2)) -- update ett_Foo_2.archetype = 'bar' ett_Bar_2.archetype = 'baz' repo:Insert(ett_Foo_2) repo:Update(ett_Bar_2) lu.assertItemsEquals(repo:Query(q_Foo), result()) lu.assertItemsEquals(repo:Query(q_Bar), result(ett_Foo_2)) lu.assertItemsEquals(repo:Query(q_Baz), result(ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Bar), result(ett_Foo_2)) lu.assertItemsEquals(repo:Query(q_Foo_Baz), result(ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Bar_Baz), result(ett_Foo_2, ett_Bar_2)) lu.assertItemsEquals(repo:Query(q_Foo_Bar_Baz), result(ett_Foo_2, ett_Bar_2)) end ================================================ FILE: test/test_Event.lua ================================================ local lu = require('luaunit') local Event = require('Event') TestEvent = {} function TestEvent:test_ConnectFireDisconnectDestroy() local event = Event.New() local Object1 = {} local Object2 = {Object1} local Calls = { [1] = 0, [2] = 0 } local conn1 = event:Connect(function(obj1, obj2) lu.assertEquals(obj1, Object1) lu.assertEquals(obj2, Object2) Calls[1] = Calls[1] + 1 end) local conn2 = event:Connect(function(obj1, obj2) lu.assertIsTrue(obj1 == Object1) lu.assertIsTrue(obj2 == Object2) Calls[2] = Calls[2] + 1 end) event:Fire(Object1, Object2) lu.assertEquals(Calls[1], 1) lu.assertEquals(Calls[2], 1) -- Disconnect conn1:Disconnect() event:Fire(Object1, Object2) lu.assertEquals(Calls[1], 1) lu.assertEquals(Calls[2], 2) lu.assertError(function() event:Connect("INVALID") end) event:Destroy() conn2:Disconnect() end ================================================ FILE: test/test_Query.lua ================================================ local lu = require('luaunit') local Query = require('Query') local Archetype = require('Archetype') local Component = require('Component') local Comp_A = Component.Create() local Comp_B = Component.Create() local Comp_B_QL = Comp_B.Qualifier("Specialized") local Comp_FSM = Component.Create() local Comp_FSM_QL = Comp_FSM.Qualifier("Specialized") Comp_FSM.States = { Standing = "*", Walking = "*", Running = "*" } local archetype_A = Archetype.Of({Comp_A}) local archetype_B = Archetype.Of({Comp_B}) local archetype_A_B = Archetype.Of({Comp_A, Comp_B}) local archetype_B_QL = Archetype.Of({Comp_B_QL}) local archetype_FSM = Archetype.Of({Comp_FSM}) local archetype_FSM_QL = Archetype.Of({Comp_FSM_QL}) TestQuery = {} function TestQuery:test_Match() -- all local all_A = Query({ Comp_A }) local all_A_B = Query({ Comp_A, Comp_B }) local all_B_QL = Query.All(Comp_B_QL).Build() lu.assertIsTrue(all_A:Match(archetype_A)) lu.assertIsTrue(all_A_B:Match(archetype_A_B)) lu.assertIsTrue(all_B_QL:Match(archetype_B_QL)) lu.assertIsFalse(all_A_B:Match(archetype_FSM)) -- all (cache result) lu.assertIsTrue(all_A:Match(archetype_A)) lu.assertIsTrue(all_A_B:Match(archetype_A_B)) lu.assertIsTrue(all_B_QL:Match(archetype_B_QL)) lu.assertIsFalse(all_A_B:Match(archetype_FSM)) -- any local any_A = Query(nil, { Comp_A }) local any_B = Query(nil, { Comp_B }) local any_A_B = Query.Any(Comp_A, Comp_B).Build() lu.assertIsTrue(any_A:Match(archetype_A_B)) lu.assertIsTrue(any_B:Match(archetype_A_B)) lu.assertIsTrue(any_A_B:Match(archetype_A_B)) lu.assertIsFalse(any_A_B:Match(archetype_FSM)) -- any (cache result) lu.assertIsTrue(any_A:Match(archetype_A_B)) lu.assertIsTrue(any_B:Match(archetype_A_B)) lu.assertIsTrue(any_A_B:Match(archetype_A_B)) lu.assertIsFalse(any_A_B:Match(archetype_FSM)) -- none local none_A = Query(nil, nil, { Comp_A }) local none_B = Query.None(Comp_B).Build() lu.assertIsFalse(none_A:Match(archetype_A_B)) lu.assertIsFalse(none_B:Match(archetype_A_B)) -- none (cache result) lu.assertIsFalse(none_A:Match(archetype_A_B)) lu.assertIsFalse(none_B:Match(archetype_A_B)) -- clause local all_A = Query({ Comp_FSM.In("Standing") }) lu.assertIsTrue(all_A:Match(archetype_FSM)) lu.assertIsTrue(all_A:Match(archetype_FSM_QL)) -- result lu.assertNotIsNil(all_A:Result({})) end ================================================ FILE: test/test_QueryResult.lua ================================================ local lu = require('luaunit') local Entity = require('Entity') local Archetype = require('Archetype') local Component = require('Component') local QueryResult = require('QueryResult') local Comp_A = Component.Create() local Comp_B = Component.Create() local Comp_B_Ql = Comp_B.Qualifier("Specialized") local Comp_FSM = Component.Create() local Comp_FSM_2 = Component.Create() local Comp_FSM_2_Ql = Comp_FSM_2.Qualifier("Specialized") local Comp_Other = Component.Create() Comp_FSM.States = { Standing = "*", Walking = "*", Running = "*" } Comp_FSM_2.States = { Standing = "*", Walking = "*", Running = "*" } local comp_a = Comp_A() local comp_b = Comp_B() local comp_b_ql = Comp_B_Ql() local comp_fsm_Standing = Comp_FSM() local comp_fsm_Walking = Comp_FSM() local comp_fsm_Running = Comp_FSM() local comp_fsm_2_Standing = Comp_FSM_2() local comp_fsm_2_Walking = Comp_FSM_2() local comp_fsm_2_Running = Comp_FSM_2() local comp_fsm_2_ql_Standing = Comp_FSM_2_Ql() local comp_fsm_2_ql_Walking = Comp_FSM_2_Ql() local comp_fsm_2_ql_Running = Comp_FSM_2_Ql() comp_fsm_Standing:SetState("Standing") comp_fsm_Walking:SetState("Walking") comp_fsm_Running:SetState("Running") comp_fsm_2_Standing:SetState("Standing") comp_fsm_2_Walking:SetState("Walking") comp_fsm_2_Running:SetState("Running") comp_fsm_2_ql_Standing:SetState("Standing") comp_fsm_2_ql_Walking:SetState("Walking") comp_fsm_2_ql_Running:SetState("Running") local entity_A = Entity.New(nil, {comp_a}) local entity_B = Entity.New(nil, {comp_b}) local entity_B_QL = Entity.New(nil, {comp_b_ql}) local entity_FSM_Standing = Entity.New(nil, {comp_fsm_Standing}) local entity_FSM_Walking = Entity.New(nil, {comp_fsm_Walking}) local entity_FSM_Running = Entity.New(nil, {comp_fsm_Running}) local entity_FSM_2_Standing = Entity.New(nil, {comp_fsm_2_Standing}) local entity_FSM_2_Walking = Entity.New(nil, {comp_fsm_2_Walking}) local entity_FSM_2_Running = Entity.New(nil, {comp_fsm_2_Running}) local entity_FSM_2_ql_Standing = Entity.New(nil, {comp_fsm_2_ql_Standing}) local entity_FSM_2_ql_Walking = Entity.New(nil, {comp_fsm_2_ql_Walking}) local entity_FSM_2_ql_Running = Entity.New(nil, {comp_fsm_2_ql_Running}) -- TO DEBUG entity_A.Name = "entity_A" entity_B.Name = "entity_B" entity_B_QL.Name = "entity_B_QL" entity_FSM_Standing.Name = "entity_FSM_Standing" entity_FSM_Walking.Name = "entity_FSM_Walking" entity_FSM_Running.Name = "entity_FSM_Running" entity_FSM_2_Standing.Name = "entity_FSM_2_Standing" entity_FSM_2_Walking.Name = "entity_FSM_2_Walking" entity_FSM_2_Running.Name = "entity_FSM_2_Running" entity_FSM_2_ql_Standing.Name = "entity_FSM_2_ql_Standing" entity_FSM_2_ql_Walking.Name = "entity_FSM_2_ql_Walking" entity_FSM_2_ql_Running.Name = "entity_FSM_2_ql_Running" -- { ARCHETYPE_STORAGE<{[ENTITY]=true}>, ... } local chunks = { { [entity_A] = true }, { [entity_B] = true }, { [entity_B_QL] = true }, { [entity_FSM_Standing] = true, [entity_FSM_Walking] = true, [entity_FSM_Running] = true }, { [entity_FSM_2_Standing] = true, [entity_FSM_2_Walking] = true, [entity_FSM_2_Running] = true }, { [entity_FSM_2_ql_Standing] = true, [entity_FSM_2_ql_Walking] = true, [entity_FSM_2_ql_Running] = true } } TestQueryResult = {} function TestQueryResult:test_ToArray() local result = QueryResult.New(chunks) local array = result:ToArray() lu.assertItemsEquals(array, { entity_A, entity_B, entity_B_QL, entity_FSM_Standing, entity_FSM_Walking, entity_FSM_Running, entity_FSM_2_Standing, entity_FSM_2_Walking, entity_FSM_2_Running, entity_FSM_2_ql_Standing, entity_FSM_2_ql_Walking, entity_FSM_2_ql_Running }) end function TestQueryResult:test_Iterator() local result = QueryResult.New(chunks) local entities = {} local indexes = {} for count, entity in result:Iterator() do table.insert(entities, entity) table.insert(indexes, count) end lu.assertEquals(indexes, {1,2,3,4,5,6,7,8,9,10,11,12}) lu.assertItemsEquals(entities, { entity_A, entity_B, entity_B_QL, entity_FSM_Standing, entity_FSM_Walking, entity_FSM_Running, entity_FSM_2_Standing, entity_FSM_2_Walking, entity_FSM_2_Running, entity_FSM_2_ql_Standing, entity_FSM_2_ql_Walking, entity_FSM_2_ql_Running }) end local function spy(method, callback) return function(...) return callback(method, table.unpack({...})) end end function TestQueryResult:test_AnyMatch_AllMatch_FindAny() local result = QueryResult.New(chunks) lu.assertIsTrue(result:AnyMatch(function(entity) return entity.archetype == entity_FSM_Standing.archetype end)) local count = 0 lu.assertIsFalse(result:AnyMatch(function(entity) count = count + 1 return entity.archetype == Archetype.EMPTY end)) lu.assertEquals(count, 12) lu.assertIsFalse(result:AllMatch(function(entity) return entity.archetype == entity_FSM_Standing.archetype end)) -- short-circuiting terminal local count = 0 lu.assertIsFalse(result:AllMatch(function(entity) count = count + 1 return entity.archetype == Archetype.EMPTY end)) lu.assertEquals(count, 1) -- short-circuiting terminal local count = 0 result.ForEach = spy(result.ForEach, function(forEachOriginal, result, callback) return forEachOriginal(result, function(value) count = count + 1 return callback(value, count) end) end) lu.assertNotIsNil(result:FindAny()) lu.assertEquals(count, 1) end function TestQueryResult:test_ForEach() local result = QueryResult.New(chunks) local count = 0 result:ForEach(function(entity) count = count + 1 end) lu.assertEquals(count, 12) -- break count = 0 result:ForEach(function(entity) count = count + 1 if count == 5 then return true end end) lu.assertEquals(count, 5) end function TestQueryResult:test_Filter_Map_Limit() local result = QueryResult.New(chunks) lu.assertIsFalse( result :Filter(function(entity) return entity.archetype == Archetype.EMPTY end) :AnyMatch(function(entity) return entity.archetype == entity_FSM_Standing.archetype end) ) lu.assertIsTrue( result :Filter(function(entity) return entity.archetype == entity_FSM_Standing.archetype end) :AnyMatch(function(entity) return entity == entity_FSM_Walking end) ) lu.assertItemsEquals( result :Filter(function(entity) return entity.archetype == entity_FSM_Standing.archetype end) :ToArray(), { entity_FSM_Standing, entity_FSM_Walking, entity_FSM_Running } ) lu.assertEquals( result :Filter(function(entity) return entity.archetype == Archetype.EMPTY end) :ToArray(), {} ) lu.assertItemsEquals( result :Filter(function(entity) return entity.archetype == entity_FSM_Standing.archetype end) :Map(function(entity) return entity[Comp_FSM]:GetState() end) :ToArray(), { "Standing", "Walking", "Running" } ) lu.assertEquals( #( result :Filter(function(entity) return entity.archetype == entity_FSM_Standing.archetype end) :Limit(2) :ToArray() ), 2 ) lu.assertEquals( #(result:Limit(8):ToArray()), 8 ) -- total = 12 lu.assertEquals( #(result:Limit(20):ToArray()), 12 ) end function TestQueryResult:test_Clauses() local clause_none_walking_running = Comp_FSM.In("Walking", "Running") clause_none_walking_running.IsNoneFilter = true local result = QueryResult.New(chunks, {clause_none_walking_running}) lu.assertItemsEquals(result:ToArray(), { entity_A, entity_B, entity_B_QL, entity_FSM_Standing, entity_FSM_2_Standing, entity_FSM_2_Walking, entity_FSM_2_Running, entity_FSM_2_ql_Standing, entity_FSM_2_ql_Walking, entity_FSM_2_ql_Running }) local clause_any_standing_running = Comp_FSM.In("Standing", "Running") clause_any_standing_running.IsAnyFilter = true local clause_any_walking_running_2 = Comp_FSM_2.In("Walking", "Running") clause_any_walking_running_2.IsAnyFilter = true local result = QueryResult.New(chunks, { clause_any_standing_running, clause_any_walking_running_2 }) lu.assertItemsEquals(result:ToArray(), { entity_FSM_Standing, entity_FSM_Running, entity_FSM_2_Walking, entity_FSM_2_Running, entity_FSM_2_ql_Walking, entity_FSM_2_ql_Running, }) local clause_any_standing_running = Comp_FSM.In("Standing", "Running") clause_any_standing_running.IsAnyFilter = true local clause_any_walking_running_2 = Comp_FSM_2.In("Walking", "Running") clause_any_walking_running_2.IsAnyFilter = true local clause_none_walking_running_2_ql = Comp_FSM_2_Ql.In("Walking", "Running") clause_none_walking_running_2_ql.IsNoneFilter = true local result = QueryResult.New(chunks, { clause_any_standing_running, clause_any_walking_running_2, clause_none_walking_running_2_ql }) lu.assertItemsEquals(result:ToArray(), { entity_FSM_Standing, entity_FSM_Running, entity_FSM_2_Walking, entity_FSM_2_Running }) local clause_any_walking_running_2_ql = Comp_FSM_2_Ql.In("Walking", "Running") clause_any_walking_running_2_ql.IsAnyFilter = true local result = QueryResult.New(chunks, { clause_any_standing_running, clause_any_walking_running_2_ql }) lu.assertItemsEquals(result:ToArray(), { entity_FSM_Standing, entity_FSM_Running, entity_FSM_2_ql_Walking, entity_FSM_2_ql_Running, }) local clause_all_standing_running = Comp_FSM.In("Standing", "Running") clause_all_standing_running.IsAllFilter = true local result = QueryResult.New(chunks, {clause_all_standing_running}) lu.assertItemsEquals(result:ToArray(), { entity_FSM_Standing, entity_FSM_Running }) end ================================================ FILE: test/test_SystemExecutor.lua ================================================ local lu = require('luaunit') local World = require('World') local Query = require('Query') local System = require('System') local SystemExecutor = require('SystemExecutor') TestSystemExecutor = {} function TestSystemExecutor:test_ExecProcessTransformRender() local steps = { render = 'ExecRender', process = 'ExecProcess', transform = 'ExecTransform' } for step, method in pairs(steps) do local world = World.New() world.version = 10 local log = {} local system1 = { Step = step, Order = 1, Update = function() table.insert(log, 1) end, version = 0, ShouldUpdate = function() return true end } local system2 = { Step = step, Order = 2, Update = function() table.insert(log, 2) end, version = 0, ShouldUpdate = function() return false end } local system3 = { Step = step, Order = 3, Update = function() table.insert(log, 3) end, version = 0, ShouldUpdate = function() return true end } local executor = SystemExecutor.New(world) executor:SetSystems({system1, system2, system3}) executor[method](executor, {}) lu.assertEquals(log, {1, 3}) lu.assertEquals(system1.version, 11) lu.assertEquals(system2.version, 0) lu.assertEquals(system3.version, 12) world:Destroy() end end function TestSystemExecutor:test_Coroutine() local log = {} local value = 0 local co = coroutine.create(function () -- https://github.com/wahern/cqueues/issues/231#issuecomment-562838785 local i, len = 0, 10000-1 -- while i <= len do -- i = i + 1 -- if i%1000 == 0 then -- print( "Before yield", i, value) -- coroutine.yield() -- print( "After yield", i, value) -- end -- end for i=1,3000 do if i%1000 == 0 then table.insert(log, i) table.insert(log, value) coroutine.yield() table.insert(log, value) end end end) coroutine.resume(co) lu.assertEquals(log, {1000, 0}) value = 1 coroutine.resume(co) lu.assertEquals(log, {1000, 0, 1, 2000, 1}) value = 2 coroutine.resume(co) lu.assertEquals(log, {1000, 0, 1, 2000, 1, 2, 3000, 2}) value = 3 coroutine.resume(co) lu.assertEquals(log, {1000, 0, 1, 2000, 1, 2, 3000, 2, 3}) value = 3 end function TestSystemExecutor:test_ExecTasks() local steps = { render = 'ExecRender', process = 'ExecProcess', transform = 'ExecTransform' } local world = World.New() world.version = 10 local log = {} local logBeforeYield = {} local logAfterYield = {} local Task_A = System.Create('task', function() -- delay execution to ensure test flow local i = 0 while i <= 4000 do i = i + 1 if i%1000 == 0 then table.insert(logBeforeYield, i) coroutine.yield() table.insert(logAfterYield, i+1) end end table.insert(log, 'A') lu.assertEquals(logBeforeYield, {1000, 2000, 3000, 4000}) lu.assertEquals(logAfterYield, {1001, 2001, 3001, 4001}) end) local Task_B = System.Create('task', function() table.insert(log, 'B') end) local Task_C = System.Create('task', function() table.insert(log, 'C') end) local Task_D = System.Create('task', function() table.insert(log, 'D') end) local Task_E = System.Create('task', function() table.insert(log, 'E') end) local Task_F = System.Create('task', function() table.insert(log, 'F') end) local Task_G = System.Create('task', function() table.insert(log, 'G') end) local Task_H = System.Create('task', function(self) table.insert(log, 'H') end) --[[ ┌─┐ ┌─┐ ┌─┐ │A│◄─────┤C│◄────┬──────┤F│◄────┐ └─┘ └┬┘ │ └┬┘ │ │ ┌┴┐ │ ┌┴┐ ┌────┘ │E│◄─────┘ │H│ │ └┬┘ └┬┘ │ │ │ ┌─┐ │ ┌─┐ │ ┌─┐ │ │B│◄─┴───┤D│◄────┴──────┤G│◄────┘ └─┘ └─┘ └─┘ A - has no dependency B - has no dependency C - Depends on A,B D - Depends on B E - Depends on A,B,C,D F - Depends on A,B,C,D,E G - Depends on B,D H - Depends on A,B,C,D,E,F,G Completion order will be B,D,G,A,C,E,F,H ]] Task_A.Before = {Task_C} Task_B.Before = {Task_D} Task_C.After = {Task_B} Task_D.Before = {Task_G} Task_F.After = {Task_E} Task_E.After = {Task_D, Task_C} Task_C.Before = {Task_F} Task_H.After = {Task_F, Task_G} local task_a = Task_A.New(world, {}) local task_b = Task_B.New(world, {}) local task_c = Task_C.New(world, {}) local task_d = Task_D.New(world, {}) local task_e = Task_E.New(world, {}) local task_f = Task_F.New(world, {}) local task_g = Task_G.New(world, {}) local task_h = Task_H.New(world, {}) local executor = SystemExecutor.New(world) executor:SetSystems({task_a, task_b, task_c, task_d, task_e, task_f, task_g, task_h}) executor:ScheduleTasks({}) local MaxExecutionTime = 0.5 executor:ExecTasks(MaxExecutionTime) lu.assertEquals(log, {'B','D','G','A','C','E','F','H'}) lu.assertEquals(logBeforeYield, {1000, 2000, 3000, 4000}) lu.assertEquals(logAfterYield, {1001, 2001, 3001, 4001}) world:Destroy() end function TestSystemExecutor:test_ExecOnEnter() local world = World.New() world.version = 10 local changedEntities = { [{id = 1, archetype='Arch_1'}] = 'Arch_0', [{id = 2, archetype='Arch_1'}] = 'Arch_0', [{id = 3, archetype='Arch_2'}] = 'Arch_1', [{id = 4, archetype='Arch_3'}] = 'Arch_1', [{id = 5, archetype='Arch_3'}] = 'Arch_3', [{id = 6, archetype='Arch_3'}] = 'Arch_0', } local log = { Arch_1 = {}, Arch_2 = {}, Arch_3 = {}, } local logExpected = { Arch_1 = { ["1_1"] = true, ["1_2"] = true}, Arch_2 = {}, Arch_3 = { ["3_4"] = true, ["3_6"] = true}, } local systemArch1 = { Step = 'process', Order = 1, OnEnter = function(s, Time, entity) log.Arch_1["1_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_1' end } } local systemNoQuery = { Step = 'process', Order = 2, OnEnter = function(s, Time, entity) log.Arch_1["2_"..entity.id] = true end, version = 0 } local systemArch3 = { Step = 'process', Order = 3, OnEnter = function(s, Time, entity) log.Arch_3["3_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_3' end } } local executor = SystemExecutor.New(world) executor:SetSystems({systemArch1, systemNoQuery, systemArch3}) executor:ExecOnExitEnter({}, changedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 12) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 14) lu.assertEquals(world.version, 14) executor:ExecOnExitEnter({}, changedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) executor:ExecOnExitEnter({}, {}) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) world:Destroy() end function TestSystemExecutor:test_ExecOnExit() local world = World.New() world.version = 10 local changedEntities = { [{id = 1, archetype='Arch_1'}] = 'Arch_0', [{id = 2, archetype='Arch_1'}] = 'Arch_3', [{id = 3, archetype='Arch_2'}] = 'Arch_1', [{id = 4, archetype='Arch_3'}] = 'Arch_1', [{id = 5, archetype='Arch_2'}] = 'Arch_3', [{id = 6, archetype='Arch_3'}] = 'Arch_3', } local log = { Arch_1 = {}, Arch_2 = {}, Arch_3 = {}, } local logExpected = { Arch_1 = { ["1_3"] = true, ["1_4"] = true}, Arch_2 = {}, Arch_3 = { ["3_2"] = true, ["3_5"] = true}, } local systemArch1 = { Step = 'process', Order = 1, OnExit = function(s, Time, entity) log.Arch_1["1_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_1' end } } local systemNoQuery = { Step = 'process', Order = 2, OnExit = function(s, Time, entity) log.Arch_1["2_"..entity.id] = true end, version = 0 } local systemArch3 = { Step = 'process', Order = 3, OnExit = function(s, Time, entity) log.Arch_3["3_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_3' end } } local executor = SystemExecutor.New(world) executor:SetSystems({systemArch1, systemNoQuery, systemArch3}) executor:ExecOnExitEnter({}, changedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 12) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 14) lu.assertEquals(world.version, 14) executor:ExecOnExitEnter({}, changedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) executor:ExecOnExitEnter({}, {}) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) world:Destroy() end function TestSystemExecutor:test_ExecOnRemove() local world = World.New() world.version = 10 local removedEntities = { [{id = 1, archetype='Arch_1'}] = 'Arch_0', [{id = 2, archetype='Arch_1'}] = 'Arch_0', [{id = 3, archetype='Arch_2'}] = 'Arch_1', [{id = 4, archetype='Arch_2'}] = 'Arch_1', [{id = 5, archetype='Arch_0'}] = 'Arch_3', [{id = 6, archetype='Arch_0'}] = 'Arch_3', } local log = { Arch_1 = {}, Arch_2 = {}, Arch_3 = {}, } local logExpected = { Arch_1 = { ["1_3"] = true, ["1_4"] = true}, Arch_2 = {}, Arch_3 = { ["3_5"] = true, ["3_6"] = true}, } local systemArch1 = { Step = 'process', Order = 1, OnRemove = function(s, Time, entity) log.Arch_1["1_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_1' end } } local systemNoQuery = { Step = 'process', Order = 2, OnRemove = function(s, Time, entity) log.Arch_1["2_"..entity.id] = true end, version = 0 } local systemArch3 = { Step = 'process', Order = 3, OnRemove = function(s, Time, entity) log.Arch_3["3_"..entity.id] = true end, version = 0, Query = { isQuery = true, Match = function(s, archetypeNew) return archetypeNew == 'Arch_3' end } } local executor = SystemExecutor.New(world) executor:SetSystems({systemArch1, systemNoQuery, systemArch3}) executor:ExecOnRemove({}, removedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 12) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 14) lu.assertEquals(world.version, 14) executor:ExecOnRemove({}, removedEntities) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) executor:ExecOnRemove({}, {}) lu.assertEquals(log, logExpected) lu.assertEquals(systemArch1.version, 16) lu.assertEquals(systemNoQuery.version, 0) lu.assertEquals(systemArch3.version, 18) lu.assertEquals(world.version, 18) world:Destroy() end ================================================ FILE: test/test_World.lua ================================================ local lu = require('luaunit') local World = require('World') local Query = require('Query') local System = require('System') local Component = require('Component') -- mock World.LoopManager = { Register = function(world) return function () end end } TestWorld = {} function TestWorld:test_SystemWhitoutQuery() local Comp_A = Component.Create(0) local Comp_B = Component.Create(0) local Comp_C = Component.Create(0) local Comp_D = Component.Create(0) local calledInitialize = 0 local calledShouldUpdate = 0 local calledUpdate = 0 local calledOnEnter = 0 local calledOnExit = 0 local calledOnRemove = 0 local calledOnDestroy = 0 local systemConfig = { value = 1 } lu.assertError(function() -- empty step System.Create() end) lu.assertError(function() -- invalid step System.Create('xpto') end) local System_A = System.Create('process', 1, function() calledUpdate = calledUpdate + 1 end) function System_A:Initialize() calledInitialize = calledInitialize + 1 lu.assertEquals(self._config, systemConfig) lu.assertEquals(self.GetType(), System_A) end function System_A:ShouldUpdate(config) calledShouldUpdate = calledShouldUpdate + 1 return true end function System_A:OnEnter(Time, entity) calledOnEnter = calledOnEnter + 1 end function System_A:OnExit(Time, entity) calledOnExit = calledOnExit + 1 end function System_A:OnRemove(Time, entity) calledOnRemove = calledOnRemove + 1 end function System_A:OnDestroy() calledOnDestroy = calledOnDestroy + 1 end local System_B = System.Create('transform', -100, Query.All(Comp_B)) function System_B:OnEnter(Time, entity) calledOnEnter = calledOnEnter + 1 end function System_B:OnExit(Time, entity) calledOnExit = calledOnExit + 1 end function System_B:OnRemove(Time, entity) calledOnRemove = calledOnRemove + 1 end function System_B:OnDestroy() calledOnDestroy = calledOnDestroy + 1 end local world = World.New({System_B}, 60) world:AddSystem(System_A, systemConfig) local entityC = world:Entity(Comp_C(1)) local System_C = System.Create('transform', 20, Query.All(Comp_C)) function System_C:Update(Time) calledUpdate = calledUpdate + 1 lu.assertItemsEquals(self:Result():ToArray(), {entityC}) end world:AddSystem(System_C) lu.assertEquals(world:GetFrequency() , 60) world:SetFrequency(30) lu.assertEquals(world:GetFrequency() , 30) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 0) lu.assertEquals(calledUpdate, 0) lu.assertEquals(calledOnEnter, 0) lu.assertEquals(calledOnExit, 0) lu.assertEquals(calledOnRemove, 0) world:Update('process', 0.0334 * 1) world:Update('transform', 0.0335 * 1 + 0.001) world:Update('render', 0.0336 * 1 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 1) lu.assertEquals(calledUpdate, 2) lu.assertEquals(calledOnEnter, 0) lu.assertEquals(calledOnExit, 0) lu.assertEquals(calledOnRemove, 0) local entityA = world:Entity(Comp_A(1)) world:Update('process', 0.0334 * 2) world:Update('transform', 0.0335 * 2 + 0.001) world:Update('render', 0.0336 * 2 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 2) lu.assertEquals(calledUpdate, 4) lu.assertEquals(calledOnEnter, 0) lu.assertEquals(calledOnExit, 0) lu.assertEquals(calledOnRemove, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {entityA}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {}) local entityB = world:Entity(Comp_B(1)) world:Update('process', 0.0334 * 3) local entityD = world:Entity(Comp_D(1)) world:Update('transform', 0.0335 * 3 + 0.001) world:Update('render', 0.0336 * 3 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 3) lu.assertEquals(calledUpdate, 6) lu.assertEquals(calledOnEnter, 1) lu.assertEquals(calledOnExit, 0) lu.assertEquals(calledOnRemove, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {entityA}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {entityB}) entityA[Comp_B] = {value = 2} world:Update('process', 0.0334 * 4) world:Update('transform', 0.0335 * 4 + 0.001) world:Update('render', 0.0336 * 4 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 4) lu.assertEquals(calledUpdate, 8) lu.assertEquals(calledOnEnter, 2) lu.assertEquals(calledOnExit, 0) lu.assertEquals(calledOnRemove, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {entityA}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {entityA, entityB}) entityA[Comp_B] = nil world:Update('process', 0.0334 * 5) world:Update('transform', 0.0335 * 5 + 0.001) world:Update('render', 0.0336 * 5 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 5) lu.assertEquals(calledUpdate, 10) lu.assertEquals(calledOnEnter, 2) lu.assertEquals(calledOnExit, 1) lu.assertEquals(calledOnRemove, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {entityA}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {entityB}) world:Remove(entityA) world:Remove(entityA) -- no result world:Update('process', 0.0334 * 6) world:Update('transform', 0.0335 * 6 + 0.001) world:Update('render', 0.0336 * 6 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 6) lu.assertEquals(calledUpdate, 12) lu.assertEquals(calledOnEnter, 2) lu.assertEquals(calledOnExit, 1) lu.assertEquals(calledOnRemove, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {entityB}) world:Remove(entityB) world:Update('process', 0.0334 * 7) world:Update('transform', 0.0335 * 7 + 0.001) world:Update('render', 0.0336 * 7 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 7) lu.assertEquals(calledUpdate, 14) lu.assertEquals(calledOnEnter, 2) lu.assertEquals(calledOnExit, 1) lu.assertEquals(calledOnRemove, 1) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {}) -- add and remove before update local entityB = world:Entity(Comp_B(1)) world:Remove(entityB) world:Update('process', 0.0334 * 8) world:Update('transform', 0.0335 * 8 + 0.001) world:Update('render', 0.0336 * 8 + 0.002) lu.assertEquals(calledInitialize, 1) lu.assertEquals(calledShouldUpdate, 8) lu.assertEquals(calledUpdate, 16) lu.assertEquals(calledOnEnter, 2) lu.assertEquals(calledOnExit, 1) lu.assertEquals(calledOnRemove, 1) lu.assertEquals(calledOnDestroy, 0) lu.assertItemsEquals(world:Exec(Query.All(Comp_A)):ToArray(), {}) lu.assertItemsEquals(world:Exec(Query.All(Comp_B)):ToArray(), {}) world:Destroy() lu.assertEquals(calledOnDestroy, 2) end function TestWorld:test_ExecTasks() local log = {} local logBeforeYield = {} local logAfterYield = {} local Task_A = System.Create('task', function() -- delay execution to ensure test flow local i = 0 while i <= 4000 do i = i + 1 if i%1000 == 0 then table.insert(logBeforeYield, i) coroutine.yield() table.insert(logAfterYield, i+1) end end table.insert(log, 'A') lu.assertEquals(logBeforeYield, {1000, 2000, 3000, 4000}) lu.assertEquals(logAfterYield, {1001, 2001, 3001, 4001}) end) local Task_B = System.Create('task', function() table.insert(log, 'B') end) local Task_C = System.Create('task', function() table.insert(log, 'C') end) local Task_D = System.Create('task', function() table.insert(log, 'D') end) local Task_E = System.Create('task', function() table.insert(log, 'E') end) local Task_F = System.Create('task', function() table.insert(log, 'F') end) local Task_G = System.Create('task', function() table.insert(log, 'G') end) local Task_H = System.Create('task', function(self) table.insert(log, 'H') end) --[[ ┌─┐ ┌─┐ ┌─┐ │A│◄─────┤C│◄────┬──────┤F│◄────┐ └─┘ └┬┘ │ └┬┘ │ │ ┌┴┐ │ ┌┴┐ ┌────┘ │E│◄─────┘ │H│ │ └┬┘ └┬┘ │ │ │ ┌─┐ │ ┌─┐ │ ┌─┐ │ │B│◄─┴───┤D│◄────┴──────┤G│◄────┘ └─┘ └─┘ └─┘ A - has no dependency B - has no dependency C - Depends on A,B D - Depends on B E - Depends on A,B,C,D F - Depends on A,B,C,D,E G - Depends on B,D H - Depends on A,B,C,D,E,F,G Completion order will be B,D,G,A,C,E,F,H ]] Task_A.Before = {Task_C} Task_B.Before = {Task_D} Task_C.After = {Task_B} Task_D.Before = {Task_G} Task_F.After = {Task_E} Task_E.After = {Task_D, Task_C} Task_C.Before = {Task_F} Task_H.After = {Task_F, Task_G} local world = World.New({ Task_A, Task_B, Task_C, Task_D, Task_E, Task_F, Task_G, Task_H}) world:SetFrequency(30) -- world.version = 10 world:Update('process', 0.0334 * 1) world:Update('transform', 0.0335 * 1 + 0.001) world:Update('transform', 0.0335 * 2 + 0.001) world:Update('transform', 0.0335 * 4 + 0.001) world:Update('transform', 0.0335 * 5 + 0.001) world:Update('transform', 0.0335 * 6 + 0.001) lu.assertEquals(log, {'B','D','G','A','C','E','F','H'}) lu.assertEquals(logBeforeYield, {1000, 2000, 3000, 4000}) lu.assertEquals(logAfterYield, {1001, 2001, 3001, 4001}) world:Destroy() end ================================================ FILE: test.lua ================================================ package.path = package.path .. ";modules/?.lua" package.path = package.path .. ";src/?.lua" local lu = require("luaunit") local luacov = require("luacov") -- tests require("test/test_Archetype") require("test/test_Component") -- require("test/test_ECS") require("test/test_Entity") require("test/test_EntityRepository") require("test/test_Event") require("test/test_Query") require("test/test_QueryResult") -- require("test/test_RobloxLoopManager") -- require("test/test_System") require("test/test_SystemExecutor") -- require("test/test_Timer") -- require("test/test_Utility") require("test/test_World") os.exit(lu.LuaUnit.run())