Upload files to 'mods/gunslinger'

This commit is contained in:
thunderdog1138 2020-09-22 19:23:47 +00:00
parent 98a0868e64
commit 5b876085bb
5 changed files with 795 additions and 0 deletions

View File

@ -0,0 +1,11 @@
unused_args = false
allow_defined_top = true
globals = {
"gunslinger",
"table",
"vector",
"minetest",
"ItemStack",
"PcgRandom"
}

View File

@ -0,0 +1,12 @@
language: generic
sudo: false
addons:
apt:
packages:
- luarocks
before_install:
- luarocks install --local luacheck
script:
- $HOME/.luarocks/bin/luacheck --no-color .
notifications:
email: false

203
mods/gunslinger/API.md Normal file
View File

@ -0,0 +1,203 @@
# `gunslinger` API documentation
This file aims to thoroughly document the `gunslinger` code-base and API.
## Data structures
### Ammo Definition Table
- `itemdef` [table]: Item definition table passed to `minetest.register_item`.
- Note that `on_use`, `on_place`, and `on_secondary_use` will be overridden by gunslinger.
### Gun Definition Table (GDT)
- `itemdef` [table]: Item definition table passed to `minetest.register_item`.
- Note that `on_use`, `on_place`, and `on_secondary_use` will be overridden by gunslinger.
- `type` [string]: Type to inherit initial weapon properties from.
- `clip_size` [number]: Number of rounds per-clip.
- `fire_rate` [number]: Number of rounds per-second.
- `range` [number]: Range of fire in number of nodes.
- `mode` [string]: Firing mode.
- `"manual"`: One round per-click, but requires manual loading for every round; aka bolt-action rifles.
- `"semi-automatic"`: One round per-click. e.g. a typical 9mm pistol.
- `"burst"`: Multiple rounds per-click. Can be set by defining `burst` field. Defaults to 3. e.g. M16A4
- `"automatic"`: Fully automatic; shoots as long as primary button is held down. e.g. AKM, M416.
- `"hybrid"`: Same as `"automatic"`, but switches to `"burst"` mode when scope view is toggled.
- `ammo` [string]: Name of valid registered ammo to be associated with the weapon. Defaults to `gunslinger:ammo`.
- `dmg_mult` [number]: Damage multiplier. Multiplied with `base_dmg` to obtain initial/rated damage value. Defaults to 1.
- `spread_mult` [number]: Spread multiplier. Multiplied with `base_spread` to obtain spread threshold for projectile. Defaults to 0.
- `recoil_mult` [number]: Recoil multiplier. Multiplied with `base_recoil` to obtain final recoil per-round. Defaults to 0.
- `reload_time` [number]: Reload time in seconds. Defaults to 3 to match default reload sound.
- `pellets` [number]: Number of pellets per-round. Used for firing multiple pellets shotgun-style. Defaults to 1, meaning only one "pellet" is fired each round.
- `textures` [table]: Textures for various requirements.
`projectile` [string]: Texture used for projectiles.
- Note that assymmetric textures aren't supported. A texture modelled after a bullet-like shape won't point in the direction of flight.
- `sounds` [table]: Sounds for various events.
- `fire` [string]: Sound played on fire. Defaults to `gunslinger_fire.ogg`.
- `reload` [string]: Sound played on reload. Defaults to `gunslinger_reload.ogg`.
- `ooa` [string]: Sound played when the gun is out of ammo and ammo isn't available in the player's inventory. Defaults to `gunslinger_ooa.ogg`.
- `load` [string]: Sound played when the gun is manually loaded. Only used if `mode` is set to `manual`.
- `zoom` [number]: Zoom multiplier to be applied on player's default FOV.
- `scope` [string]: Name of scope overlay texture.
- Overlay texture would be stretched across the screen, and center of texture will be positioned on top of crosshair.
- Only required if `zoom` is defined.
- `scope_scale` [table]: Passed to `ObjectRef:hud_add` for the field `scale`.
- Needs to have two numerical values, indexed by `x` and `y`.
- Either of the values can be negative, and would be taken as the percentage of that direction to scale to.
- Only required if `scope` is defined.
## `gunslinger` namespace
The `gunslinger` namespace has the following members:
### "Private" members
(**Note**: _It's not recommended to directly access the private members of the `gunslinger` namespace_)
- `__guns` [table]: Table of registered guns.
- `__types` [table]: Table of registered types.
- `__automatic` [table]: Table of players wielding automatic guns.
- `__scopes` [table]: Table of HUD IDs of scope overlays.
- `__interval` [table]: Table storing time from last fire; used to regulate fire-rate.
### `gunslinger.register_ammo(name, def)`
- Registers ammo.
- `def` [ADT]: Ammo properties. ADTs currently only support the `itemdef` field, but
### `gunslinger.register_type(name, def)`
- Registers a type for `name`.
- `def` [GDT]: Type defaults.
### `gunslinger.register_gun(name, def)`
- Registers a gun with the name `name`.
- `def` [GDT]: Gun properties.
### `gunslinger.get_def(name)`
- Retrieves the [GDT] of the given itemname. Returns `nil` if no registered gun matches `name`.
### `gunslinger.get_config(name)`
- Returns the gunslinger's internal config table (read-only). This table contains, among others, configuration settings and default values used by the API internally. e.g `debug` [bool].
- TODO: Improve this section.
## Misc. helpers
### `rangelim(min, val, max, default)`
- Convenience function used for validating gun definition fields. Returns a range-limited value if `val` exists, or returns `default`.
- `min`, `max` [number]: Allowed minimum and maximum bounds for the value.
- `val` [number]: Value to be validated and returned.
- `default` [number]: Value to be returned if `val` is `nil`.
### `get_eye_pos(player)`
- Returns position of player eye in `v3f` format.
- Equivalent to
```lua
local pos = player:get_pos()
pos.y = pos.y + player:get_properties().eye_height
```
- `player` [ObjectRef]: Player whose eye position is to be calculated.
### `get_pointed_thing(pos, dir, range)`
- Helper function that performs a raycast from player in the direction of player's look dir, and up to the distance defined by `range`.
- `pos` [table]: Initial position of raycast.
- `dir` [table]: Direction of raycast.
- `range` [number]: Range of raycast from `pos` in nodes/meters.
### `play_sound(sound, obj)`
- Helper function to play object-centric sound.
- `sound` [SimpleSoundSpec]: Sound to be played.
- `obj` [ObjectRef]: ObjectRef which is the origin of the played sound.
## Internal API methods
### `add_auto(name, def, stack)`
- Helper function to add player entry to `automatic` table.
- `def` and `stack` are cached locally for improved performance.
- `name` [string]: Player name.
- `def` [GDT]: Wielded gun's GDT.
- `stack` [itemstack]: Itemstack of wielded item.
### `sanitize_def(def)`
- Helper function to check for and correct erroneous fields and to add default values for missing fields in a GDT.
- Returns the sanitized version of `def`.
- `def` [GDT]: GDT to be sanitized.
### `show_scope(player, scope, zoom)`
- Activates gun scope, handles placement of HUD scope element.
- `player` [ObjectRef]: Player used for HUD element creation.
- `scope` [string]: Name of scope overlay texture.
- `zoom` [number]: FOV that will override player's default FOV.
### `hide_scope(player)`
- De-activates gun scope, removes HUD element.
- `player` [ObjectRef]: Player to remove HUD element from.
### `on_lclick(stack, player)`
- `on_use` callback for all registered guns. This is where most of the firing logic happens.
- Handles gun firing depending on their `mode`.
- [`reload`] is called when the gun's magazine is empty.
- If `mode` is `"automatic"`, an entry is added to the `automatic` table which is parsed by `on_step`.
- `stack` [ItemStack]: ItemStack of wielditem.
- `player` [ObjectRef]: ObjectRef of user.
### `on_rclick(stack, player)`
- `on_place`/`on_secondary_use` callback for all registered guns. Toggles scope view.
- `stack` [ItemStack]: ItemStack of wielditem.
- `player` [ObjectRef]: Right-clicker.
### `reload(stack, player)`
- Reloads stack if ammo exists and plays `def.sounds.reload`. Otherwise, just plays `def.sounds.ooa`.
- Takes the same arguments as `on_lclick`.
### `fire(stack, player)`
- Responsible for firing one single round and dealing damage if target was hit. Updates wear by `def.unit_wear`.
- If gun is worn out, `reload` is called.
- Takes the same arguments as `on_lclick`.
### `burst_fire(stack, player)`
- Helper method to fire in burst mode.
- Takes the same arguments as `on_lclick`.
### `handle_hit_target(shooter, pthing, stack)`
- Processes target hits. Currently only damages the target by `config.base_dmg * def.dmg_mult` HP.
- `shooter` [ObjectRef]: Player firing the projectile that intersected the target.
- `pthing` [pointed_thing]: Pointed thing corresponding to the intersected target.
- `stack` [ItemStack]: Item used to fire the projectile that intersected the target.
### `auto_fire(dtime)`
- Updates player's time from last shot (`gunslinger.__interval`).
- Calls `fire` for all guns in the `automatic` table if player's LMB is pressed.
- If LMB is released, the respective entry is removed from the table.
- `dtime` [number]: Delta-time (in seconds) passed to all globalsteps.
### `process_progressive_raycast(dtime)`
- Processes all progressive raycast rounds each server step.
- If the projectile intersects a target, `handle_hit_target` is invoked with the appropriate params.
- If no targets are intersected, the projectile continues on its path.
- `dtime` [number]: Delta-time (in seconds) passed to all globalsteps.

552
mods/gunslinger/api.lua Normal file
View File

@ -0,0 +1,552 @@
gunslinger = {
__guns = {},
__ammo = {},
__types = {},
__stack = {},
__rounds = {},
__reloading = {},
__automatic = {},
__scopes = {},
__interval = {}
}
local config = {
debug = minetest.settings:get_bool("gunslinger.debug", false),
max_wear = 65534,
base_dmg = 1,
projectile_speed = 500,
base_spread = 0.001,
base_recoil = 0.001,
lite = minetest.settings:get_bool("gunslinger.lite"),
fov_transition_time = 0.1
}
local random = PcgRandom(os.time())
local vec = table.copy(vector)
--
-- Internal API functions
--
local function rangelim(low, val, high, default)
if not val and default then
return default
elseif low and val and high then
return math.max(low, math.min(val, high))
else
error("gunslinger: Invalid rangelim invocation!", 2)
end
end
local function get_eye_pos(player)
if not player then
return
end
local pos = player:get_pos()
pos.y = pos.y + player:get_properties().eye_height
return pos
end
local function get_pointed_thing(pos, dir, range, avoid_self)
if not pos or not dir or not range then
error("gunslinger: Invalid get_pointed_thing invocation" ..
" (missing params)", 2)
end
local pos2 = vector.add(pos, vector.multiply(dir, range))
local ray = minetest.raycast(pos, pos2)
local pthing = ray:next()
-- pointer.intersection_normal is a zero vector
-- if ray originates from inside pointed_thing
if avoid_self and pthing and
vector.equals(pthing.intersection_normal, vector.new(0, 0, 0)) then
pthing = ray:next()
end
return pthing
end
local function play_sound(sound, player)
minetest.sound_play(sound, {
object = player,
loop = false
})
end
local function add_auto(name, def, stack)
gunslinger.__automatic[name] = {
def = def,
stack = stack
}
end
local function sanitize_def(def)
if type(def) ~= "table" then
error("gunslinger: Gun definition has to be a table!", 2)
end
if (def.mode == "automatic" or def.mode == "hybrid") and config.lite then
error("gunslinger: Attempting to register gun of type '" ..
def.mode .. "' when lite mode is enabled", 2)
end
-- Check for ammo
if def.ammo then
assert(gunslinger.__ammo[def.ammo], "gunslinger.register_gun: Invalid ammo!")
else
def.ammo = "gunslinger:default_ammo"
end
if def.mode == "burst" then
def.burst = rangelim(2, def.burst, 5, 3)
end
def.dmg_mult = rangelim(1, def.dmg_mult, 100, 1)
def.reload_time = rangelim(1, def.reload_time, 10, 2.5)
def.spread_mult = rangelim(0, def.spread_mult, 500, 0)
def.recoil_mult = rangelim(0, def.recoil_mult, 500, 0)
def.pellets = rangelim(1, def.pellets, 20, 1)
def.sounds = def.sounds or {}
def.sounds.fire = def.sounds.fire or "gunslinger_fire"
def.sounds.reload = def.sounds.reload or "gunslinger_reload"
def.sounds.ooa = def.sounds.ooa or "gunslinger_ooa"
def.textures = def.textures or {}
def.textures.projectile = def.textures.projectile or "gunslinger_projectile.png"
-- Limit zoom to 8x; default to no zoom
def.zoom = def.zoom and rangelim(1, def.zoom, 8)
local scale = def.scope_scale
if def.scope and (not scale or type(scale) ~= "table" or not scale.x or not scale.y or
type(scale.x) ~= "number" or type(scale.y) ~= "number") then
error("gunslinger: Invalid `scope_scale` definition!", 2)
end
return def
end
--------------------------------
local function show_scope(player, zoom, scope, scale)
if not player then
return
end
local scope_spec = { fov = zoom }
-- Set FOV multiplier to 1 / def.zoom
-- e.g. if def.zoom == 4, FOV multiplier would be 1/4
player:set_fov(1 / zoom, true, config.fov_transition_time)
-- Scope HUD element; disable wielditem and crosshair HUD elements if scope exists
if scope then
scope_spec.hud = player:hud_add({
hud_elem_type = "image",
position = {x = 0.5, y = 0.5},
alignment = {x = 0, y = 0},
scale = scale,
text = scope
})
player:hud_set_flags({
wielditem = false,
crosshair = false
})
end
gunslinger.__scopes[player:get_player_name()] = scope_spec
end
local function hide_scope(player)
if not player then
return
end
local name = player:get_player_name()
local scope_spec = gunslinger.__scopes[name]
player:set_fov(0, false, config.fov_transition_time)
-- Remove scope HUD element; revert visibility changes to default HUD elements
if scope_spec.hud then
player:hud_remove(scope_spec.hud)
player:hud_set_flags({
wielditem = true,
crosshair = true
})
end
gunslinger.__scopes[name] = nil
end
--------------------------------
local function reload(stack, player)
-- Check for ammo
local inv = player:get_inventory()
local def = gunslinger.__guns[stack:get_name()]
local meta = stack:get_meta()
if meta:contains("reloading") then
return
end
local taken = inv:remove_item("main", def.ammo .. " " .. def.clip_size)
if taken:is_empty() then
play_sound(def.sounds.ooa, player)
else
local name = player:get_player_name()
gunslinger.__interval[name] = gunslinger.__interval[name] - def.reload_time
play_sound(def.sounds.reload, player)
meta:set_string("reloading")
local wear = math.floor(config.max_wear -
(taken:get_count() / def.clip_size) * config.max_wear)
minetest.after(def.reload_time, function()
stack:set_wear(wear)
meta:set_string("reloading", "")
player:set_wielded_item(stack)
end)
end
return stack
end
local function fire(stack, player)
if not stack then
return
end
local name = player:get_player_name()
local def = gunslinger.__guns[stack:get_name()]
if not def then
return stack
end
local wear = stack:get_wear()
if wear == config.max_wear then
gunslinger.__automatic[name] = nil
return reload(stack, player)
end
-- Play gunshot sound
play_sound(def.sounds.fire, player)
local pos = get_eye_pos(player)
local dir = player:get_look_dir()
-- Apply projectile engine to each pellet
for i = 1, def.pellets do
-- Mimic inaccuracy by applying randomized miniscule deviations
-- Reduce inaccuracy by half if player is using scope
if def.spread_mult ~= 0 then
-- TODO: Unhardcode scoping factor by taking scope FOVs into consideration
local scoping_factor = gunslinger.__scopes[name] and 0.5 or 1
dir = vector.apply(dir, function(n)
return n +
random:next(-def.spread_mult, def.spread_mult) *
config.base_spread * scoping_factor
end)
end
--
-- Progressive Raycasting
--
-- Tracks and simulates individual projectiles until they hit a target
--
-- Insert round_spec
gunslinger.__rounds[#gunslinger.__rounds + 1] = {
shooter = name,
stack = player:get_wielded_item(),
initial_pos = pos,
pos = pos,
dir = dir,
range = def.range,
speed = config.projectile_speed
}
-- Projectile particle
minetest.add_particle({
pos = pos,
velocity = vector.multiply(dir, config.projectile_speed),
expirationtime = def.range / config.projectile_speed,
size = 3,
texture = def.textures.projectile,
collisiondetection = true,
collision_removal = true,
object_collision = true,
glow = 10
})
end
-- Simulate recoil
local offset = config.base_recoil * def.recoil_mult
local look_vertical = player:get_look_vertical() - offset
look_vertical = rangelim(-math.pi / 2, look_vertical, math.pi / 2)
player:set_look_vertical(look_vertical)
-- Update wear
wear = stack:get_wear() + def.unit_wear
if wear > config.max_wear then
wear = config.max_wear
end
stack:set_wear(wear)
return stack
end
local function burst_fire(stack, player)
local def = gunslinger.__guns[stack:get_name()]
for i = 1, def.burst do
minetest.after(i / def.fire_rate, function(...)
-- Use global var to store stack, because the stack
-- can't be directly accessed outside minetest.after
gunslinger.__stack[arg[2]:get_player_name()] = fire(arg[1], arg[2])
end, stack, player)
end
return gunslinger.__stack[player:get_player_name()]
end
--------------------------------
local function handle_hit_target(shooter, pthing, stack)
-- TODO: Run on_hit callbacks here
if config.debug then
local pthing_str
if pthing.type == "object" then
local obj = pthing.ref
if obj:is_player() then
pthing_str = "[Player] " .. obj:get_player_name()
else
pthing_str = "[Entity] " .. obj:get_luaentity()
end
else
pthing_str = minetest.get_node(pthing.under).name
end
minetest.chat_send_all("handle_hit_target\n-----------------" ..
"\n\tstack=" .. stack:to_string() .. "\n\tpthing=" .. pthing_str)
end
if pthing.type == "object" then
pthing.ref:punch(shooter, nil, {damage_groups = {
fleshy = config.base_dmg * gunslinger.__guns[stack:get_name()].dmg_mult
}})
end
end
--------------------------------
-- Progressive Raycasting
local function process_progressive_raycast(dtime)
for i, round_spec in pairs(gunslinger.__rounds) do
-- Calculate distance projectile can travel until next iteration
local delta_range = round_spec.speed * dtime
local pointed = get_pointed_thing(round_spec.pos,
round_spec.dir, delta_range, true)
-- We've hit something!
if pointed then
gunslinger.__rounds[i] = nil
-- Invoke handle_hit_target, pass the required data
handle_hit_target(minetest.get_player_by_name(round_spec.shooter),
pointed, round_spec.stack)
end
-- We've hit nothing; continue tracking projectile
local prev_pos = round_spec.pos
round_spec.pos = vec.add(round_spec.pos,
vec.multiply(round_spec.dir, delta_range))
round_spec.dir = vec.direction(prev_pos, round_spec.pos)
-- Spawn particles
if config.debug then
minetest.add_particle({
pos = prev_pos,
expirationtime = 10,
size = 10,
glow = 10
})
end
end
end
minetest.register_globalstep(process_progressive_raycast)
--------------------------------
local function on_lclick(stack, player)
if not stack or not player then
return
end
local def = gunslinger.__guns[stack:get_name()]
if not def then
return
end
local name = player:get_player_name()
if gunslinger.__interval[name] and gunslinger.__interval[name] < def.unit_time then
return
end
gunslinger.__interval[name] = 0
if def.mode == "automatic" and not gunslinger.__automatic[name] then
stack = fire(stack, player)
add_auto(name, def, stack)
elseif def.mode == "hybrid"
and not gunslinger.__automatic[name] then
if gunslinger.__scopes[name] then
stack = burst_fire(stack, player)
else
add_auto(name, def)
end
elseif def.mode == "burst" then
stack = burst_fire(stack, player)
elseif def.mode == "semi-automatic" then
stack = fire(stack, player)
elseif def.mode == "manual" then
local meta = stack:get_meta()
local loaded = meta:get("loaded")
if not loaded then
if def.sounds.load then
play_sound(def.sounds.load, player)
end
meta:set_string("loaded", "true")
stack = reload(stack, player)
else
meta:set_string("loaded", "")
stack = fire(stack, player)
end
end
return stack
end
local function on_rclick(stack, player)
local def = gunslinger.__guns[stack:get_name()]
if gunslinger.__scopes[player:get_player_name()] then
hide_scope(player)
else
if def.zoom then
show_scope(player, def.zoom, def.scope, def.scope_scale)
end
end
end
--------------------------------
-- Process automatic fire
local function auto_fire(dtime)
for name in pairs(gunslinger.__interval) do
gunslinger.__interval[name] = gunslinger.__interval[name] + dtime
end
if not config.lite then
for name, info in pairs(gunslinger.__automatic) do
local player = minetest.get_player_by_name(name)
if not player or player:get_hp() <= 0 then
gunslinger.__automatic[name] = nil
elseif gunslinger.__interval[name] > info.def.unit_time then
if player:get_player_control().LMB and
player:get_wielded_item():get_name() ==
info.stack:get_name() then
-- If LMB pressed, fire
info.stack = fire(info.stack, player)
player:set_wielded_item(info.stack)
if gunslinger.__automatic[name] then
gunslinger.__automatic[name].stack = info.stack
end
gunslinger.__interval[name] = 0
else
gunslinger.__automatic[name] = nil
end
end
end
end
end
minetest.register_globalstep(auto_fire)
--
-- Public API functions
--
function gunslinger.get_config()
return table.copy(config)
end
function gunslinger.get_def(name)
return gunslinger.__guns[name]
end
function gunslinger.register_ammo(name, def)
assert(type(name) == "string" and type(def) == "table",
"gunslinger.register_ammo: Invalid params!")
assert(not gunslinger.__ammo[name], "gunslinger.register_ammo:" ..
" Attempt to register new ammo with an existing name!")
-- TODO: Generalize sanitize_def to work with all definition tables
assert(def.itemdef and type(def.itemdef) == "table",
"gunslinger.register_ammo: Invalid Ammo Definition Table!")
gunslinger.__ammo[name] = def
minetest.register_craftitem(name, def.itemdef)
end
function gunslinger.register_type(name, def)
assert(type(name) == "string" and type(def) == "table",
"gunslinger.register_type: Invalid params!")
assert(not gunslinger.__types[name], "gunslinger.register_type:" ..
" Attempt to register new type with an existing name!")
gunslinger.__types[name] = def
end
function gunslinger.register_gun(name, def)
assert(type(name) == "string" and type(def) == "table",
"gunslinger.register_gun: Invalid params!")
assert(not gunslinger.__guns[name], "gunslinger.register_gun: " ..
"Attempt to register new gun with an existing name!")
-- Import type defaults if def.type specified
-- This should be the first field to be parsed for the types system to work properly
if def.type then
assert(gunslinger.__types[def.type], "gunslinger.register_gun: Invalid type!")
for attr, val in pairs(gunslinger.__types[def.type]) do
def[attr] = val
end
end
def = sanitize_def(def)
-- Add additional helper fields for internal use
def.unit_wear = math.ceil(config.max_wear / def.clip_size)
def.unit_time = 1 / def.fire_rate
-- Register gun
gunslinger.__guns[name] = def
def.itemdef.on_use = on_lclick
def.itemdef.on_secondary_use = on_rclick
def.itemdef.on_place = function(stack, player, pointed)
if pointed.type == "node" then
local node = minetest.get_node_or_nil(pointed.under)
local nodedef = minetest.registered_items[node.name]
return nodedef.on_rightclick or on_rclick(stack, player)
elseif pointed.type == "object" then
local entity = pointed.ref:get_luaentity()
return entity:on_rightclick(player) or on_rclick(stack, player)
end
end
-- Register tool
minetest.register_tool(name, def.itemdef)
end

17
mods/gunslinger/guns.lua Normal file
View File

@ -0,0 +1,17 @@
gunslinger.register_gun("gunslinger:cheetah", {
itemdef = {
description = "Cheetah (Assault Rifle)",
inventory_image = "gunslinger_cheetah.png",
wield_image = "gunslinger_cheetah.png^[transformFXR300",
wield_scale = {x = 3, y = 3, z = 1}
},
mode = "automatic",
dmg_mult = 2,
recoil_mult = 5,
fire_rate = 8,
clip_size = 30,
range = 80
})
minetest.register_alias("cheetah", "gunslinger:cheetah")