DEMI-PROJECT-PACKAGE ¾{"files":[{"hash":"sha256:db1331d6071d0b8feebc31b88dfde5b94d13bcddf61f1a4f07753b911ab7dd52","path":"README.md","size":182},{"hash":"sha256:60ff6e9479ab1988ca7d1e54a9fdc3b3effb920ada0c01fd209033cf85629833","path":"scripts/demi/gameplay/hitscan.lua","size":1059},{"hash":"sha256:a152c72800bbaea9a7b9d2fc48a231ceb0b2294bd212e604397005e4b4f6756c","path":"scripts/demi/gameplay/projectiles.lua","size":2948},{"hash":"sha256:f5adda4a4e90b9f2410ad050790129111f945a92a3cead3351041654eb2c3794","path":"scripts/demi/gameplay/weapon.lua","size":611},{"hash":"sha256:14037f0af73794f1998433cd17f4604cd9211ec25a0b36989ac0ab49e323c8af","path":"tests/projectiles_test.lua","size":2127}],"format_version":1,"manifest":{"dependencies":{"demi.gameplay.core":"^1.0.0"},"engine":"^0.1.0","exported_events":["damage_requested","projectile_spawned","projectile_released"],"files":["README.md","scripts/demi/gameplay/hitscan.lua","scripts/demi/gameplay/projectiles.lua","scripts/demi/gameplay/weapon.lua","tests/projectiles_test.lua"],"format_version":1,"name":"demi.gameplay.projectiles","public_modules":["demi.gameplay.hitscan","demi.gameplay.projectiles","demi.gameplay.weapon"],"version":"1.0.0"},"package_type":"DemiProjectPackage"}# demi.gameplay.projectiles Injected segment-query projectile and hit-scan simulation with independent cooldown/ammo policy. Query adapters may use 2D or 3D engine ray/shape casts. local Hitscan = {} function Hitscan.fire(events, query, values) assert(events and query) values = values or {} local hits = query(values.from_x or 0, values.from_y or 0, values.to_x or 0, values.to_y or 0, values.mask) or {} table.sort(hits, function(a, b) local left, right = a.fraction or 0, b.fraction or 0 if left == right then return tostring(a.entity) < tostring(b.entity) end return left < right end) local remaining = values.pierce or 0 local struck = {} for _, hit in ipairs(hits) do local id = hit.entity if id and id ~= values.owner and id ~= values.ignored and not hit.trigger and not struck[id] then struck[id] = true events:emit("damage_requested", { source = values.owner, target = id, amount = values.damage or 0, type = values.type, point = hit.point, normal = hit.normal, tags = { "hitscan" }, }) if remaining == 0 then return id end remaining = remaining - 1 end end return nil end return Hitscan local Projectiles = {} Projectiles.__index = Projectiles local function reset(projectile) projectile.active, projectile.owner, projectile.ignored, projectile.mask = false, nil, nil, nil projectile.x, projectile.y, projectile.vx, projectile.vy = 0, 0, 0, 0 projectile.damage, projectile.life, projectile.pierce = 0, 0, 0 projectile.hit_once = {} end function Projectiles.new(events, query) return setmetatable({ events = assert(events), query = assert(query), pool = {}, active = {} }, Projectiles) end function Projectiles:spawn(values) local projectile = table.remove(self.pool) or {} reset(projectile) projectile.active, projectile.owner = true, values.owner projectile.ignored, projectile.mask = values.ignored, values.mask projectile.x, projectile.y = values.x or 0, values.y or 0 projectile.vx, projectile.vy = values.vx or 0, values.vy or 0 projectile.damage, projectile.life = values.damage or 0, values.life or 1 projectile.pierce = values.pierce or 0 table.insert(self.active, projectile) self.events:emit("projectile_spawned", { owner = projectile.owner }) return projectile end function Projectiles:release(projectile) if not projectile.active then return false end projectile.active = false for index = #self.active, 1, -1 do if self.active[index] == projectile then table.remove(self.active, index); break end end self.events:emit("projectile_released", { owner = projectile.owner }) reset(projectile); table.insert(self.pool, projectile) return true end function Projectiles:update(dt) local snapshot = {} for index = 1, #self.active do snapshot[index] = self.active[index] end for _, projectile in ipairs(snapshot) do if projectile.active then local next_x, next_y = projectile.x + projectile.vx * dt, projectile.y + projectile.vy * dt local hits = self.query(projectile.x, projectile.y, next_x, next_y, projectile.mask) or {} table.sort(hits, function(a, b) return (a.fraction or 0) == (b.fraction or 0) and a.entity < b.entity or (a.fraction or 0) < (b.fraction or 0) end) for _, hit in ipairs(hits) do if hit.entity ~= projectile.owner and hit.entity ~= projectile.ignored and not projectile.hit_once[hit.entity] and not hit.trigger then projectile.hit_once[hit.entity] = true self.events:emit("damage_requested", { source = projectile.owner, target = hit.entity, amount = projectile.damage, point = hit.point, normal = hit.normal, tags = { "projectile" } }) if projectile.pierce == 0 then self:release(projectile); break end projectile.pierce = projectile.pierce - 1 end end if projectile.active then projectile.x, projectile.y = next_x, next_y projectile.life = projectile.life - dt if projectile.life <= 0 then self:release(projectile) end end end end end return Projectiles local Weapon = {} Weapon.__index = Weapon function Weapon.new(values) values = values or {} return setmetatable({ cooldown = values.cooldown or 0, remaining = 0, ammo = values.ammo, magazine = values.magazine, reload = 0 }, Weapon) end function Weapon:update(dt) self.remaining = math.max(0, self.remaining - dt) end function Weapon:can_fire() return self.remaining == 0 and (self.ammo == nil or self.ammo > 0) end function Weapon:fire() if not self:can_fire() then return false end self.remaining = self.cooldown if self.ammo then self.ammo = self.ammo - 1 end return true end return Weapon local Events = require("demi.gameplay.events") local Projectiles = require("demi.gameplay.projectiles") Test.case("fast projectile uses the whole segment", function() local events, target = Events.new(), nil events:on("damage_requested", function(value) target = value.target end) local shots = Projectiles.new(events, function(x1, y1, x2) Test.truthy(x2 > x1 + 100); return { { entity = "enemy", fraction = 0.5 } } end) shots:spawn({ owner = "player", vx = 1000, damage = 2, life = 1 }) shots:update(0.2); events:flush(); Test.equal(target, "enemy") end) Test.case("pool resets identity hits and double release", function() local events = Events.new() local shots = Projectiles.new(events, function() return {} end) local first = shots:spawn({ owner = "old", ignored = "old-ignore", pierce = 3 }) first.hit_once.enemy = true Test.equal(shots:release(first), true); Test.equal(shots:release(first), false) local reused = shots:spawn({ owner = "new" }) Test.equal(reused, first); Test.equal(reused.ignored, nil); Test.equal(reused.hit_once.enemy, nil) end) Test.case("trigger before solid does not consume projectile", function() local events, target = Events.new(), nil events:on("damage_requested", function(value) target = value.target end) local shots = Projectiles.new(events, function() return { { entity = "trigger", fraction = 0.1, trigger = true }, { entity = "solid", fraction = 0.2 } } end) shots:spawn({ owner = "player", damage = 1 }); shots:update(0.1); events:flush() Test.equal(target, "solid") end) Test.case("hitscan sorts contacts and ignores triggers", function() local Hitscan = require("demi.gameplay.hitscan") local events, target = Events.new(), nil events:on("damage_requested", function(value) target = value.target end) local hit = Hitscan.fire(events, function() return { { entity = "far", fraction = 0.9 }, { entity = "trigger", fraction = 0.1, trigger = true }, { entity = "near", fraction = 0.2 } } end, { owner = "player", damage = 4 }) events:flush() Test.equal(hit, "near") Test.equal(target, "near") end)