DEMI-PROJECT-PACKAGE á{"files":[{"hash":"sha256:3065937c7cb4e2018eb4daa1569505b0ad7d2f1ec84c4c4e7a67fd7b8c3de103","path":"LICENSE","size":1492},{"hash":"sha256:6c6dd7f8b99fca89d83ed8a32db970b1b58eced6975c27a175fe6e6bf592469a","path":"README.md","size":4281},{"hash":"sha256:9cff341a78a2ca011f401bb88a95461e2dd74e9aef38a1cf87e7ab7f6352fdc6","path":"scripts/demi/gameplay/water/buoyancy.lua","size":2253},{"hash":"sha256:8053e7d2160d8b49e5de8218987fbf6d1197bd3f29554386b5514e92ed5f6daf","path":"scripts/demi/gameplay/water/motion.lua","size":2920},{"hash":"sha256:ca982cf57f0013bfdf077b1d1ba5b4588a7d418a42c96772579bc3ab7c4e0041","path":"scripts/demi/gameplay/water/swimming.lua","size":2974},{"hash":"sha256:1dbf51364d65334e32d83551d312fcd2c38d728a1427d9d59049cfaaeecd9b28","path":"tests/water_test.lua","size":1832}],"format_version":1,"manifest":{"dependencies":{},"engine":"^0.1.0","exported_events":[],"files":["LICENSE","README.md","scripts/demi/gameplay/water/buoyancy.lua","scripts/demi/gameplay/water/motion.lua","scripts/demi/gameplay/water/swimming.lua","tests/water_test.lua"],"format_version":1,"name":"demi.gameplay.water","public_modules":["demi.gameplay.water.buoyancy","demi.gameplay.water.swimming"],"version":"1.0.0"},"package_type":"DemiProjectPackage"}BSD 3-Clause License Copyright (c) 2026, JEAPI Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Water Gameplay Opt-in fixed-step buoyancy, flow-relative drag and dynamic-body swimming over prepared terrain water. No renderer, automatic listeners or fluid simulation. Import the specific service you need: ```lua local Buoyancy = require("demi.gameplay.water.buoyancy") local Swimming = require("demi.gameplay.water.swimming") local float = Buoyancy.new("boat", {half_height = .5, density_ratio = 1.05}) local swimmer = Swimming.new("player", {speed = 3, min_depth = 1.5, max_flow = 2.5}) function Behaviour:on_fixed_update(dt) local floating = float:fixed_update(dt) local swimming = swimmer:fixed_update(dt, {1, 0, 0}) -- world-space direction end ``` A game owns input/camera conversion, character animation, oxygen, transitions between land and swimming controllers, and choosing which entities participate. These modules do not change gravity, position, collider shape or body type. Use Rigidbody3D dynamic bodies with authored colliders. Static, kinematic, disabled and missing bodies receive no forces. A missing/dry water column also receives no force. Native contacts remain authoritative. ## Buoyancy `Buoyancy.new(entity_id, options)` accepts `half_height` (world units, default 0.5), `density_ratio` (default 1.05), `linear_drag` (per second, default 2), `gravity` (world acceleration vector, default `{0,-9.81,0}`), and optional `terrain_id`. Density ratio scales displacement lift relative to configured body mass; larger values make the virtual body float higher. It is a gameplay ratio, not a measurement of the collider's true displaced volume. The virtual volume is a constant cross-section upright segment centred on the body origin. Its overlap with surface-to-bed water is measured along native `column_up`, including transformed terrain placements. It does not sample hull vertices, apply torque or stabilize rotation. Gravity acceleration is explicit: match it to the game's physics gravity and body gravity scale when seeking neutral equilibrium. Lift still acts if native body gravity is disabled; the package never toggles or silently restores that property. Drag damps velocity relative to authored water flow using an exponential step; its drag-only response is independent of timestep subdivision and cannot reverse relative velocity. Buoyancy plus native contact/gravity remains a fixed-step approximation. Both lift and drag use `Rigidbody3D.add_force`, not teleportation. ## Swimming `Swimming.new(entity_id, options)` accepts `speed` (world units/second, default 3), `acceleration` (world units/second squared, default 8), `linear_drag` (default 2), `min_depth` (default 1.5 world units), `max_flow` (default 2.5 world units/second), and optional `terrain_id`. These are game policy, not native water eligibility rules. The body origin must be immersed; shallow water or a flow above the chosen limit leaves land movement to the caller. `fixed_update(dt, world_direction)` clamps direction length to one, accelerates toward the chosen velocity relative to flow, then applies water drag. Drag can reduce the attained speed. Vertical input permits ascent/descent. Gravity is unchanged: add Buoyancy if desired, or author the swimming body's gravity policy. Do not simultaneously apply a separate land controller's movement force. Both update methods return a retained state table (`active`, `sample`, and for buoyancy `submersion`). Read it immediately; the next update replaces its values. Copy values that a game needs to retain. Use `demi.terrain.water.tracker` directly for point enter/stay/exit events; these modules do not invent collider events. All options, fixed timesteps and input directions must be finite. Fixed timestep and half-height must be positive; rates and thresholds are nonnegative. Call exactly once per physical step, in `on_fixed_update`; calling twice adds forces twice. Shader water ripples change shading normals only, so gameplay samples and floating objects use the same underlying flat/follow-bed surface. `examples/water_interaction_3d` demonstrates real native contacts, buoyancy and swimming with deterministic E2E assertions. Package tests exercise math and configuration; `demi-water-gameplay-tests` exercises these same Lua modules through the actual Lua/physics runtime without a window. local Entity = require("demi.entity") local Transform = require("demi.transform3d") local Rigidbody = require("demi.physics.rigidbody3d") local Water = require("demi.terrain.water") local Motion = require("demi.gameplay.water.motion") local Buoyancy = {} Buoyancy.__index = Buoyancy function Buoyancy.new(entity_id, options) options = Motion.options(entity_id, options) return setmetatable({ entity_id = entity_id, terrain_id = options.terrain_id, half_height = Motion.number(Motion.value(options, "half_height", .5), "half_height", 0, true), density_ratio = Motion.number(Motion.value(options, "density_ratio", 1.05), "density_ratio", 0), linear_drag = Motion.number(Motion.value(options, "linear_drag", 2), "linear_drag", 0), gravity = Motion.vector(Motion.value(options, "gravity", {0, -9.81, 0}), "gravity"), state = {active = false, submersion = 0}, }, Buoyancy) end -- Call once from on_fixed_update, before the physics step. This is a virtual -- upright volume approximation: it adds no collider or angular/righting torque. function Buoyancy:fixed_update(dt) Motion.number(dt, "fixed dt", 0, true) local state = self.state state.active, state.submersion, state.sample = false, 0, nil local body = Rigidbody.state(self.entity_id) if not body or body.body_type ~= "dynamic" or not body.enabled or not Entity.is_enabled(self.entity_id) then return state end local x, y, z = Transform.get_world_position(self.entity_id) if x == nil then return state end local sample = Water.sample({x, y, z}, self.terrain_id) local immersion = Motion.submersion(sample, {x, y, z}, self.half_height) state.sample, state.submersion = sample, immersion if immersion <= 0 then return state end local force = {} for axis = 1, 3 do local relative_velocity = body.velocity[axis] - sample.flow_velocity[axis] local drag = Motion.drag_delta(relative_velocity, self.linear_drag, immersion, dt) / dt local lift = -self.gravity[axis] * self.density_ratio * immersion force[axis] = Motion.number(body.mass * (lift + drag), "buoyancy force", -math.huge) end state.active = Rigidbody.add_force(self.entity_id, force[1], force[2], force[3]) return state end return Buoyancy -- Private shared math. Body volume and swimming policy belong to this package, -- not to the native prepared-water query service. local Motion = {} function Motion.number(value, name, minimum, positive) assert(type(value) == "number" and value == value and math.abs(value) < math.huge, name .. " must be finite") assert(value >= minimum and (not positive or value > minimum), name .. " is outside its allowed range") return value end function Motion.vector(value, name) assert(type(value) == "table", name .. " must be a three-number array") local count = 0 for key in pairs(value) do assert(type(key) == "number" and key % 1 == 0 and key >= 1 and key <= 3, name .. " must be a dense three-number array") count = count + 1 end assert(count == 3, name .. " must contain three coordinates") return { Motion.number(value[1], name, -math.huge), Motion.number(value[2], name, -math.huge), Motion.number(value[3], name, -math.huge), } end function Motion.value(options, key, fallback) if options[key] == nil then return fallback end return options[key] end function Motion.options(entity_id, options) assert(type(entity_id) == "string" and entity_id ~= "", "entity ID is required") assert(options == nil or type(options) == "table", "options must be a table") options = options or {} if options.terrain_id ~= nil then assert(type(options.terrain_id) == "string" and options.terrain_id ~= "", "terrain_id must be a nonempty string") end return options end function Motion.submersion(sample, position, half_height) if not sample then return 0 end -- Native depth and surface projection share column_up, including rotated and -- scaled terrain. The river surface normal is not this measurement axis. local surface = sample.surface local up = sample.column_up local surface_offset = 0 for axis = 1, 3 do surface_offset = surface_offset + (surface[axis] - position[axis]) * up[axis] end local water_bottom = surface_offset - sample.depth local overlap = math.min(half_height, surface_offset) - math.max(-half_height, water_bottom) return math.max(0, math.min(1, overlap / (2 * half_height))) end function Motion.drag_delta(relative_velocity, rate, immersion, dt) -- Exponential damping composes across fixed-step subdivisions and cannot -- reverse relative velocity even when a game takes a large fixed step. return relative_velocity * (math.exp(-rate * immersion * dt) - 1) end function Motion.clamp_direction(value) local scale = math.max(math.abs(value[1]), math.abs(value[2]), math.abs(value[3])) if scale == 0 then return value end local x, y, z = value[1] / scale, value[2] / scale, value[3] / scale local length = math.sqrt(x*x + y*y + z*z) if scale <= 1 and scale * length <= 1 then return value end return {x / length, y / length, z / length} end return Motion local Entity = require("demi.entity") local Transform = require("demi.transform3d") local Rigidbody = require("demi.physics.rigidbody3d") local Water = require("demi.terrain.water") local Motion = require("demi.gameplay.water.motion") local Swimming = {} Swimming.__index = Swimming function Swimming.new(entity_id, options) options = Motion.options(entity_id, options) return setmetatable({ entity_id = entity_id, terrain_id = options.terrain_id, speed = Motion.number(Motion.value(options, "speed", 3), "speed", 0), acceleration = Motion.number(Motion.value(options, "acceleration", 8), "acceleration", 0), linear_drag = Motion.number(Motion.value(options, "linear_drag", 2), "linear_drag", 0), min_depth = Motion.number(Motion.value(options, "min_depth", 1.5), "min_depth", 0), max_flow = Motion.number(Motion.value(options, "max_flow", 2.5), "max_flow", 0), state = {active = false}, }, Swimming) end -- Input is a world-space direction, including optional ascent/descent. The -- caller owns camera-relative input, animation, oxygen and land-controller mode. function Swimming:fixed_update(dt, direction) Motion.number(dt, "fixed dt", 0, true) if direction == nil then direction = {0, 0, 0} end direction = Motion.clamp_direction(Motion.vector(direction, "swim direction")) local state = self.state state.active, state.sample = false, nil local body = Rigidbody.state(self.entity_id) if not body or body.body_type ~= "dynamic" or not body.enabled or not Entity.is_enabled(self.entity_id) then return state end local x, y, z = Transform.get_world_position(self.entity_id) if x == nil then return state end local sample = Water.sample({x, y, z}, self.terrain_id) state.sample = sample if not sample or not sample.underwater or sample.depth < self.min_depth then return state end local flow = sample.flow_velocity local flow_speed = math.sqrt(flow[1]^2 + flow[2]^2 + flow[3]^2) if flow_speed > self.max_flow then return state end local requested = {} local length_squared = 0 for axis = 1, 3 do local relative_velocity = body.velocity[axis] - sample.flow_velocity[axis] local desired = direction[axis] * self.speed requested[axis] = desired - relative_velocity length_squared = length_squared + requested[axis]^2 end local length = math.sqrt(length_squared) local amount = length > 0 and math.min(1, self.acceleration * dt / length) or 0 local force = {} for axis = 1, 3 do local relative_velocity = body.velocity[axis] - sample.flow_velocity[axis] local driven = relative_velocity + requested[axis] * amount local damping = Motion.drag_delta(driven, self.linear_drag, 1, dt) force[axis] = Motion.number(body.mass * (requested[axis] * amount + damping) / dt, "swimming force", -math.huge) end state.active = Rigidbody.add_force(self.entity_id, force[1], force[2], force[3]) return state end return Swimming local Test = require("demi.test") local Motion = require("demi.gameplay.water.motion") Test.case("volume clips to water and bed along the transformed column", function() local sample = {surface = {10, 0, 0}, column_up = {1, 0, 0}, depth = 2} Test.equal(Motion.submersion(sample, {10, 0, 0}, 1), .5) Test.equal(Motion.submersion(sample, {9, 0, 0}, 1), 1) Test.equal(Motion.submersion(sample, {8, 0, 0}, 1), .5) Test.equal(Motion.submersion(sample, {7, 0, 0}, 1), 0) Test.equal(Motion.submersion(sample, {11, 0, 0}, 1), 0) Test.equal(Motion.submersion(nil, {9, 0, 0}, 1), 0) end) Test.case("exponential water drag composes across fixed-step subdivisions", function() local full = 8 + Motion.drag_delta(8, 2, .75, 1) local subdivided = 8 for _ = 1, 20 do subdivided = subdivided + Motion.drag_delta(subdivided, 2, .75, .05) end Test.truthy(math.abs(full - subdivided) < 1e-12) Test.truthy(full > 0 and full < 8) Test.equal(Motion.drag_delta(8, 2, 0, 1), 0) end) Test.case("directions are copied and length limited without diagonal advantage", function() local supplied = {3, 4, 0} local result = Motion.clamp_direction(Motion.vector(supplied, "direction")) Test.equal(result[1], .6) Test.equal(result[2], .8) Test.equal(supplied[1], 3) local huge = Motion.clamp_direction({1e300, 1e300, 0}) Test.truthy(math.abs(huge[1] - math.sqrt(.5)) < 1e-12) end) Test.case("invalid finite configuration is rejected", function() for _, value in ipairs({-1, math.huge, 0/0, false}) do Test.equal(pcall(function() Motion.number(value, "rate", 0) end), false) end Test.equal(pcall(function() Motion.vector({1, 2}, "gravity") end), false) Test.equal(pcall(function() Motion.vector({1, 2, 3, 4}, "gravity") end), false) Test.equal(pcall(function() Motion.options("", {}) end), false) end)