Shooting range

A wall of forty-five crates and a barrel to knock it down with. Click to take the mouse, aim with it, fire with the left button, R rebuilds the wall.


    
main.lua
local natiny = require("natiny")

natiny.backend.init(arg and arg[1] or natiny.backend.AUTO)
natiny.physics.init()

local win = natiny.window.create("Natiny - Shooting range", 960, 640)
natiny.window.set_scale_mode(win, natiny.window.SCALE_MODE_STRETCH)

local BG = natiny.color.from_hex("#151E2C")

-- Metres, kilograms, seconds: the units the solver works in. The crate and
-- the barrel are both shipped at twice the size this scene wants, so both are
-- worn at half - the collider is made at the size the thing really is and the
-- model is scaled onto it, because scaling an entity never scales a collider.
local SCALE = 0.5
local BOX = 0.5 * SCALE
local BARREL = { radius = 0.267 * SCALE, height = 0.858 * SCALE }
local ROWS = 9              -- 9 + 8 + ... + 1 crates, which is 45 of them

-- The camera never leaves this spot; the mouse only turns it.
local CAMERA = { x = 0, y = 1.2, z = 3.8, fov = 55, sensitivity = 0.12 }
local SHOT = { speed = 12, density = 800, spawn = 0.8, limit = 12 }
local GONE = -2.0           -- under the platform, and still falling

local box_mesh, barrel_mesh
local crates, barrels = {}, {}
local yaw, pitch = 0, -6

local camera = natiny.camera.create()
natiny.camera.set_fov(camera, CAMERA.fov)
natiny.camera.set_clip(camera, 0.1, 100.0)
natiny.entity.set_position(camera, natiny.entity.SPACE_WORLD, CAMERA.x, CAMERA.y, CAMERA.z)
natiny.entity.set_rotation(camera, natiny.entity.SPACE_WORLD, pitch, yaw, 0)

-- Nine rows, one crate shorter each time. Every crate is a cube body with
-- the model hung off it, so the wall is only its own weight and friction.
local function build_stack()
    for row = 0, ROWS - 1 do
        local count = ROWS - row
        for i = 1, count do
            local body = natiny.physics.cube(BOX, BOX, BOX)
            natiny.entity.set_position(body, natiny.entity.SPACE_WORLD,
                (i - (count + 1) / 2) * (BOX + 0.01),
                BOX / 2 + row * (BOX + 0.005),
                0)
            natiny.physics.set_material(body, 0.6, 0.0, 400)
            natiny.physics.set_interpolation(body, true)

            local model = natiny.model.create(box_mesh)
            natiny.entity.set_scale(model, SCALE, SCALE, SCALE)
            natiny.entity.set_parent(model, body)
            crates[#crates + 1] = { body = body, model = model }
        end
    end
end

-- A body and the model on it are two objects, and both have to go; a crate
-- that fell off the platform is still falling and would go on falling.
local function clear(items)
    for _, item in ipairs(items) do
        natiny.model.destroy(item.model)
        natiny.physics.destroy(item.body)
    end
end

local function reset()
    clear(crates)
    clear(barrels)
    crates, barrels = {}, {}
    build_stack()
end

-- Which barrel to give up when the limit is reached: the first one that went
-- over the edge, and only if none did, the oldest. Oldest alone would take
-- the barrel lying in plain sight on the platform and leave a dozen falling
-- through nothing, because a barrel that missed keeps its place in the queue.
local function spare()
    for i, barrel in ipairs(barrels) do
        local _, y = natiny.entity.get_position(barrel.body, natiny.entity.SPACE_WORLD)
        if y < GONE then return i end
    end
    return 1
end

-- A barrel leaves the camera at the same speed whatever it weighs, which is
-- what set_velocity is for; an impulse would depend on its mass.
local function shoot(dx, dy, dz)
    local body = natiny.physics.cylinder(BARREL.radius, BARREL.height)
    local model = natiny.model.create(barrel_mesh)
    natiny.entity.set_scale(model, SCALE, SCALE, SCALE)

    natiny.entity.set_position(body, natiny.entity.SPACE_WORLD,
        CAMERA.x + dx * SHOT.spawn,
        CAMERA.y + dy * SHOT.spawn,
        CAMERA.z + dz * SHOT.spawn)
    -- The barrel stands on its origin while the body is centred on its own.
    natiny.entity.set_parent(model, body)
    natiny.entity.set_position(model, natiny.entity.SPACE_LOCAL, 0, -BARREL.height / 2, 0)

    natiny.physics.set_material(body, 0.5, 0.1, SHOT.density)
    natiny.physics.set_interpolation(body, true)
    natiny.physics.set_velocity(body, dx * SHOT.speed, dy * SHOT.speed, dz * SHOT.speed)

    barrels[#barrels + 1] = { body = body, model = model }
    if #barrels > SHOT.limit then
        clear({ table.remove(barrels, spare()) })
    end
end

local MOUSE_LEFT = natiny.input.MOUSE_BUTTON_LEFT
local KEY_R = natiny.input.KEY_R

local function frame(dt)
    natiny.window.bind(win, function()
        local captured = natiny.input.get_mouse_captured()
        if captured then
            yaw = yaw - natiny.input.get_mouse_x_speed() * CAMERA.sensitivity
            pitch = pitch - natiny.input.get_mouse_y_speed() * CAMERA.sensitivity
            pitch = math.max(-60, math.min(30, pitch))
            natiny.entity.set_rotation(camera, natiny.entity.SPACE_WORLD, pitch, yaw, 0)
        end

        -- The click that takes the mouse does not also fire: a barrel spawned
        -- by it would come as a surprise.
        if natiny.input.get_mouse_button_pressed(MOUSE_LEFT) then
            if captured then
                local ry, rx = math.rad(yaw), math.rad(pitch)
                shoot(-math.sin(ry) * math.cos(rx),
                       math.sin(rx),
                      -math.cos(ry) * math.cos(rx))
            else
                natiny.input.set_mouse_capture(true)
            end
        end

        if natiny.input.get_key_pressed(KEY_R) then
            reset()
        end

        natiny.render.clear(BG)
        natiny.camera.bind(camera, function()
            natiny.render.tag("model")
        end)
    end)
end

local function load_mesh(path)
    local resource = natiny.resource.load(path)
    local mesh = natiny.mesh.load(resource)
    natiny.resource.destroy(resource)
    return mesh
end

-- One mesh, used twice: drawn as a model and collided with triangle for
-- triangle, so the crates rest on the platform they look to be on.
local platform_mesh = load_mesh("common/models/platform.glb")
natiny.model.create(platform_mesh)
natiny.physics.mesh(platform_mesh)

barrel_mesh = load_mesh("common/models/barrel.glb")
box_mesh = load_mesh("common/models/box_01.glb")
build_stack()

natiny.backend.loop(function(dt)
    frame(dt)
end)

natiny.backend.shutdown()