DEMI-PROJECT-PACKAGE t{"files":[{"hash":"sha256:138e7bd81af00a89bca269dfbf991b3b7153067f82f3e67c9189b7da4147a687","path":"README.md","size":1339},{"hash":"sha256:92dbd4afc703c0408050f192c359d74a3a54e39c31cadbd3e0c9fe1c14b54246","path":"scripts/demi/gameplay/melee.lua","size":1549},{"hash":"sha256:7f690fbe3826a8c18001a0ad178778322b97e1a334cdb60c8b32ebf76ed66aa4","path":"scripts/demi/gameplay/orbit_camera.lua","size":1523},{"hash":"sha256:1eda8ac4241516d21f682cc453aa7c93233689f10e7df3643a410d56355f9d75","path":"scripts/demi/gameplay/third_person.lua","size":2208},{"hash":"sha256:9ca5161b9d3bcd928b24ff4cc27dde9b6214c29f24828e8cb8580420c2930bbf","path":"tests/foundation_test.lua","size":1766}],"format_version":1,"manifest":{"dependencies":{},"engine":"^0.1.0","exported_events":[],"files":["README.md","scripts/demi/gameplay/orbit_camera.lua","scripts/demi/gameplay/melee.lua","scripts/demi/gameplay/third_person.lua","tests/foundation_test.lua"],"format_version":1,"name":"demi.gameplay.third_person","public_modules":["demi.gameplay.orbit_camera","demi.gameplay.melee","demi.gameplay.third_person"],"version":"1.0.0"},"package_type":"DemiProjectPackage"}# Third-person foundation Three independent modules over ordinary Lua values: - `demi.gameplay.third_person`: camera-relative movement, stamina and directional rolls. - `demi.gameplay.orbit_camera`: mouse/lock-on orbit and obstruction distance. - `demi.gameplay.melee`: windup, active, recovery and one hit per target per swing. Construct with `Character.new(options)`, `Orbit.new(options)`, and `Melee.new({windup=0.8, active=0.15, recovery=1.2, reach=2.5, damage=20})`. See `examples/third_person_foundation/scripts/room.lua` for the complete adapter. Call attack update and character input for all participants before resolving hits in the fixed tick. Check `character:invulnerable()` at hit resolution. These modules never translate entity transforms themselves: pass velocities to `CharacterController3D.set_velocity` so static collision remains authoritative. The camera cast callback returns nearest blocking distance. A missing callback disables obstruction. Capture/release the mouse in the owning script lifecycle. The room deliberately uses procedural pose cues; production animation clips and bone-attached hit volumes still require integration. Reach/cone tests are an approximation, not mesh collision. GPU skinning and retargeting are separate work. Run `demi package test packages/sources/demi.gameplay.third_person`. local Melee = {} Melee.__index = Melee function Melee.new(definition) assert(type(definition) == "table", "attack definition required") assert(definition.windup >= 0 and definition.active > 0 and definition.recovery >= 0, "invalid attack phases") assert(definition.reach > 0 and definition.damage >= 0, "invalid attack damage/reach") return setmetatable({ definition = definition, elapsed = 0, running = false, victims = {} }, Melee) end function Melee:start() if self.running then return false end self.elapsed, self.running, self.victims = 0, true, {} return true end function Melee:phase() if not self.running then return "idle" end local d = self.definition if self.elapsed < d.windup then return "windup" end if self.elapsed < d.windup + d.active then return "active" end return "recovery" end function Melee:update(dt) assert(dt >= 0, "negative timestep") if not self.running then return end self.elapsed = self.elapsed + dt local d = self.definition if self.elapsed >= d.windup + d.active + d.recovery then self.running = false end end -- Call only after all combatants have processed this tick's input/defence. -- An avoided swing is consumed too: leaving i-frames cannot re-hit this swing. function Melee:hit(id, distance, facing_dot, invulnerable) local d = self.definition if self:phase() ~= "active" or self.victims[id] or distance > d.reach or facing_dot < (d.minimum_dot or 0) then return 0 end self.victims[id] = true return invulnerable and 0 or d.damage end return Melee local Orbit = {} Orbit.__index = Orbit function Orbit.new(options) options = options or {} return setmetatable({ yaw = options.yaw or math.pi, pitch = options.pitch or 0.35, distance = options.distance or 6, height = options.height or 0.7, sensitivity = options.sensitivity or 0.003, radius = options.radius or 0.25, clearance = options.clearance or 0.08 }, Orbit) end function Orbit:look(dx, dy, target, lock_target) if lock_target then local x, z = lock_target.x-target.x, lock_target.z-target.z if x*x+z*z > 0.000001 then self.yaw = math.atan(x,z) end else self.yaw = self.yaw - dx*self.sensitivity end self.pitch = math.max(-0.1, math.min(1.1, self.pitch + dy*self.sensitivity)) return math.sin(self.yaw), math.cos(self.yaw) end -- cast(origin, radius, direction, distance) returns nearest blocking distance. -- Clamp to the actual obstruction; a minimum boom length can push through walls. function Orbit:position(target, cast) local origin = { x=target.x, y=target.y+self.height, z=target.z } local direction = { x=-math.sin(self.yaw)*math.cos(self.pitch), y=math.sin(self.pitch), z=-math.cos(self.yaw)*math.cos(self.pitch) } local distance = self.distance if cast then local hit = cast(origin, self.radius, direction, distance) if hit then distance = math.max(0, math.min(distance, hit-self.clearance)) end end return { x=origin.x+direction.x*distance, y=origin.y+direction.y*distance, z=origin.z+direction.z*distance }, origin end return Orbit local Character = {} Character.__index = Character function Character.new(options) options = options or {} local self = setmetatable({ speed = options.speed or 4, roll_speed = options.roll_speed or 7, roll_duration = options.roll_duration or 0.6, iframe_duration = options.iframe_duration or 0.35, roll_cost = options.roll_cost or 25, stamina_max = options.stamina or 100, regeneration = options.regeneration or 20, roll_left = 0, vx = 0, vz = 0, facing_x = 0, facing_z = -1 }, Character) assert(self.roll_duration > 0 and self.iframe_duration <= self.roll_duration and self.iframe_duration >= 0 and self.roll_cost >= 0, "invalid roll settings") self.stamina = self.stamina_max return self end function Character:invulnerable() return self.roll_left > 0 and self.roll_duration - self.roll_left < self.iframe_duration end function Character:update(input, forward_x, forward_z, dt, movement_locked) assert(dt >= 0, "negative timestep") self.roll_left = math.max(0, self.roll_left - dt) local x, z = input.x or 0, input.y or 0 local magnitude = math.sqrt(x*x + z*z) if magnitude > 1 then x, z = x/magnitude, z/magnitude end local dx = -forward_z*x + forward_x*z local dz = forward_x*x + forward_z*z if input.roll and self.roll_left == 0 and not movement_locked and self.stamina >= self.roll_cost then local length = math.sqrt(dx*dx + dz*dz) self.roll_x = length > 0.001 and dx/length or self.facing_x self.roll_z = length > 0.001 and dz/length or self.facing_z self.roll_left = self.roll_duration self.stamina = self.stamina - self.roll_cost end if self.roll_left > 0 then self.vx, self.vz = self.roll_x*self.roll_speed, self.roll_z*self.roll_speed self.facing_x, self.facing_z = self.roll_x, self.roll_z return "roll" end if movement_locked then self.vx, self.vz = 0, 0; return "attack" end self.stamina = math.min(self.stamina_max, self.stamina + self.regeneration*dt) self.vx, self.vz = dx*self.speed, dz*self.speed local length = math.sqrt(dx*dx + dz*dz) if length > 0.001 then self.facing_x, self.facing_z = dx/length, dz/length return "run" end return "idle" end return Character local Character = require("demi.gameplay.third_person") local Orbit = require("demi.gameplay.orbit_camera") local Melee = require("demi.gameplay.melee") Test.case("damage is active-only and a dodged swing cannot re-hit", function() local attack = Melee.new({windup=0.5,active=0.2,recovery=1,reach=3,damage=20}) Test.equal(attack:start(),true) Test.equal(attack:hit("player",1,1,false),0) attack:update(0.5) Test.equal(attack:hit("player",1,-1,false),0) Test.equal(attack:hit("player",1,1,true),0) Test.equal(attack:hit("player",1,1,false),0) Test.equal(attack:hit("second",1,1,false),20) Test.equal(attack:hit("second",1,1,false),0) attack:update(0.21) Test.equal(attack:hit("third",1,1,false),0) Test.equal(attack:start(),false) attack:update(1) Test.equal(attack:start(),true) end) Test.case("roll protects startup, expires, costs stamina and locks direction", function() local c = Character.new() c:update({x=1,roll=true},0,-1,0.01,false) Test.equal(c:invulnerable(),true) Test.equal(c.stamina,75) c:update({x=-1},1,0,0.1,false) Test.equal(c.vx,7) Test.equal(c.vz,0) c:update({},0,-1,0.3,false) Test.equal(c:invulnerable(),false) c:update({},0,-1,0.3,false) Test.equal(c.roll_left,0) end) Test.case("camera basis normalizes diagonals and respects nearby obstruction", function() local c = Character.new() c:update({x=1,y=1},0,-1,0.01,false) Test.near(math.sqrt(c.vx*c.vx+c.vz*c.vz),4) local orbit = Orbit.new({clearance=0.08}) local eye,focus = orbit:position({x=0,y=1,z=0},function() return 0.1 end) local x,y,z = eye.x-focus.x,eye.y-focus.y,eye.z-focus.z Test.near(math.sqrt(x*x+y*y+z*z),0.02) local before = orbit.yaw orbit:look(100,0,{x=0,z=0}) Test.equal(orbit.yaw < before,true) end)