ecs refactor: added commit() to signal systems

This commit is contained in:
Leon van Kammen 2026-06-10 22:58:48 +02:00
parent c4bb52b896
commit 46c21d041e
12 changed files with 399 additions and 125 deletions

View file

@ -1,14 +1,15 @@
-- sync lovr/love positional cli arguments
if arg[0] == nil and arg[1] ~= nil then arg[0] = arg[1] end
package.path = package.path .. ';' ..
arg[0] .. '/lovr/?.lua;' ..
arg[0] .. '/lovr/?/init.lua;' ..
arg[0] .. '/lib/?.lua'
local runtime = ""
if lovr ~= nil then runtime = "lovr" end
if love ~= nil then runtime = "love" end
package.path = package.path .. ';' ..
arg[0] .. '/' .. runtime .. '/?.lua;' ..
arg[0] .. '/' .. runtime .. '/?/init.lua;' ..
arg[0] .. '/lib/?.lua'
require( runtime .. "/conf")

View file

@ -1,37 +1,14 @@
local api = ...
local ecs = require("tiny-ecs")
ecs.initers = {
renderer = function()
local renderer = ecs.processingSystem()
renderer.filter = ecs.requireAll('type') -- *TODO* might need filter later
function renderer:process(obj, pass)
local type = obj:type()
if type == "Model" then
pass:setMaterial()
pass:setCullMode('none')
pass:draw(obj, 0, 0, 0, 1, 0, 1, 0, 0, 1)
end
end
ecs.addSystem( ecs.world, renderer )
end
}
ecs.enabled = true
ecs.world = ecs.world()
ecs.world.update = ecs.update -- swap with extension update-func
ecs.init = function()
ecs.initers.renderer()
end
ecs.update = function(dt)
--ecs.world.update( ecs.world, dt ) -- *TODO* different filter
end
ecs.draw = function(pass)
local opts = {
ecs.world.update( ecs.world, pass )
print("[i] loading ecs")
baseEntify = ecs.processingSystem()
baseEntify.filter = ecs.rejectAll('commit')
baseEntify.updatethread = true
function baseEntify:onAdd(obj)
obj.commit = api.util.commit( api.world, api.ecs.requireAll('allowcommit') )
end
ecs.addSystem( api.world, baseEntify )
end
return ecs

View file

@ -5,28 +5,22 @@ return {
name = "3DFile",
enabled = true,
init = function()
modelLoader = ecs.processingSystem()
modelLoader.updatesystem = true
modelLoader.filter = ecs.requireAll('URL', 'method', 'data', 'ok')
function modelLoader:process(req, dt)
if req.ok and req.model == nil then
if req.URL.extension == 'OBJ' or
req.URL.extension == 'GLB' or
req.URL.extension == 'GLTF' then
req.model = api.graphics.newModel( api.data.newBlob(req.data) )
api.world.add({
x = 0,
y = 0,
z = 0,
model = req.model,
req = req
})
end
loadasset = function(obj)
if( obj.URL ~= nil and obj.URLResponse ~= nil and
(obj.URL.extension == 'GLB' or
obj.URL.extension == 'GLTF' or
obj.URL.extension == 'OBJ')) then
if obj.URLResponse.ok then
obj.model = api.graphics.newModel( api.data.newBlob( obj.URLResponse.data) )
obj.root = (obj.URI.target == '_top')
obj.x = 0
obj.y = 0
obj.z = 0
obj.commit() -- notify systems
else
print("[3DFile] error: could not load " .. obj.URL.string )
end
end
api.world.add(modelLoader)
end
}

42
src/ext/URI/main.lua Normal file
View file

@ -0,0 +1,42 @@
local api = ...
local ecs = api.ecs
return {
name = "URI",
enabled = true,
init = function()
local urlListener
urlListener = ecs.processingSystem({
updatethread = true,
filter = ecs.requireAll('URI', ecs.rejectAll('URLResponse') ),
onAdd = function(self,obj)
api.ext.URI.to(obj.URI, nil, obj)
end
})
ecs.addSystem( api.world, urlListener )
end,
to = function(URI,refererer, obj)
obj = obj or {}
print("surfing to " .. URI.url)
obj.URL = api.url.parse(URI.url)
obj.URLResponse = {}
foreach( api.protocol, function(name, p)
if obj.URL.protocol:match("^"..name) then
protocol = p
end
end)
if protocol then
local status, data, headers = protocol.request(URI.url)
obj.URLResponse = {
ok = (status >= 200 and status < 300),
status = status,
data = data,
headers = headers
}
api.exec( api.ext, 'loadasset', obj)
end
end
}

View file

@ -1,38 +0,0 @@
local api = ...
local ecs = api.ecs
return {
name = "browser",
enabled = true,
init = function()
urlListener = ecs.processingSystem({
updatesystem = true,
filter = ecs.requireAll('url', 'method'),
onAdd = function(self,obj)
api.ext.browser.to(obj.url, nil, obj)
end
})
api.world.add(urlListener)
end,
to = function(url,refererer, opts)
opts = opts or {method = 'GET' }
print("surfing to " .. url)
local URL = api.url.parse(url)
foreach( api.protocol, function(name, p)
if URL.protocol:match("^"..name) then
protocol = p
end
end)
if protocol then
local status, data, headers = protocol.request(url)
opts.ok = (status >= 200 and status < 300)
opts.status = status
opts.data = data
opts.headers = headers
opts.URL = URL
end
end
}

View file

@ -8,7 +8,7 @@ return {
init = function()
JMLLoader = ecs.processingSystem()
JMLLoader.updatesystem = true
JMLLoader.updatethread = true
JMLLoader.filter = ecs.requireAll('URL', 'method', 'data', 'ok')
function JMLLoader:process(req, dt)
if req.ok and req.xml == nil then
@ -37,7 +37,7 @@ return {
end
end
end
api.world.add(JMLLoader)
ecs.addSystem(api.world,JMLLoader)
end,
}

View file

@ -9,7 +9,9 @@ return {
local ecs = api.ecs
xrfsystem = ecs.system({
filter = ecs.filter('x', 'y', 'z', 'model'),
filter = ecs.requireAll('x', 'y', 'z', 'model'),
nocache = true,
filterDynamic = true,
onAdd = function(self,obj)

272
src/lib/lust.lua Normal file
View file

@ -0,0 +1,272 @@
-- lust v0.2.0 - Lua test framework
-- https://github.com/bjornbytes/lust
-- MIT LICENSE
local lust = {}
lust.level = 0
lust.passes = 0
lust.errors = 0
lust.befores = {}
lust.afters = {}
local red = string.char(27) .. '[31m'
local green = string.char(27) .. '[32m'
local normal = string.char(27) .. '[0m'
local function indent(level) return string.rep('\t', level or lust.level) end
function lust.nocolor()
red, green, normal = '', '', ''
return lust
end
function lust.describe(name, fn)
print(indent() .. name)
lust.level = lust.level + 1
fn()
lust.befores[lust.level] = {}
lust.afters[lust.level] = {}
lust.level = lust.level - 1
end
function lust.it(name, fn)
for level = 1, lust.level do
if lust.befores[level] then
for i = 1, #lust.befores[level] do
lust.befores[level][i](name)
end
end
end
local success, err = pcall(fn)
if success then lust.passes = lust.passes + 1
else lust.errors = lust.errors + 1 end
local color = success and green or red
local label = success and 'PASS' or 'FAIL'
print(indent() .. color .. label .. normal .. ' ' .. name)
if err then
print(indent(lust.level + 1) .. red .. tostring(err) .. normal)
end
for level = 1, lust.level do
if lust.afters[level] then
for i = 1, #lust.afters[level] do
lust.afters[level][i](name)
end
end
end
end
function lust.before(fn)
lust.befores[lust.level] = lust.befores[lust.level] or {}
table.insert(lust.befores[lust.level], fn)
end
function lust.after(fn)
lust.afters[lust.level] = lust.afters[lust.level] or {}
table.insert(lust.afters[lust.level], fn)
end
-- Assertions
local function isa(v, x)
if type(x) == 'string' then
return type(v) == x,
'expected ' .. tostring(v) .. ' to be a ' .. x,
'expected ' .. tostring(v) .. ' to not be a ' .. x
elseif type(x) == 'table' then
if type(v) ~= 'table' then
return false,
'expected ' .. tostring(v) .. ' to be a ' .. tostring(x),
'expected ' .. tostring(v) .. ' to not be a ' .. tostring(x)
end
local seen = {}
local meta = v
while meta and not seen[meta] do
if meta == x then return true end
seen[meta] = true
meta = getmetatable(meta) and getmetatable(meta).__index
end
return false,
'expected ' .. tostring(v) .. ' to be a ' .. tostring(x),
'expected ' .. tostring(v) .. ' to not be a ' .. tostring(x)
end
error('invalid type ' .. tostring(x))
end
local function has(t, x)
for k, v in pairs(t) do
if v == x then return true end
end
return false
end
local function eq(t1, t2, eps)
if type(t1) ~= type(t2) then return false end
if type(t1) == 'number' then return math.abs(t1 - t2) <= (eps or 0) end
if type(t1) ~= 'table' then return t1 == t2 end
for k, _ in pairs(t1) do
if not eq(t1[k], t2[k], eps) then return false end
end
for k, _ in pairs(t2) do
if not eq(t2[k], t1[k], eps) then return false end
end
return true
end
local function stringify(t)
if type(t) == 'string' then return "'" .. tostring(t) .. "'" end
if type(t) ~= 'table' or getmetatable(t) and getmetatable(t).__tostring then return tostring(t) end
local strings = {}
for i, v in ipairs(t) do
strings[#strings + 1] = stringify(v)
end
for k, v in pairs(t) do
if type(k) ~= 'number' or k > #t or k < 1 then
strings[#strings + 1] = ('[%s] = %s'):format(stringify(k), stringify(v))
end
end
return '{ ' .. table.concat(strings, ', ') .. ' }'
end
local paths = {
[''] = { 'to', 'to_not' },
to = { 'have', 'equal', 'be', 'exist', 'fail', 'match' },
to_not = { 'have', 'equal', 'be', 'exist', 'fail', 'match', chain = function(a) a.negate = not a.negate end },
a = { test = isa },
an = { test = isa },
be = { 'a', 'an', 'truthy',
test = function(v, x)
return v == x,
'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to be the same',
'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to not be the same'
end
},
exist = {
test = function(v)
return v ~= nil,
'expected ' .. tostring(v) .. ' to exist',
'expected ' .. tostring(v) .. ' to not exist'
end
},
truthy = {
test = function(v)
return v,
'expected ' .. tostring(v) .. ' to be truthy',
'expected ' .. tostring(v) .. ' to not be truthy'
end
},
equal = {
test = function(v, x, eps)
local comparison = ''
local equal = eq(v, x, eps)
if not equal and (type(v) == 'table' or type(x) == 'table') then
comparison = comparison .. '\n' .. indent(lust.level + 1) .. 'LHS: ' .. stringify(v)
comparison = comparison .. '\n' .. indent(lust.level + 1) .. 'RHS: ' .. stringify(x)
end
return equal,
'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to be equal' .. comparison,
'expected ' .. tostring(v) .. ' and ' .. tostring(x) .. ' to not be equal'
end
},
have = {
test = function(v, x)
if type(v) ~= 'table' then
error('expected ' .. tostring(v) .. ' to be a table')
end
return has(v, x),
'expected ' .. tostring(v) .. ' to contain ' .. tostring(x),
'expected ' .. tostring(v) .. ' to not contain ' .. tostring(x)
end
},
fail = { 'with',
test = function(v)
return not pcall(v),
'expected ' .. tostring(v) .. ' to fail',
'expected ' .. tostring(v) .. ' to not fail'
end
},
with = {
test = function(v, pattern)
local ok, message = pcall(v)
return not ok and message:match(pattern),
'expected ' .. tostring(v) .. ' to fail with error matching "' .. pattern .. '"',
'expected ' .. tostring(v) .. ' to not fail with error matching "' .. pattern .. '"'
end
},
match = {
test = function(v, p)
if type(v) ~= 'string' then v = tostring(v) end
local result = string.find(v, p)
return result ~= nil,
'expected ' .. v .. ' to match pattern [[' .. p .. ']]',
'expected ' .. v .. ' to not match pattern [[' .. p .. ']]'
end
}
}
function lust.expect(v)
local assertion = {}
assertion.val = v
assertion.action = ''
assertion.negate = false
setmetatable(assertion, {
__index = function(t, k)
if has(paths[rawget(t, 'action')], k) then
rawset(t, 'action', k)
local chain = paths[rawget(t, 'action')].chain
if chain then chain(t) end
return t
end
return rawget(t, k)
end,
__call = function(t, ...)
if paths[t.action].test then
local res, err, nerr = paths[t.action].test(t.val, ...)
if assertion.negate then
res = not res
err = nerr or err
end
if not res then
error(err or 'unknown failure', 2)
end
end
end
})
return assertion
end
function lust.spy(target, name, run)
local spy = {}
local subject
local function capture(...)
table.insert(spy, {...})
return subject(...)
end
if type(target) == 'table' then
subject = target[name]
target[name] = capture
else
run = name
subject = target or function() end
end
setmetatable(spy, {__call = function(_, ...) return capture(...) end})
if run then run() end
return spy
end
lust.test = lust.it
lust.paths = paths
return lust

View file

@ -16,6 +16,8 @@ api.backend = backend
-- decorate api
api.protocol.http = require('http')
local ecs = api.ecs
ecs.filterDraw = ecs.requireAll('drawthread')
ecs.filterUpdate = ecs.requireAll('updatethread')
-- ecs systems
@ -48,11 +50,8 @@ function lovr.load()
end
function lovr.update(dt)
if lovr.system.isKeyDown("escape") then
lovr.event.quit()
end
ecs.update( api.world, dt, ecs.filter('updatesystem') )
ecs.update( api.world, dt, ecs.filterUpdate )
iui.beginFrame(dt)
@ -92,7 +91,7 @@ function lovr.draw(pass)
end
api.exec(api.ext, "draw",pass)
ecs.update( api.world, pass, ecs.filter('drawsystem') )
ecs.update( api.world, pass, ecs.filterDraw )
-- Dot
if selectedBox then
@ -154,12 +153,12 @@ if launch.mode == "desktop" then
end
local initECS = function(ecs)
-- lovr.draw => ecs.drawsystem (render-logic thread)
-- lovr.draw => ecs.draw (render-logic thread)
renderer = ecs.processingSystem()
renderer.filter = ecs.requireAny('model')
renderer.drawsystem = true
renderer.filter = ecs.requireAll('model')
renderer.drawthread = true
function renderer:process(obj, pass)
if obj['model'] ~= nil then
if obj.model ~= nil and obj.root ~= nil then
pass:setMaterial()
pass:setCullMode('none')
pass:draw(
@ -172,22 +171,13 @@ local initECS = function(ecs)
end
ecs.addSystem( api.world, renderer )
-- lovr.update => ecs updatesystem (game-logic thread)
local updater = ecs.processingSystem()
updater.updatesystem = true
updater.filter = ecs.requireAll('update')
function updater:process(obj, dt)
print_r(obj['data'])
end
ecs.addSystem( api.world, updater )
end
function initInteractions(ecs)
ecs.worldPhysics = lovr.physics.newWorld(0, 0, 0)
interactionsUpdater = ecs.processingSystem()
interactionsUpdater.updatesystem = true
interactionsUpdater.updatethread = true
interactionsUpdater.filter = ecs.requireAll('collider')
-- collider vars
interactionsUpdater.mouse = { released = false}

View file

@ -1,5 +1,5 @@
local util = require("util")
local ecs = require("tiny-ecs")
local ecs = require("ecs")
api = {
parser = {
@ -15,10 +15,6 @@ api = {
exec = function(...) util.exec(...) end -- calls function on each table item
}
-- convenience wrappers
api.world.add = function(...) api.ecs.add( api.world, ...) end
api.world.remove = function(...) api.ecs.remove( api.world, ...) end
local runtime
local runtimepath
@ -26,11 +22,13 @@ if lovr ~= nil then runtime = { path = "lovr", api = lovr } end
if love ~= nil then runtime = { path = "love", api = love } end
api = util.merge( api, runtime.api )
require( runtime.path .. "/main")
util.loaddir( "ext", api, api.ext )
util.loaddir( "media", api, api.media )
ecs.init()
api.exec( api.ext, 'init')
require( runtime.path .. "/main")
local url='https://coderofsalvation.codeberg.page/xrfragment-haxe/example/assets/example.glb?bar=1&f=2#foo'
--local url = 'https://codeberg.org/coderofsalvation/xrfragment/raw/branch/main/assets/template/website/website.glb'
@ -38,4 +36,4 @@ local url='https://coderofsalvation.codeberg.page/xrfragment-haxe/example/assets
--local url = 'https://janusxr.org/index.html'
api.world.add({ url = url, method = 'GET' })
api.ecs.add( api.world, { URI = { url = url, method = 'GET', target = '_top' } })

19
src/test/main.lua Normal file
View file

@ -0,0 +1,19 @@
local lust = require 'lust'
local describe, it, expect = lust.describe, lust.it, lust.expect
describe('my project', function()
lust.before(function()
-- This gets run before every test.
end)
describe('module1', function() -- Can be nested
it('feature1', function()
expect(1).to.be.a('number') -- Pass
expect('astring').to.equal('astring') -- Pass
end)
it('feature2', function()
expect(nil).to.exist() -- Fail
end)
end)
end)

View file

@ -151,7 +151,6 @@ function print_r(t)
print( util.dump(t) )
end
util.match = function(list,k,v)
local ret = false
foreach( list, function(K,V)
@ -160,4 +159,22 @@ util.match = function(list,k,v)
return ret
end
-- recalculate filters for tiny-ecs systems
-- usage: entity.commit = util.commit(api.world) -- once
-- entity.commit() -- this recalculates the filtercache of systems
util.commit = function(world)
return function()
for j = 1, #world.entities do
local system = world.systems[j]
system.entities = {}
for j = 1, #world.entities do
local entity = world.entities[j]
if system.filter(system, entity) then
table.insert( system.entities, entity)
end
end
end
end
end
return util