mirror of
https://github.com/Noodlemire/projectile.git
synced 2024-12-12 08:43:18 +01:00
439 lines
17 KiB
Lua
439 lines
17 KiB
Lua
--[[
|
|
Research N' Duplication
|
|
Copyright (C) 2020 Noodlemire
|
|
|
|
This library is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU Lesser General Public
|
|
License as published by the Free Software Foundation; either
|
|
version 2.1 of the License, or (at your option) any later version.
|
|
|
|
This library is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
Lesser General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
License along with this library; if not, write to the Free Software
|
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
--]]
|
|
|
|
--This function registers a weapon able to shoot projectiles
|
|
function projectile.register_weapon(name, def)
|
|
--either create a groups table for the definition, or use the provided one
|
|
def.groups = def.groups or {}
|
|
--Every projectile weapon belongs to the projectile_weapon group.
|
|
def.groups.projectile_weapon = 1
|
|
|
|
--Charge time defaults to 1 second
|
|
def.charge_time = def.charge_time or 1
|
|
--The weapon's damage multiplier defaults to 1.
|
|
def.damage = def.damage or 1
|
|
--The weapon's speed multiplier defaults to 1.
|
|
def.speed = def.speed or 1
|
|
|
|
--A function that determines when a weapon can fire. If none is provided in a definition, a weapon can always fire.
|
|
--Note that it does not prevent the need for ammunition.
|
|
def.can_fire = def.can_fire or function() return true end
|
|
|
|
--If this weapons has to be charged...
|
|
if def.charge then
|
|
--Define a function to reset the weapon's sprite and delete the player's charge data.
|
|
local uncharge = function(wep, user, cancelled)
|
|
--If nothing was fired and the weapon defines an on_cancel function, call it.
|
|
if cancelled and def.on_cancel then
|
|
def.on_cancel(wep, user)
|
|
end
|
|
|
|
if projectile.charge_levels[user:get_player_name()] and projectile.charge_levels[user:get_player_name()].sound then
|
|
minetest.sound_stop(projectile.charge_levels[user:get_player_name()].sound)
|
|
end
|
|
|
|
--Delete charge data.
|
|
projectile.charge_levels[user:get_player_name()] = nil
|
|
|
|
--Change the name of the stack to transform it back to its uncharged form.
|
|
wep:set_name(name)
|
|
|
|
return wep
|
|
end
|
|
|
|
--A function that begins a new charge, or fires a shot if the player is charging.
|
|
local charge = function(wep, user)
|
|
--If it is allowed to fire...
|
|
if def.can_fire(wep, user) then
|
|
local pname = user:get_player_name()
|
|
|
|
--If there is no charge data yet...
|
|
if not projectile.charge_levels[pname] then
|
|
local inv = user:get_inventory()
|
|
|
|
--Look for ammo in the player's inventory, starting from the first slot.
|
|
for i = 1, inv:get_size("main") do
|
|
--Get the itemstack of the current slot.
|
|
local ammo = inv:get_stack("main", i)
|
|
|
|
--If the stack is there, and it's registered as ammo that this weapon can use...
|
|
if not ammo:is_empty() and projectile.registered_projectiles[def.rw_category][ammo:get_name()] then
|
|
--Create new charge data. Store the inventory slot of the weapon, and start the charge at 0
|
|
projectile.charge_levels[pname] = {slot = user:get_wield_index(), charge = 0}
|
|
|
|
--As feedback for the charge beginning, change the weapon's sprite to show it loaded.
|
|
--I originally wanted the item to be shown loaded with specific ammo, but it doesn't seem to be possible.
|
|
wep = ItemStack({name = name.."_2", wear = wep:get_wear()})
|
|
|
|
--If a callback is defined to do something when a charge begins, call it now.
|
|
if def.on_charge_begin then
|
|
def.on_charge_begin(wep, user)
|
|
end
|
|
|
|
--Once ammo is found, the search can be stopped.
|
|
break
|
|
end
|
|
end
|
|
|
|
--If no ammo was found, a charge won't start at all. No dry-firing allowed.
|
|
|
|
--Otherwise, if there is charge data...
|
|
else
|
|
--If a callback is defined to do something right before firing, call it now.
|
|
if def.on_fire then
|
|
def.on_fire(wep, user)
|
|
end
|
|
|
|
--Shoot out the projectile
|
|
projectile.shoot(wep, user, projectile.charge_levels[pname].charge)
|
|
|
|
--If a callback is defined to do something right after firing, call it now.
|
|
if def.after_fire then
|
|
def.after_fire(wep, user)
|
|
end
|
|
|
|
--Then, end the charge
|
|
uncharge(wep, user, false)
|
|
end
|
|
end
|
|
|
|
return wep
|
|
end
|
|
|
|
--Right-click to start a charge. Right-click again to fire.
|
|
def.on_place = charge
|
|
def.on_secondary_use = charge
|
|
--Left-click to cancel a charge without firing.
|
|
def.on_use = uncharge
|
|
|
|
--Start the creating of the partially and fully charged versions of this item, first by copying the definition.
|
|
local def2 = table.copy(def)
|
|
local def3 = table.copy(def)
|
|
|
|
--The partially and fully-charged versions have specific inventory images
|
|
def2.inventory_image = def.inventory_image_2
|
|
def3.inventory_image = def.inventory_image_3
|
|
--The projectile_weapon group rating increases with charge level
|
|
def2.groups.projectile_weapon = 2
|
|
def3.groups.projectile_weapon = 3
|
|
--Partially charged weapons cannot be grabbed from the creative inventory.
|
|
def2.groups.not_in_creative_inventory = 1
|
|
def3.groups.not_in_creative_inventory = 1
|
|
|
|
--Weapons that need charging can only be fired before fully charging if def.fire_while_charging is set to true.
|
|
if def.charge and not def.fire_while_charging then
|
|
def2.on_place = nil
|
|
def2.on_secondary_use = nil
|
|
end
|
|
|
|
--Some versions store the names of different versions, for convenience.
|
|
--The partially-charged version stores the name of the fully charged version, to be used when transitioning to the fully charged version.
|
|
def2.full_charge_name = name.."_3"
|
|
--Full and partial charge versions can both be cancelled, so they remember the name of the uncharged version
|
|
def2.no_charge_name = name
|
|
def3.no_charge_name = name
|
|
|
|
--Finally, register the partially and fully charged projectile weapons.
|
|
minetest.register_tool(name.."_2", def2)
|
|
minetest.register_tool(name.."_3", def3)
|
|
else
|
|
--Otherwise, right-click simply shoots the projectile.
|
|
def.on_place = function(wep, user)
|
|
--If it is allowed to fire...
|
|
if def.can_fire(wep, user) then
|
|
--Shoot every time on_place is called.
|
|
projectile.shoot(wep, user)
|
|
end
|
|
end
|
|
|
|
def.on_secondary_use = def.on_place
|
|
end
|
|
|
|
--Finally, register the projectile weapon here.
|
|
--This is the only thing that happens regardless of if the weapon has to charge or not.
|
|
minetest.register_tool(name, def)
|
|
end
|
|
|
|
--Register a projectile that can be fired by a weapon.
|
|
--Note that it also has to define what kind of weapon can fire it, and the item version of itself.
|
|
function projectile.register_projectile(name, usable_by, ammo, def)
|
|
--First, check that a table exists for that particular weapon category. If not, make it.
|
|
projectile.registered_projectiles[usable_by] = projectile.registered_projectiles[usable_by] or {}
|
|
--Then, add this projectile to said table.
|
|
projectile.registered_projectiles[usable_by][ammo] = name
|
|
|
|
--Default initial properties for the projectile
|
|
--Including the table itself, if it wasn't already created.
|
|
def.initial_properties = def.initial_properties or {}
|
|
--The projectile is always physical. It has to hit stuff, after all.
|
|
def.initial_properties.physical = true
|
|
--The projectile also definitely has to be able to hit other entities.
|
|
def.initial_properties.collide_with_objects = true
|
|
--By default, the projectile's hitbox is half a block in size.
|
|
def.initial_properties.collisionbox = def.initial_properties.collisionbox or {-.25, 0, -.25, .25, .5, .25}
|
|
--The projectile can't be hit by players.
|
|
def.initial_properties.pointable = false
|
|
--By default, the projectile is a flat image, provided by the "image" field.
|
|
def.initial_properties.visual = def.initial_properties.visual or "sprite"
|
|
def.initial_properties.textures = def.initial_properties.textures or {def.image}
|
|
--By default, the projectile's visual size is also half size.
|
|
def.initial_properties.visual_size = def.initial_properties.visual_size or {x = 0.5, y = 0.5, z = 0.5}
|
|
--The projectile won't be saved if it becomes unloaded.
|
|
def.initial_properties.static_save = false
|
|
|
|
--collide_self allows projectiles fired by the same person to strike each other. This is true by default.
|
|
if def.collide_self == nil then
|
|
def.collide_self = true
|
|
end
|
|
|
|
--"visible" can be used as a shorthand to set itself in initial_properties.
|
|
if def.visible == false then
|
|
def.initial_properties.is_visible = false
|
|
end
|
|
|
|
--During each of this entity's steps...
|
|
def.on_step = function(self, dtime, info)
|
|
--Let projectiles define their own on_step if they need to
|
|
if self._on_step then
|
|
self._on_step(self, dtime, info)
|
|
end
|
|
|
|
--A little shorthand
|
|
local selfo = self.object
|
|
--By default, assume nothing was hit this step.
|
|
local hit = false
|
|
|
|
--selfo:get_pos() is used to know if remove() was called in the _on_step function.
|
|
if not selfo:get_pos() then
|
|
return
|
|
end
|
|
|
|
--For each collision that was found...
|
|
for k, c in pairs(info.collisions) do
|
|
--If it's a node, don't do anything more than acknowledging that something was hit.
|
|
if c.type == "node" then
|
|
hit = true
|
|
|
|
--Get the definition of the node that was hit...
|
|
local ndef = minetest.registered_nodes[minetest.get_node(c.node_pos).name]
|
|
--If the definition exists and defines a sound for being dug...
|
|
if ndef and ndef.sounds and ndef.sounds.dug then
|
|
--Play that sound
|
|
minetest.sound_play(ndef.sounds.dug, {gain = 1.0, pos = c.node_pos}, true)
|
|
end
|
|
|
|
--If it's an object...
|
|
else
|
|
--As long as that object isn't the player who fired this projectile and the target isn't already dead...
|
|
--And the target isn't a projectile owned by the same player when collide_self is disabled...
|
|
--And the target isn't in the same party as this projectile's owner...
|
|
if not (c.object:is_player() and self.owner == c.object:get_player_name()) and c.object:get_hp() > 0
|
|
and not (self.collide_self == false and not c.object:is_player() and self.owner == c.object:get_luaentity().owner)
|
|
and not projectile.in_same_party(self, c.object) then
|
|
--Acknowledge the hit
|
|
hit = true
|
|
--Deal damage to the target.
|
|
c.object:punch(selfo, 1, {full_punch_interval = 1, damage_groups = {fleshy = def.damage * self.level * self.damage}}, vector.normalize(selfo:get_velocity()))
|
|
else
|
|
--Otherwise, pass by the object as best as possible.
|
|
selfo:set_velocity(self.oldvel)
|
|
end
|
|
end
|
|
end
|
|
|
|
--If this projectile hit something...
|
|
if hit then
|
|
--Grant the entity an on_impact function that it can define
|
|
if self.on_impact then
|
|
self:on_impact(info.collisions)
|
|
end
|
|
|
|
if node_damage and minetest.settings:get_bool("placeable_impacts_damage_nodes") then
|
|
for _, c in pairs(info.collisions) do
|
|
if c.type == "node" then
|
|
node_damage.damage(c.node_pos)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
--Make the projectile destroy itself.
|
|
selfo:remove()
|
|
end
|
|
end
|
|
|
|
--Finally, register the entity.
|
|
minetest.register_entity(name, def)
|
|
end
|
|
|
|
--A function that creates and launches a function out of a player's side when they use a projectile weapon.
|
|
function projectile.shoot(wep, user, level)
|
|
--Some useful shorthands
|
|
local pname = user:get_player_name()
|
|
local inv = user:get_inventory()
|
|
local def = wep:get_definition()
|
|
|
|
--A projectile isn't spawned directly inside a player, and it doesn't come from the center of the screen.
|
|
--It does start directly in front of the player...
|
|
local pos = user:get_look_dir()
|
|
--But then it's shifted to the right of the player, where it looks like the weapon is held.
|
|
pos = vector.rotate(pos, {x=0 , y = -math.pi / 4, z=0})
|
|
--Then it's shifted up by the player's face.
|
|
pos.y = 1
|
|
--The user's actual position is added last, to make rotating easier.
|
|
pos = vector.add(pos, user:get_pos())
|
|
|
|
--Charge level depends on how long the player waited before firing. 1 = 100% charge.
|
|
level = math.min(level / def.charge_time, 1)
|
|
|
|
--Look through each inventory slot...
|
|
for i = 1, inv:get_size("main") do
|
|
--Get the stack itself
|
|
local ammo = inv:get_stack("main", i)
|
|
--Get the name of the entity that will be created by this ammo type.
|
|
local ammo_entity = projectile.registered_projectiles[def.rw_category][ammo:get_name()]
|
|
|
|
--If there is an item stack, and it's registered as an ammo type that this weapon can use...
|
|
if not ammo:is_empty() and ammo_entity then
|
|
local adef = minetest.registered_entities[ammo_entity]
|
|
|
|
--Fire an amount of projectiles at once according to the ammo's defined "count".
|
|
for n = 1, (adef.count or 1) do
|
|
--Create the projectile entity at the determined position
|
|
local projectile = minetest.add_entity(pos, ammo_entity)
|
|
--A shorthand of the luaentity version of the projectile, where data can easily be stored
|
|
local luapro = projectile:get_luaentity()
|
|
|
|
--Set velocity according to the direction it was fired. Speed is determined by the weapon, ammo, and how long the weapon was charged.
|
|
projectile:set_velocity(vector.multiply(user:get_look_dir(), luapro.speed * level * def.speed))
|
|
--An acceleration of -9.81y is how gravity is applied.
|
|
projectile:set_acceleration({x=0, y=-9.81, z=0})
|
|
|
|
--If the ammo defines a spread, randomly rotate the direction of velocity by that given radius.
|
|
if adef.spread then
|
|
local rx = (math.random() * adef.spread * 2 - adef.spread) * math.pi / 180
|
|
local ry = (math.random() * adef.spread * 2 - adef.spread) * math.pi / 180
|
|
|
|
projectile:set_velocity(vector.rotate(projectile:get_velocity(), {x = rx, y = ry, z = 0}))
|
|
end
|
|
|
|
--Store level for later, to determine impact damage
|
|
luapro.level = level
|
|
--Also store the projectile's damage itself.
|
|
luapro.damage = def.damage
|
|
--The player's name is stored to prevent hitting yourself
|
|
--And by "hitting yourself" I mean accidentally being hit by the arrow just by firing it at a somewhat low angle, the moment it spawns.
|
|
luapro.owner = pname
|
|
--Store the initial velocity for passing by objects when needed.
|
|
luapro.oldvel = projectile:get_velocity()
|
|
end
|
|
|
|
--If the player isn't in creative mode, some weapon durability and ammo is consumed.
|
|
if not minetest.is_creative_enabled(pname) then
|
|
ammo:take_item(1)
|
|
inv:set_stack("main", i, ammo)
|
|
|
|
wep:add_wear(65536 / (def.durability or 100))
|
|
end
|
|
|
|
--Once the ammo is found, the search is stopped.
|
|
break
|
|
end
|
|
end
|
|
|
|
return wep
|
|
end
|
|
|
|
--A helper function to know when the party of a projectile's owner and target is the same.
|
|
function projectile.in_same_party(projectile, target)
|
|
--Automatically return false if:
|
|
--The parties mod isn't included.
|
|
--The target isn't a player.
|
|
--The projectile's target and/or owner isn't in a party/
|
|
if not parties or not target:is_player() or not parties.is_player_in_party(projectile.owner) or not parties.is_player_in_party(target:get_player_name()) then
|
|
return false
|
|
end
|
|
|
|
--Return true only if the projectile's owner and target and under the same party leadership.
|
|
return parties.get_party_leader(projectile.owner) == parties.get_party_leader(target:get_player_name())
|
|
end
|
|
|
|
--A helper functions for arrows in general, as they rotate themselves according to how they move.
|
|
function projectile.autorotate_arrow(self)
|
|
--Shorthand for velocity
|
|
local v = self.object:get_velocity()
|
|
--Set calculate rotation according to velocity
|
|
local rot = vector.dir_to_rotation(v)
|
|
|
|
--Define a timer for itself. Based on how fast its currently moving, and how far the timer has progressed,
|
|
--this makes it seem to spin through the air, with the tip still always pointing forward.
|
|
self.timer = (self.timer or 0) + (v.x + v.y + v.z) / 30
|
|
rot.z = rot.z + self.timer
|
|
|
|
--Apply the calculated rotation.
|
|
self.object:set_rotation(rot)
|
|
end
|
|
|
|
--A can_fire function for flintlock weapons that enforces a gunpowder requirement, in addition to the usual ammo needs
|
|
function projectile.needs_gunpowder(wep, user)
|
|
--Automatically return true if creative mode is enabled or the weapon is already firing.
|
|
if minetest.is_creative_enabled(user:get_player_name()) or projectile.charge_levels[user:get_player_name()] then
|
|
return true
|
|
end
|
|
|
|
--Shorthand to get the user's inventory
|
|
local inv = user:get_inventory()
|
|
|
|
--For each slot in the user's inventory...
|
|
for i = 1, inv:get_size("main") do
|
|
--Get the current stack at this index.
|
|
local stack = inv:get_stack("main", i)
|
|
|
|
--If this stack contains gunpowder...
|
|
if stack:get_name() == "tnt:gunpowder" then
|
|
--Consume some.
|
|
stack:take_item()
|
|
--Update the inventory.
|
|
inv:set_stack("main", i, stack)
|
|
|
|
--Allow firing.
|
|
return true
|
|
end
|
|
end
|
|
|
|
--If no gunpowder could be consumed, disallow firing.
|
|
return false
|
|
end
|
|
|
|
--A flintlock's can_fire function consumes gunpowder before letting the weapon charge.
|
|
--So, if that charge was cancelled, gunpowder should be returned.
|
|
function projectile.return_gunpowder(wep, user)
|
|
--In creative mode, nothing gets taken, so nothing needs to be returned.
|
|
if not minetest.is_creative_enabled(user:get_player_name()) then
|
|
--Try adding the gunpowder back into the inventory. Store the leftover stack.
|
|
local leftover = user:get_inventory():add_item("main", ItemStack({name = "tnt:gunpowder"}))
|
|
|
|
--If the gunpowder couldn't be added due to the inventory being full...
|
|
if not leftover:is_empty() then
|
|
--Drop it on the ground instead.
|
|
minetest.add_item(user:get_pos(), leftover)
|
|
end
|
|
end
|
|
end
|