536 lines
19 KiB
Lua
536 lines
19 KiB
Lua
--[[
|
||
Leads — Adds leads for transporting animals to Minetest.
|
||
Copyright © 2023, Silver Sandstone <@SilverSandstone@craftodon.social>
|
||
|
||
Permission is hereby granted, free of charge, to any person obtaining a
|
||
copy of this software and associated documentation files (the "Software"),
|
||
to deal in the Software without restriction, including without limitation
|
||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||
and/or sell copies of the Software, and to permit persons to whom the
|
||
Software is furnished to do so, subject to the following conditions:
|
||
|
||
The above copyright notice and this permission notice shall be included in
|
||
all copies or substantial portions of the Software.
|
||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||
DEALINGS IN THE SOFTWARE.
|
||
]]
|
||
|
||
|
||
--- Lead entity definition.
|
||
-- @module leads
|
||
|
||
|
||
local S = leads.S;
|
||
|
||
|
||
leads.SLACK_MODELS =
|
||
{
|
||
[0] = 'leads_lead.obj',
|
||
'leads_lead_slack1.obj',
|
||
'leads_lead_slack2.obj',
|
||
'leads_lead_slack3.obj',
|
||
'leads_lead_slack4.obj',
|
||
'leads_lead_slack5.obj',
|
||
'leads_lead_slack6.obj',
|
||
'leads_lead_slack7.obj',
|
||
'leads_lead_slack8.obj',
|
||
'leads_lead_slack9.obj',
|
||
'leads_lead_slack10.obj',
|
||
'leads_lead_slack11.obj',
|
||
'leads_lead_slack12.obj',
|
||
};
|
||
|
||
leads.STRETCH_SOUND_INTERVAL = 2.0;
|
||
|
||
if leads.settings.drop_mode == 'drop' then
|
||
leads.DROP_ITEM = true;
|
||
elseif leads.settings.drop_mode == 'give' then
|
||
leads.DROP_ITEM = false;
|
||
else
|
||
leads.DROP_ITEM = (minetest.get_modpath('mcl_core') or minetest.get_modpath('rp_default') or minetest.get_modpath('item_drop')) ~= nil;
|
||
end;
|
||
|
||
|
||
--- The main lead entity.
|
||
-- @type LeadEntity
|
||
leads.LeadEntity = {};
|
||
|
||
leads.LeadEntity.description = S'Lead';
|
||
leads.LeadEntity._leads_immobile = true;
|
||
|
||
leads.LeadEntity.initial_properties =
|
||
{
|
||
visual = 'mesh';
|
||
mesh = 'leads_lead.obj';
|
||
textures = {leads.DEFAULT_LEAD_TEXTURE};
|
||
physical = false;
|
||
selectionbox = {-0.5, -0.5, -0.5, 0.5, 0.5, 0.5};
|
||
};
|
||
|
||
--- Spawns or unloads a lead.
|
||
function leads.LeadEntity:on_activate(staticdata, dtime_s)
|
||
self.current_length = 0.24;
|
||
self.max_length = leads.settings.lead_length;
|
||
self.rotation = vector.zero();
|
||
self.leader_attach_offset = vector.zero();
|
||
self.follower_attach_offset = vector.zero();
|
||
self.sound_timer = 0.0;
|
||
self.item = ItemStack();
|
||
self.texture = leads.DEFAULT_LEAD_TEXTURE;
|
||
self.strength = leads.settings.lead_strength;
|
||
self.breaking = 0.0;
|
||
|
||
local data = minetest.deserialize(staticdata);
|
||
if data then
|
||
self:load_from_data(data);
|
||
end;
|
||
|
||
self.object:set_armor_groups{fleshy = 0};
|
||
end;
|
||
|
||
--- Initialises the lead's state from a table.
|
||
function leads.LeadEntity:load_from_data(data)
|
||
self.max_length = data.max_length or self.max_length;
|
||
self.leader_id = data.leader_id or {};
|
||
self.follower_id = data.follower_id or {};
|
||
self.leader_attach_offset = data.leader_attach_offset or self.leader_attach_offset;
|
||
self.follower_attach_offset = data.follower_attach_offset or self.follower_attach_offset;
|
||
|
||
self.leader_id.pos = vector.new(self.leader_id.pos);
|
||
self.follower_id.pos = vector.new(self.follower_id.pos);
|
||
|
||
if data.item then
|
||
self:set_item(data.item);
|
||
end;
|
||
|
||
self:update_visuals();
|
||
end;
|
||
|
||
--- Sets the lead's item, updating relevant properties.
|
||
function leads.LeadEntity:set_item(item)
|
||
item = ItemStack(item);
|
||
self.item = item;
|
||
local def = item:get_definition();
|
||
|
||
self.strength = def._leads_strength or leads.settings.lead_strength;
|
||
self.max_length = def._leads_length or leads.settings.lead_length;
|
||
self.texture = def._leads_texture or leads.DEFAULT_LEAD_TEXTURE;
|
||
|
||
if not leads.settings.dynamic_textures then
|
||
self.object:set_properties{textures = {self.texture}};
|
||
end;
|
||
end;
|
||
|
||
--- Steps the knot.
|
||
function leads.LeadEntity:on_step(dtime)
|
||
self:_update_connectors();
|
||
local success, pos, offset = self:step_physics(dtime);
|
||
if success then
|
||
self.current_length = leads.util.clamp(offset:length(), 0.25, 256);
|
||
self.rotation = offset:dir_to_rotation();
|
||
self.object:move_to(pos, true);
|
||
self:update_visuals();
|
||
end;
|
||
end;
|
||
|
||
--- Simulates the lead's physics.
|
||
-- @param dtime [number] The time elapsed since the last tick, in seconds.
|
||
-- @return [boolean] true if the lead is functioning correctly, or false if it should break.
|
||
-- @return [vector|nil] The centre position of the lead, or nil on failure.
|
||
-- @return [vector|nil] The offset between the leader and the follower, or nil on failure.
|
||
function leads.LeadEntity:step_physics(dtime)
|
||
dtime = math.min(dtime, 0.125);
|
||
|
||
local l_pos = self.leader_pos;
|
||
local f_pos = self.follower_pos;
|
||
if not (l_pos and f_pos) then
|
||
self:break_lead();
|
||
return false, nil, nil;
|
||
end;
|
||
|
||
l_pos = l_pos + self.leader_attach_offset;
|
||
f_pos = f_pos + self.follower_attach_offset;
|
||
|
||
local pull_distance = self.max_length;
|
||
local break_distance = pull_distance * 2;
|
||
local distance = l_pos:distance(f_pos);
|
||
if distance > break_distance then
|
||
-- Lead is too long, break:
|
||
local overextension = distance - break_distance;
|
||
self.breaking = self.breaking + overextension * dtime;
|
||
if self.breaking > self.strength then
|
||
self:break_lead(nil, true);
|
||
return false, nil, nil;
|
||
end;
|
||
else
|
||
self.breaking = 0.0;
|
||
end;
|
||
|
||
local pos = (f_pos + l_pos) / 2;
|
||
if self.leader and self.follower and distance > pull_distance then
|
||
local base_force;
|
||
|
||
local function _pull_connector(connector, this_pos, other_pos)
|
||
if leads.is_immobile(connector) then
|
||
return;
|
||
end;
|
||
|
||
if not base_force then
|
||
base_force = (distance - pull_distance) * leads.settings.pull_force / pull_distance;
|
||
end;
|
||
|
||
local force = base_force / math.sqrt(leads.util.get_object_mass(connector));
|
||
local pull_direction = (other_pos - this_pos):normalize();
|
||
connector:add_velocity(pull_direction * dtime * force ^ 1.5);
|
||
end;
|
||
|
||
-- Pull follower:
|
||
_pull_connector(self.follower, f_pos, l_pos);
|
||
|
||
-- Pull leader if symmetrical mode is enabled:
|
||
if leads.settings.symmetrical then
|
||
_pull_connector(self.leader, l_pos, f_pos);
|
||
end;
|
||
|
||
-- Play stretching sound:
|
||
self.sound_timer = self.sound_timer + dtime;
|
||
if self.sound_timer >= leads.STRETCH_SOUND_INTERVAL then
|
||
self.sound_timer = self.sound_timer - leads.STRETCH_SOUND_INTERVAL;
|
||
if leads.util.rng:next(0, 8) == 0 then
|
||
minetest.sound_play(leads.sounds.stretch, {pos = pos}, true);
|
||
end;
|
||
end;
|
||
end;
|
||
return true, pos, f_pos - l_pos;
|
||
end;
|
||
|
||
--- Updates the connector references and stored positions.
|
||
-- @local
|
||
function leads.LeadEntity:_update_connectors()
|
||
local function _get_pos(key)
|
||
local object = self[key];
|
||
local pos = object and object:get_pos();
|
||
local id = self[key .. '_id'];
|
||
if not pos then
|
||
pos = id.pos;
|
||
if not pos then
|
||
return nil;
|
||
end;
|
||
|
||
local object = leads.util.deserialise_objref(id);
|
||
if object then
|
||
pos = object:get_pos();
|
||
self[key] = object;
|
||
self[key .. '_attach_offset'] = leads.util.get_attach_offset(object);
|
||
leads.leads_by_connector[object][self.object] = true;
|
||
else
|
||
-- The object reference is invalid, and deserialising the
|
||
-- object failed. This could mean that the object has been
|
||
-- removed and the lead should break, or it could mean that
|
||
-- the object's mapblock has been unloaded, and the lead
|
||
-- should just wait until it gets loaded again. We can figure
|
||
-- out which one by checking if the mapblock is active.
|
||
if minetest.compare_block_status(pos, 'active') then
|
||
return nil;
|
||
end;
|
||
end;
|
||
end;
|
||
id.pos = pos or id.pos;
|
||
return pos;
|
||
end;
|
||
|
||
self.leader_pos = _get_pos('leader');
|
||
self.follower_pos = _get_pos('follower');
|
||
end;
|
||
|
||
--- Handles the lead being punched.
|
||
function leads.LeadEntity:on_punch(puncher, time_from_last_punch, tool_capabilities, dir, damage)
|
||
local name = puncher and puncher:get_player_name() or '';
|
||
|
||
-- Check protection:
|
||
local is_protected, protected_pos = self:is_protected(name);
|
||
if is_protected then
|
||
minetest.record_protection_violation(protected_pos, name);
|
||
return true;
|
||
end;
|
||
|
||
-- Break the lead:
|
||
self:break_lead(puncher);
|
||
|
||
-- Block the player's interaction for a moment to prevent accidentally breaking the node behind the lead:
|
||
if name ~= '' then
|
||
leads.util.block_player_interaction(name, 0.25);
|
||
end;
|
||
|
||
return true;
|
||
end;
|
||
|
||
--- Handles the lead being interacted with while holding a lead item.
|
||
function leads.LeadEntity:_leads_on_interact(itemstack, user, pointed_thing, is_punch)
|
||
if is_punch then
|
||
self:on_punch(user);
|
||
return true, nil;
|
||
end;
|
||
return false, nil;
|
||
end;
|
||
|
||
--- Handles the lead being ‘killed’.
|
||
function leads.LeadEntity:on_death(killer)
|
||
self:break_lead(killer);
|
||
end;
|
||
|
||
--- Returns the lead's state as a table.
|
||
function leads.LeadEntity:get_staticdata()
|
||
local data = {};
|
||
data.item = self.item:to_string();
|
||
data.max_length = self.max_length;
|
||
data.leader_id = self.leader_id;
|
||
data.follower_id = self.follower_id;
|
||
data.leader_attach_offset = self.leader_attach_offset;
|
||
data.follower_attach_offset = self.follower_attach_offset;
|
||
return minetest.serialize(data);
|
||
end;
|
||
|
||
--- Breaks the lead, possibly giving/dropping an item.
|
||
-- @param breaker [ObjectRef|nil] The object breaking the lead.
|
||
-- @param snap [boolean|nil] true if the lead is breaking due to tension.
|
||
function leads.LeadEntity:break_lead(breaker, snap)
|
||
if leads.settings.debug then
|
||
minetest.log(debug.traceback(('[Leads] Breaking lead %s at %s.'):format(self, self.object:get_pos())));
|
||
end;
|
||
|
||
-- Notify leader and follower:
|
||
self:notify_connector_removed(self.leader, true);
|
||
self:notify_connector_removed(self.follower, false);
|
||
|
||
-- Give or drop item:
|
||
if not self.item:is_empty() then
|
||
local owner = breaker;
|
||
if not minetest.is_player(owner) then
|
||
owner = self.leader;
|
||
end;
|
||
local pos = self.object:get_pos();
|
||
local item = self.item;
|
||
if not leads.DROP_ITEM then
|
||
local inventory = minetest.is_player(owner) and owner:get_inventory();
|
||
if inventory then
|
||
if minetest.is_creative_enabled(owner) and inventory:contains_item('main', item, true) then
|
||
item = ItemStack();
|
||
else
|
||
item = inventory:add_item('main', item);
|
||
end;
|
||
end;
|
||
end;
|
||
minetest.add_item(pos, item);
|
||
end;
|
||
|
||
-- Play sound:
|
||
if snap then
|
||
minetest.sound_play(leads.sounds.snap, {pos = self.object:get_pos()}, true);
|
||
else
|
||
minetest.sound_play(leads.sounds.remove, {pos = self.object:get_pos()}, true);
|
||
end;
|
||
|
||
-- Remove lead:
|
||
self.object:remove();
|
||
self.item = ItemStack();
|
||
end;
|
||
|
||
--- Checks if either end of the lead is in an area protected from the specified player.
|
||
-- If protection support is disabled, this always returns false.
|
||
-- @param player [string|ObjectRef] A player object or username.
|
||
-- @return [boolean] true if the player is not allowed to break the lead due to protection.
|
||
-- @return [vector|nil] The protected position, if any.
|
||
function leads.LeadEntity:is_protected(player)
|
||
if not leads.settings.respect_protection then
|
||
return false, nil; -- Protection support is disabled.
|
||
end;
|
||
|
||
local name;
|
||
if type(player) == 'string' then
|
||
name = player;
|
||
else
|
||
name = player:get_player_name();
|
||
end;
|
||
name = name or '';
|
||
|
||
if minetest.check_player_privs(name, 'protection_bypass') then
|
||
return false, nil; -- The player is exempt from protection.
|
||
end;
|
||
|
||
if name == self.leader_id.player_name then
|
||
return false, nil; -- The player is holding the lead.
|
||
end;
|
||
|
||
for __, connector_id in ipairs{self.leader_id, self.follower_id} do
|
||
if connector_id and connector_id.pos then
|
||
local pos = vector.round(connector_id.pos);
|
||
if minetest.is_protected(pos, name) then
|
||
return true, pos; -- An end of the lead is in a protected area.
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
return false, nil;
|
||
end;
|
||
|
||
--- Updates the visual properties of the lead to show its current state.
|
||
function leads.LeadEntity:update_visuals()
|
||
local SCALE = 8;
|
||
|
||
if self.current_length == self.old_length then
|
||
return;
|
||
end;
|
||
self.old_length = self.current_length;
|
||
|
||
local properties = {visual_size = vector.new(1, 1, self.current_length)};
|
||
local selbox_offset = 0;
|
||
-- Dynamic textures:
|
||
if leads.settings.dynamic_textures then
|
||
local texture = leads.util.tile_texture(self.texture, 96 * SCALE, 2 * SCALE, math.floor(self.current_length * 16 * SCALE), 2 * SCALE);
|
||
properties.textures = {texture};
|
||
elseif self.texture ~= self.old_texture then
|
||
self.old_texture = self.texture;
|
||
properties.textures = {self.texture};
|
||
end;
|
||
-- Slack model:
|
||
if leads.settings.enable_slack then
|
||
if self.leader_pos and self.follower_pos then
|
||
local slack, mesh = self:get_slack();
|
||
properties.mesh = mesh;
|
||
selbox_offset = selbox_offset - slack / 12;
|
||
end;
|
||
end;
|
||
-- Selection box:
|
||
if leads.settings.rotate_selection_box then
|
||
properties.selectionbox = {-0.0625, -0.0625 + selbox_offset, -self.current_length / 2,
|
||
0.0625, 0.0625 + selbox_offset, self.current_length / 2, rotate = true};
|
||
end;
|
||
self.object:set_properties(properties);
|
||
self.object:set_rotation(self.rotation);
|
||
end;
|
||
|
||
--- Calculates the slack value and chooses a model to represent it.
|
||
-- @return [number] The current slack value.
|
||
-- @return [string] A model name.
|
||
function leads.LeadEntity:get_slack()
|
||
local span = self.follower_pos - self.leader_pos;
|
||
local slack = 0.5 + 1 - span:length() / self.max_length;
|
||
|
||
-- Scale the slack by how horizontal the lead is, otherwise it would droop sideways when vertical.
|
||
slack = slack * (1 - math.abs(span:normalize().y));
|
||
|
||
slack = leads.util.clamp(slack, 0.0, 1.0)
|
||
local model_index = math.floor(slack * #leads.SLACK_MODELS);
|
||
return slack, leads.SLACK_MODELS[model_index];
|
||
end;
|
||
|
||
--- Updates the connector IDs to reflect the current connectors.
|
||
function leads.LeadEntity:update_objref_ids()
|
||
self.leader_id = leads.util.serialise_objref(self.leader) or self.leader_id;
|
||
self.follower_id = leads.util.serialise_objref(self.follower) or self.follower_id;
|
||
self:update_attach_offsets();
|
||
end;
|
||
|
||
--- Updates the attachment offsets to reflect the current connectors' properties.
|
||
function leads.LeadEntity:update_attach_offsets()
|
||
self.leader_attach_offset = leads.util.get_attach_offset(self.leader) or self.leader_attach_offset;
|
||
self.follower_attach_offset = leads.util.get_attach_offset(self.follower) or self.follower_attach_offset;
|
||
end;
|
||
|
||
--- Transfers this lead to a new leader.
|
||
-- @param leader [ObjectRef] The new leader object.
|
||
-- @return [boolean] true on success.
|
||
function leads.LeadEntity:set_leader(leader)
|
||
return self:set_connector(leader, true);
|
||
end;
|
||
|
||
--- Transfers this lead to a new follower.
|
||
-- @param follower [ObjectRef] The new follower object.
|
||
-- @return [boolean] true on success.
|
||
function leads.LeadEntity:set_follower(follower)
|
||
return self:set_connector(follower, false);
|
||
end;
|
||
|
||
--- Transfers this lead to a new leader or follower.
|
||
-- @param object [ObjectRef] The new connector.
|
||
-- @param is_leader [boolean] true to set the leader, false to set the follower.
|
||
-- @return [boolean] true on success.
|
||
function leads.LeadEntity:set_connector(object, is_leader)
|
||
if (self.leader and leads.util.is_same_object(object, self.leader)) or
|
||
(self.follower and leads.util.is_same_object(object, self.follower)) then
|
||
return false;
|
||
end;
|
||
|
||
local key = is_leader and 'leader' or 'follower';
|
||
local old_object = self[key];
|
||
self:notify_connector_removed(old_object, is_leader);
|
||
self[key] = object;
|
||
self:notify_connector_added(object, is_leader);
|
||
self:update_objref_ids();
|
||
return true;
|
||
end;
|
||
|
||
--- Reverses the direction of the lead, swapping the leader and follower.
|
||
function leads.LeadEntity:reverse()
|
||
self.leader, self.follower = self.follower, self.leader;
|
||
self.leader_id, self.follower_id = self.follower_id, self.leader_id;
|
||
end;
|
||
|
||
--- Notifies the connector that this lead has been added.
|
||
-- @param object [ObjectRef|nil] The connector to notify.
|
||
-- @param is_leader [boolean] true if the connector is the leader.
|
||
function leads.LeadEntity:notify_connector_added(object, is_leader)
|
||
if not object then
|
||
return;
|
||
end;
|
||
|
||
leads.leads_by_connector[object][self.object] = true;
|
||
|
||
local entity = object:get_luaentity();
|
||
if entity and entity._leads_lead_add then
|
||
entity:_leads_lead_add(self, is_leader or false);
|
||
end;
|
||
end;
|
||
|
||
--- Notifies the connector that this lead has been removed.
|
||
-- @param object [ObjectRef|nil] The connector to notify.
|
||
-- @param is_leader [boolean] true if the connector was the leader.
|
||
function leads.LeadEntity:notify_connector_removed(object, is_leader)
|
||
if not object then
|
||
return;
|
||
end;
|
||
|
||
leads.leads_by_connector[object][self.object] = nil;
|
||
|
||
local entity = object:get_luaentity();
|
||
if entity and entity._leads_lead_remove then
|
||
entity:_leads_lead_remove(self, is_leader or false);
|
||
end;
|
||
end;
|
||
|
||
--- Gets the lead's leader if it's loaded.
|
||
-- @return [ObjectRef|nil] The leader object.
|
||
function leads.LeadEntity:get_leader()
|
||
if self.leader and self.leader:get_pos() then
|
||
return self.leader;
|
||
end;
|
||
return nil;
|
||
end;
|
||
|
||
--- Gets the lead's follower if it's loaded.
|
||
-- @return [ObjectRef|nil] The follower object.
|
||
function leads.LeadEntity:get_follower()
|
||
if self.leader and self.follower:get_pos() then
|
||
return self.follower;
|
||
end;
|
||
return nil;
|
||
end;
|
||
|
||
minetest.register_entity('leads:lead', leads.LeadEntity);
|