--[[ 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