mobs/init.lua

1650 lines
49 KiB
Lua

--------------------------------------------------------
-- Minetest :: Mobs Lite Mod (mobs)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2016-2020, Leslie E. Krause
--
-- ./games/minetest_game/mods/mobs/init.lua
--------------------------------------------------------
mobs = { }
local registry = {
players = { },
avatars = { },
objects = { },
spawnitems = { },
}
local world_gravity = -10
local liquid_density = 0.5
local liquid_viscosity = 0.6
local next_noise_id = 1
--------------------
local random = math.random
local floor = math.floor
local min = math.min
local max = math.max
local sqrt = math.sqrt
local pow = math.pow
local abs = math.abs
local pi = math.pi
local atan2 = math.atan2
local sin = math.sin
local cos = math.cos
local rad_360 = 2 * pi
local rad_180 = pi
local rad_90 = pi / 2
local rad_60 = pi / 3
local rad_45 = pi / 4
local rad_30 = pi / 6
local rad_20 = pi / 9
local rad_10 = pi / 18
local rad_5 = pi / 36
local rad_0 = 0
--------------------
function Timekeeper( this )
local timer_defs = { }
local pending_timer_defs = { }
local clock = 0.0
local delay = 0.0
local self = { }
self.shift = function ( dtime )
delay = delay + dtime
end
self.unshift = function ( )
delay = 0.0
end
self.start = function ( period, name, func )
timer_defs[ name ] = nil
pending_timer_defs[ name ] = { cycles = 0, period = period, expiry = clock + delay + period, started = clock, func = func }
end
self.start_now = function ( period, name, func )
timer_defs[ name ] = nil
if not func( this, 0, period, 0.0, 0.0 ) then
pending_timer_defs[ name ] = { cycles = 0, period = period, expiry = clock + period, started = clock, func = func }
end
end
self.clear = function ( name )
pending_timer_defs[ name ] = nil
timer_defs[ name ] = nil
end
self.on_step = function ( dtime )
clock = clock + dtime
for k, v in pairs( pending_timer_defs ) do
timer_defs[ k ] = v
pending_timer_defs[ k ] = nil
end
local timers = { }
for k, v in pairs( timer_defs ) do
if clock >= v.expiry and clock > v.started then
v.expiry = clock + v.period
v.cycles = v.cycles + 1
-- callback( this, cycles, period, elapsed, overrun )
if v.func and v.func( this, v.cycles, v.period, clock - v.started, clock - v.expiry ) then
self.clear( k )
end
timers[ k ] = v
end
end
return timers
end
return self
end
--------------------
mobs.effect = function ( pos, amount, texture, min_size, max_size, radius, gravity )
minetest.add_particlespawner({
amount = amount,
time = 0.5,
minpos = { x = pos.x, y = pos.y, z = pos.z },
maxpos = { x = pos.x, y = pos.y + 1.5, z = pos.z },
minvel = {x = -radius, y = -radius, z = -radius},
maxvel = {x = radius, y = radius, z = radius},
minacc = {x = 0, y = gravity, z = 0},
maxacc = {x = 0, y = gravity, z = 0},
minexptime = 0.1,
maxexptime = 1,
minsize = min_size,
maxsize = max_size,
texture = texture,
})
end
local function smoke_effect( pos )
mobs.effect( pos, 8, "tnt_smoke.png", 1.4, 1.6, 2, 0 )
end
local function blood_effect( pos )
mobs.effect( pos, 4, "mobs_blood.png", 1.2, 1.4, 2, -10 )
end
--------------------
mobs.iterate_registry = function ( source_pos, radius, height, classes )
local length = radius * radius
local class_id = next( classes )
local key
local function is_inside_area( obj )
-- perform fast length-squared distance check
local target_pos = obj:get_pos( )
local a = source_pos.x - target_pos.x
local b = source_pos.z - target_pos.z
return a * a + b * b <= length and abs( source_pos.y - target_pos.y ) <= height
end
return function ( )
while class_id and classes[ class_id ] do
local obj
key, obj = next( registry[ class_id ], key )
if obj then
if obj:get_hp( ) > 0 and is_inside_area( obj ) then
return obj
end
else
class_id = next( classes, class_id )
key = nil
end
end
return nil
end
end
--------------------
local function node_locator( pos, size, time, color )
if is_debug then
minetest.add_particle( {
pos = pos,
velocity = { x=0, y=0, z=0 },
acceleration = { x=0, y=0, z=0 },
exptime = time + 4,
size = size,
collisiondetection = false,
vertical = true,
texture = "wool_" .. color .. ".png",
})
end
end
local function printf( ... )
print( string.format( ... ) )
end
--------------------
local function to_vector3d( length, yaw, pitch )
local y = sin( pitch ) * length
local length2 = cos( pitch ) * length
local x = -sin( yaw ) * length2
local z = cos( yaw ) * length2
return { x = x, y = y, z = z }, length2
end
local function normalize_angle( r )
-- stackoverflow.com/questions/1878907/the-smallest-difference-between-2-angles
return atan2( sin( r ), cos( r ) )
end
local function get_vector_angle( p1, p2 )
return atan2( p2.z - p1.z, p2.x - p1.x )
end
local function get_vector_height( p1, p2 ) -- get altitude from p1 to p2
return abs( p2.y - p1.y )
end
local function get_vector_length( p1, p2 ) -- get distance from p1 to p2
return sqrt( pow( p2.x - p1.x, 2 ) + pow( p2.z - p1.z, 2 ) )
end
local function get_vector_incline( p1, p2 )
local h = get_vector_length( p1, p2 )
local v = p2.y - p1.y
return v / h
end
local function ramp( f, cur_v, max_v )
-- min function handles NaN, but let's err on the side of caution
return max_v == 0 and f or f * min( 1, cur_v / max_v )
end
local function sign( v )
return random( 2 ) == 1 and v or -v
end
local function lower_random( v_limit, v_ratio )
return sign( v_ratio * random( ) * v_limit )
end
local function upper_random( v_limit, v_ratio )
return sign( v_limit - v_ratio * random( ) * v_limit )
end
local function get_power_decrease( scale, power, value )
return value <= 1 - scale and 1.0 or
max( 1 - pow( ( scale + value - 1 ) / scale, 1 + power ), 0 )
end
local function get_power_increase( scale, power, value )
return value >= scale and 1.0 or
1 - pow( ( scale - value ) / scale, 1 + power )
end
local function check_limits( v, min_v, max_v )
return v >= min_v and v <= max_v
end
local function random_range( min_v, max_v )
return random( min_v * 100, max_v * 100 ) / 100
end
--------------------
minetest.register_on_leaveplayer( function( player, is_timeout )
local name = player:get_player_name( )
-- delete all target references (if applicable)
for id, obj in pairs( registry.avatars ) do
local this = obj:get_luaentity( )
if this.target and this.target.obj == player then
this:reset_alertness( "ignore" )
end
end
registry.players[ name ] = nil
end )
minetest.register_on_joinplayer( function( player )
local name = player:get_player_name( )
registry.players[ name ] = player
end )
--------------------
local builtin_item = minetest.registered_entities[ "__builtin:item" ]
builtin_item.old_on_activate = builtin_item.on_activate
builtin_item.old_set_item = builtin_item.set_item
builtin_item.set_item = function ( self, itemstring )
self:old_set_item( itemstring )
self.item_name = ItemStack( self.itemstring ):get_name( )
end
builtin_item.on_activate = function ( self, staticdata, dtime, id )
self:old_on_activate( staticdata, dtime )
registry.spawnitems[ id ] = self.object
end
builtin_item.on_deactivate = function ( self, id )
for id, obj in pairs( registry.avatars ) do
local this = obj:get_luaentity( )
if this.target and this.target.obj == self.object then
this:reset_alertness( "ignore" )
end
end
registry.spawnitems[ id ] = nil
end
--------------------
local globaltimer = Timekeeper( { } )
minetest.register_globalstep( function ( dtime )
globaltimer.on_step( dtime )
end )
--------------------
minetest.register_entity( "mobs:gibbage", {
physical = true,
visual = "mesh",
visual_size = { x = 1.0, y = 1.0 },
collisionbox = { -0.2, -0.1, -0.2, 0.2, 0.1, 0.2 },
motion_sounds = { },
physics = {
density = 0.5,
elasticity = 0.2,
resistance = 0.0,
friction = 0.7,
},
on_activate = function ( self, staticdata, dtime )
BasicPhysics( self )
self.timekeeper = Timekeeper( self )
self.timekeeper.start( math.random( 4, 8 ), "gibbage", function ( )
self.object:remove( )
end )
if dtime > 0 then
self.object:remove( )
return
end
end,
on_step = function ( self, dtime, pos )
self.timekeeper.on_step( dtime )
end,
launch = function ( self, intensity, texture, piece, sound )
local obj = self.object
obj:set_properties( {
mesh = "gib_" .. piece .. ".b3d",
textures = { texture },
} )
local vel_horz = min( 4, intensity * 0.5 )
local vel_vert = min( 4, intensity * 0.7 )
obj:set_velocity( vector.new( random_range( -vel_horz, vel_horz ), vel_vert, random_range( -vel_horz, vel_horz ) ) )
obj:set_animation( { x = 0, y = 120 }, random( 20, 40 ), 0, false )
self.motion_sounds.bouncing = sound
end,
} )
mobs.register_mob = function ( name, def )
minetest.register_entity( name, {
type = def.type,
hp_max = def.hp_max,
hp_low = def.hp_low,
physical = true,
mesh = def.mesh,
collisionbox = def.collisionbox,
visual = def.visual,
visual_size = def.visual_size,
height = def.height,
y_offset = def.y_offset,
gravity = def.gravity or world_gravity,
density = def.density or 0.5,
textures = def.textures,
makes_footstep_sound = def.makes_footstep_sound,
makes_bloodshed_effect = def.makes_bloodshed_effect,
gibbage_params = def.gibbage_params,
receptrons = def.receptrons,
death_message = def.death_message,
alertness_states = def.alertness_states,
awareness_stages = def.awareness_stages,
sensitivity = def.sensitivity or 0.0,
certainty = def.certainty or 1.0,
attack_range = def.attack_range,
escape_range = def.escape_range,
follow_range = def.follow_range,
search_range = def.search_range,
pickup_range = def.pickup_range,
sneak_velocity = def.sneak_velocity,
walk_velocity = def.walk_velocity,
run_velocity = def.run_velocity,
stray_velocity = def.stray_velocity,
recoil_velocity = def.recoil_velocity,
standoff = def.standoff or 5,
damage = def.damage,
light_damage = def.light_damage or 0,
water_damage = def.water_damage or 0,
lava_damage = def.lava_damage or 0,
enable_fall_damage = def.enable_fall_damage,
drops = def.drops,
armor = def.armor,
yaw_origin = def.drawtype == "front" and 0 or -rad_90,
drawtype = def.drawtype,
on_rightclick = def.on_rightclick,
attack_type = def.attack_type,
projectile = def.projectile,
shoot_period = def.shoot_period,
shoot_chance = def.shoot_chance,
weapon_params = def.weapon_params,
watch_wielditems = def.watch_wielditems or { },
watch_spawnitems = def.watch_spawnitems or { },
watch_players = def.watch_players or { },
sounds = def.sounds,
animation = def.animation,
fear_factor = def.fear_factor,
flee_factor = def.flee_factor,
can_jump = def.can_jump or false,
can_fly = def.can_fly or false,
can_walk = def.can_walk or false,
enable_climbing = true,
enable_swimming = true,
shoot_count = 0,
timeout = def.timeout,
neutral_state = "ignore",
offense_state = def.offense_state or def.type == "monster" and "attack" or "ignore",
defense_state = def.defense_state or def.type == "monster" and "attack" or "escape",
retreat_state = def.retreat_state or "escape",
is_tamed = false,
description = def.description,
after_activate = def.after_activate,
before_deactivate = def.before_deactivate,
before_state_change = def.before_state_change,
before_punch = def.before_punch,
-- prepare noise generator with seed, octaves, persistence, spread
hunger_noise = PerlinNoise( random( 1000 ), 1, 0, def.hunger_params.spread ),
hunger_offset = def.hunger_params.offset,
-- primitive movement functions --
set_speed = function( self, speed )
self.speed = speed
self.object:set_speed( speed )
end,
set_speed_lateral = function( self, speed_x, speed_y )
self.speed = speed_y
self.object:set_speed_lateral( speed_x, speed_y )
end,
set_velocity_vert = function ( self, vel_y )
self.object:set_velocity_vert( vel_y )
self.object:set_acceleration_vert( self.gravity )
end,
set_acceleration_vert = function ( self, acc_y )
self.object:set_acceleration_vert( acc_y )
end,
set_yaw = function ( self, yaw )
self.object:set_yaw( yaw )
end,
turn_to = function ( self, yaw, period )
local yaw_delta = normalize_angle( yaw - self.yaw )
self.object:turn_by( yaw_delta, period / 10 )
end,
turn_by = function ( self, yaw_delta, period )
self.object:turn_by( yaw_delta, period / 10 )
end,
move_by = function ( self, pos_delta, period )
self.object:move_by( pos_delta, period / 10 )
end,
get_random_yaw = function ( self, r_limit, r_ratio )
return self.yaw + upper_random( r_limit, r_ratio or 1 )
end,
get_direct_yaw = function ( self, pos )
-- convert to world coordinate system
return get_vector_angle( self.pos, pos ) - rad_90 - self.yaw_origin
end,
get_direct_yaw_delta = function ( self, pos )
local yaw = self:get_direct_yaw( pos )
return abs( normalize_angle( yaw - self.yaw ) )
end,
get_target_yaw = function ( self, r_limit, r_ratio )
return self:get_direct_yaw( self.target.pos ) + upper_random( r_limit, r_ratio or 1 )
end,
get_target_yaw_delta = function ( self )
local yaw = self:get_direct_yaw( self.target.pos )
return abs( normalize_angle( yaw - self.yaw ) )
end,
get_target_distance = function ( self )
return vector.distance( self.pos, self.target.pos )
end,
-- collision processing --
get_pos_ahead = function ( self, outset, angle )
-- convert from world coordinate system
local rot = angle + self.yaw_origin
return vector.round( {
x = self.pos.x - sin( self.yaw + rot ) * ( self.collisionbox[ 4 ] + outset ),
y = self.pos.y,
z = self.pos.z + cos( self.yaw + rot ) * ( self.collisionbox[ 4 ] + outset )
} )
end,
can_walk_ahead = function ( self, outset, angle )
local fpos = self:get_pos_ahead( outset, angle )
local unknown_ndef = { groups = { }, walkable = false }
if self.height < 2 then
local node = minetest.get_node( fpos )
local ndef = core.registered_nodes[ node.name ] or unknown_ndef -- account for unknown nodes
local is_airlike = not ndef.walkable
--node_locator( vector.offset_y( fpos ), 4.5, 1.0, is_airlike and "green" or angle == 0 and "white" or "red" )
return is_airlike
else
local node_below = minetest.get_node( fpos )
local node_above = minetest.get_node_above( fpos )
local ndef_below = core.registered_nodes[ node_below.name ] or unknown_ndef -- account for unknown nodes
local ndef_above = core.registered_nodes[ node_above.name ] or unknown_ndef
local is_airlike = not ndef_below.walkable and not ndef_above.walkable
--node_locator( vector.offset_y( fpos ), 4.5, 1.0, is_airlike and "green" or angle == 0 and "white" or "red" )
return is_airlike
end
end,
-- utility functions --
is_starving = function ( self )
local hunger = self.hunger_noise:get2d( { x = self.timeout, y = 0 } )
-- offset of -1 is never hungry, offset of 1 is always hungry
return hunger > -self.hunger_offset
end,
play_sound = function ( self, name )
minetest.sound_play( name, { object = self.object, loop = false } )
end,
play_sound_repeat = function ( self, name )
return minetest.sound_play( name, { object = self.object, loop = true } )
end,
make_noise = function ( self, radius, group, intensity )
axon.generate_radial_stimulus( self.pos, radius, 0.0, 0.0, 1, { [group] = intensity }, { avatars = true } )
end,
make_noise_repeat = function ( self, radius, interval, duration, group, intensity )
self.timekeeper.start_now( interval, "noise" .. next_noise_id, function ( this, cycles, period, elapsed )
if elapsed >= duration then return true end -- we're finished, so cancel timer
axon.generate_radial_stimulus( self.pos, radius, 0.0, 0.0, 1, { [group] = intensity }, { avatars = true } )
end )
next_noise_id = next_noise_id + 1
end,
-- sensory processing --
check_suspect = function ( self, target_obj, clarity, elapsed )
-- default to defense or neutral state if not in any watch list
local suspect = random( 10 ) <= self.fear_factor and self.offense_state or self.neutral_state
local entity = target_obj:get_luaentity( )
if not entity then
local player_name = target_obj:get_player_name( )
local item_name = target_obj:get_wielded_item( ):get_name( )
suspect = self.watch_players[ player_name ] or self.watch_wielditems[ item_name ] or suspect
elseif entity.name == "__builtin:item" then
suspect = self.watch_spawnitems[ entity.item_name ] or suspect
end
if type( suspect ) == "function" then
return suspect( self, target_obj, clarity, elapsed or 0.0 ) -- must always return a valid state
else
return suspect
end
end,
get_visibility = function ( self, target_pos )
local length = get_vector_length( self.pos, target_pos )
local height = get_vector_height( self.pos, target_pos )
local this = self.alertness
if not this or length > 48 or height > 48 then -- immediately eliminate far objects
return 0.0, false, false
else
local view_range = this.view_radius + this.view_offset
local view_pos = this.view_offset == 0.0 and self.pos or vector.new(
self.pos.x - sin( self.yaw + self.yaw_origin ) * this.view_offset,
self.pos.y,
self.pos.z + cos( self.yaw + self.yaw_origin ) * this.view_offset
)
local radius = get_vector_length( view_pos, target_pos )
local clarity = get_power_decrease( 1.0, this.view_acuity, length / view_range )
-- certainty factor ranges from 0 (target never evident) to 1 (target always evident)
-- sensitivity threshold ranges from 0 (full perception) to 1 (no perception)
--node_locator( vector.offset_y( view_pos ), 4.5, 1.0, "yellow" )
--printf( "is_paranoid(%s):\n[%s] radius <= view_radius\n[%s] height <= view_height\n%0.2f = clarity (%0.2f = sensitivity, %$
local is_visible = radius <= this.view_radius and height <= this.view_height
local is_evident = clarity > self.sensitivity and clarity * self.certainty > random( )
return clarity, is_visible, is_evident
end
end,
-- target functions --
create_target = function ( self, obj )
local pos = obj:get_pos( )
local clarity, is_visible, is_evident = self:get_visibility( pos )
local alertness = self.alertness_states[ self.state ]
-- when creating target it must be visible and evident!
if is_visible and is_evident then
if alertness and alertness.view_filter then
return alertness.view_filter( self, obj, clarity, 0.0 ), { obj = obj, pos = pos }
elseif clarity > 0.0 then
return self:check_suspect( obj, clarity, 0.0 ), { obj = obj, pos = pos }
end
else
return self.state, self.target
end
end,
verify_target = function ( self, elapsed )
if self.target.obj then
local target_pos = self.target.obj:get_pos( ) -- check target's new position
local clarity, is_visible, is_evident = self:get_visibility( target_pos )
local alertness = self.alertness_states[ self.state ]
-- update last-known target position only if visible and evident
if is_visible and is_evident then
self.target.pos = target_pos
else
clarity = 0.0
end
if alertness and alertness.view_filter then
return alertness.view_filter( self, self.target.obj, clarity, elapsed )
elseif clarity > 0.0 then
return self.awareness.pass_state or self:check_suspect( self.target.obj, clarity, elapsed )
else
return self.awareness.fail_state or self.neutral_state
end
end
return self.state
end,
locate_target = function ( self )
if self.sounds and self.sounds.random and random( 35 ) == 1 then
minetest.sound_play( self.sounds.random, { object = self.object } )
end
-- when not upset, seek out food or prey at random intervals
for obj in mobs.iterate_registry( self.pos, 30, 30, { players = true, spawnitems = true } ) do
if random( 10 ) <= self.fear_factor and not obj:get_attach( ) then
local state, target = self:create_target( obj )
if state ~= self.state then
self:reset_alertness( state, target )
return
end
end
end
end,
-- alertness and awareness functions --
start_awareness = function ( self )
self.awareness = self.awareness_stages[ self.state ] or { }
if self.awareness.decay and self.awareness.decay > 0 then
self.timekeeper.start( self.awareness.decay, "awareness", function ( )
self:reset_alertness( self.awareness.wait_state, self.target )
end )
else
self.timekeeper.clear( "awareness" )
end
end,
reset_alertness = function ( self, state, target )
if state == self.state then return end
if self.before_state_change then
self:before_state_change( self.state, state )
end
self.state = state
self.target = target
self.alertness = self.alertness_states[ state ]
self:start_awareness( )
self.action_funcs[ state ]( self )
end,
-- action hooks --
start_ignore_action = function ( self )
self.target = nil -- forget any target
if not self.move_result.is_standing then
-- always stand when in mid-air
self:set_speed( 0 )
self:set_animation( self.can_fly and "swim" or "stand" )
elseif self:is_starving( ) then
self:set_speed( self.walk_velocity )
self:set_animation( "walk" )
else
self:set_speed( 0 )
self:set_animation( "stand" )
end
self.timekeeper.start( 1.0, "hunger", self.locate_target )
self.timekeeper.start( 1.0, "action", self.on_ignore_action )
end,
on_ignore_action = function ( self, cycles )
-- handle standing and walking motion
if self.speed == 0 then
if random( 3 ) == 1 then
-- turn randomly, because we're bored
self:turn_to( self:get_random_yaw( rad_180 ), random( 2 ) == 1 and 10 or 20 )
end
-- don't stand still when hungry
if self:is_starving( ) then
if self.can_fly and random( 2 ) == 1 then
self:set_speed( self.run_velocity )
self:set_velocity_vert( self.run_velocity )
self:set_animation( "swim" )
else
self:set_speed( self.walk_velocity )
self:set_animation( "walk" )
end
elseif self.can_walk and self.move_result.is_standing then
self:set_animation( "stand" )
elseif self.can_fly then
local is_above = minetest.line_of_sight( self.pos,
{ x = self.pos.x, y = self.pos.y - self.standoff, z = self.pos.z }, 1 )
if self.can_walk then
-- slowly descend until reaching ground
self.object:set_velocity_vert( -self.walk_velocity )
elseif is_above then
self.object:set_velocity_vert( random_range( -self.sneak_velocity, 0 ) )
else
self.object:set_velocity_vert( random_range( 0, self.sneak_velocity ) )
end
self:set_animation( "swim" )
end
else
if not self:can_walk_ahead( 1.0, 0 ) then
if self:can_walk_ahead( 0.8, rad_90 ) then
self:turn_by( rad_60, 5 )
elseif self:can_walk_ahead( 0.8, -rad_90 ) then
self:turn_by( -rad_60, 5 )
else
self:turn_by( rad_180, 20 )
end
elseif random( 3 ) == 1 then
-- otherwise occasionally change direction
self:turn_to( self:get_random_yaw( rad_60 ), 20 )
end
if self.move_result.is_standing then
if self.can_jump and random( 10 ) == 1 then
-- random jump
self.object:set_velocity_vert( 5 )
end
if not self:is_starving( ) then
self:set_speed( 0 )
self:set_animation( "stand" )
else
self:set_animation( "walk" )
end
elseif self.can_fly then
-- random vertical flight pattern, keeping clear of ground obstacles
local is_above = minetest.line_of_sight( self.pos,
{ x = self.pos.x, y = self.pos.y - self.standoff, z = self.pos.z }, 1 )
if not self:is_starving( ) then
self:set_speed( 0 )
self.object:set_velocity_vert( is_above and -self.walk_velocity )
elseif is_above then
self.object:set_velocity_vert( random_range( -self.sneak_velocity, self.sneak_velocity ) )
else
self.object:set_velocity_vert( self.walk_velocity )
end
self:set_animation( "swim" )
end
end
end,
start_follow_action = function ( self )
assert( self.target.pos ) -- sanity check
self:turn_to( self:get_target_yaw( rad_20 ), 10 )
self:set_speed( self.recoil_velocity )
self:set_animation( "walk" )
self.timekeeper.clear( "hunger" )
self.timekeeper.start( 0.5, "action", self.on_follow_action )
end,
on_follow_action = function ( self, cycles, period, elapsed )
if cycles % 2 == 0 then -- validate target every 1.0 seconds
local goal_state = self:verify_target( )
if goal_state ~= self.state then
self:reset_alertness( goal_state, self.target )
return
end
end
local target_pos = self.target.pos
local dist = self:get_target_distance( )
if cycles % 2 == 0 then
if self:get_target_yaw_delta( ) > rad_45 or random( 3 ) == 1 then
self:turn_to( self:get_target_yaw( rad_20 ), 15 )
end
end
if dist <= self.follow_range then
if self.speed > 0 then
self:set_speed( 0 )
self:set_animation( "stand" )
end
else
if self.speed == 0 then
self:set_speed( self.walk_velocity )
self:set_animation( "walk" )
else
if self.can_fly then
-- descend or ascend to slightly above player altitude, but prevent incessant bobbing
local v = 0
if target_pos.y + 2.5 > self.pos.y then
v = self.walk_velocity
elseif target_pos.y + 0.5 < self.pos.y then
v = -self.walk_velocity
end
self:set_velocity_vert( v )
elseif self.can_jump and self.move_result.is_standing then
if self.yield_level < 3 and self.move_result.collides_xz then
self.yield_level = self.yield_level + 1
self.object:set_velocity_vert( 5 )
elseif random( 5 ) == 1 then
self.object:set_velocity_vert( 5 )
end
end
end
end
end,
start_search_action = function ( self )
assert( self.target.pos ) -- sanity check
local dist = self:get_target_distance( )
self:turn_to( self:get_target_yaw( rad_0 ), 20 )
if self:get_target_yaw_delta( ) > rad_10 or dist <= self.search_range then
self:set_speed( 0 )
self:set_animation( "stand" )
else
self:set_speed( self.sneak_velocity )
self:set_animation( "walk" )
end
self.timekeeper.clear( "hunger" )
self.timekeeper.start( 0.5, "action", self.on_search_action )
end,
on_search_action = function ( self, cycles, period, elapsed )
if cycles % 2 == 0 then -- validate target every 1.0 seconds
local goal_state = self:verify_target( )
if goal_state ~= self.state then
self:reset_alertness( goal_state, self.target )
return
end
end
local target_pos = self.target.pos
local dist = self:get_target_distance( )
-- go to last known position of target and look around
if dist <= self.search_range then
if self.speed > 0 then
self:set_speed( 0 )
self:set_animation( "stand" )
end
if random( 4 ) == 1 then
self:turn_to( self:get_random_yaw( rad_180 ), 10 )
end
else
-- wait at least 2 seconds after turning to start walking
if self.speed == 0 and cycles >= 4 and random( 3 ) == 1 then
self:set_speed( self.sneak_velocity )
self:set_animation( "walk" )
end
if self.can_fly then
-- descend or ascend to slightly above player altitude, but prevent incessant bobbing
local v = 0
if target_pos.y + 2.5 > self.pos.y then
v = self.walk_velocity
elseif target_pos.y + 0.5 < self.pos.y then
v = -self.walk_velocity
end
self:set_velocity_vert( v )
elseif self.can_jump and self.move_result.is_standing then
if self.yield_level < 3 and self.move_result.collides_xz then
self.yield_level = self.yield_level + 1
self.object:set_velocity_vert( 5 )
end
end
end
end,
start_escape_action = function ( self )
assert( self.target.pos ) -- sanity check
local dist = self:get_target_distance( )
if self:get_target_yaw_delta( ) < rad_60 and dist <= self.escape_range then
-- recoil if facing intruder
self:set_speed( -self.recoil_velocity )
else
-- otherwise run in current direction
self:set_speed( self.run_velocity )
end
if self.can_fly then
self:set_velocity_vert( self.walk_velocity )
self:set_animation( "swim" )
else
self:set_animation( "walk" )
end
if self.target.obj and self.target.obj:is_player( ) then
-- this is a bad player, so keep watch
self.watch_players[ self.target.obj:get_player_name( ) ] = "escape"
end
if self.sounds and self.sounds.escape and random( 2 ) == 1 then
minetest.sound_play( self.sounds.escape, { object = self.object } )
end
self.timekeeper.clear( "hunger" )
self.timekeeper.start( 0.5, "action", self.on_escape_action )
end,
on_escape_action = function ( self, cycles, period, elapsed )
if cycles % 2 == 0 then
local goal_state = self:verify_target( )
if goal_state ~= self.state or random( 10 ) > self.flee_factor then
self:reset_alertness( goal_state, self.target )
return
end
end
local target_pos = self.target.pos
local dist = self:get_target_distance( )
if dist <= self.escape_range then
-- if close, keep backtracking
if cycles % 2 == 0 then
-- turn every 1.0 seconds (2 cycles)
self:turn_to( self:get_target_yaw( rad_20 ), 10 )
end
if self.speed > 0 then
self:set_speed( -self.recoil_velocity )
self:set_animation( self.can_fly and "swim" or "walk" )
end
else
-- otherwise turn and run away
if cycles % 4 == 0 and self:get_target_yaw_delta( ) < rad_60 then
-- turn immediately if facing target
self:turn_to( self:get_target_yaw( rad_60 ) + rad_180, 20 )
elseif cycles % 2 == 0 then
-- otherwise turn every 1.0 seconds (2 cycles)
self:turn_to( self:get_target_yaw( rad_30 ) + rad_180, 10 )
end
if self.speed <= 0 then
self:set_speed( self.run_velocity )
self:set_animation( self.can_fly and "swim" or "run" )
end
end
if self.can_fly then
local is_above = minetest.line_of_sight( self.pos,
{ x = self.pos.x, y = self.pos.y - self.standoff, z = self.pos.z }, 1 )
if is_above then
self.object:set_velocity_vert( random_range( -self.stray_velocity, self.stray_velocity ) )
else
self.object:set_velocity_vert( random_range( self.walk_velocity, self.run_velocity ) )
end
elseif self.can_jump and self.move_result.is_standing then
if self.yield_level < 3 and self.move_result.collides_xz then
self.yield_level = self.yield_level + 1
self:set_velocity_vert( 5 )
end
end
end,
start_attack_action = function ( self )
assert( self.target.pos and self.target.obj ) -- sanity check
self:set_speed( self.run_velocity )
self:set_animation( "run" )
self:turn_to( self:get_target_yaw( rad_90 ), 10 )
if self.sounds and self.sounds.attack and random( 2 ) == 1 then
minetest.sound_play( self.sounds.attack, { object = self.object } )
end
self.timekeeper.clear( "hunger" )
self.timekeeper.start( 0.2, "action", self.on_attack_action )
end,
on_attack_action = function ( self, cycles, period, elapsed )
if self.target.obj:get_hp( ) == 0 or self.target.obj:get_attach( ) then
self:reset_alertness( "ignore" )
return
end
if cycles % 5 == 0 then -- validate target once per second
local goal_state = self:verify_target( )
if goal_state ~= self.state then
self:reset_alertness( goal_state, self.target )
return
end
end
local target_pos = self.target.pos
local dist = self:get_target_distance( )
self:turn_to( self:get_target_yaw( rad_0 ), 5 )
if dist <= self.attack_range then
if self.attack_type == "shoot" and self.fire_weapon then
if cycles % ( self.shoot_period * 5 ) == 0 and random( self.shoot_chance ) == 1 then
if self.sounds and self.sounds.attack and random( 2 ) == 1 then
minetest.sound_play( self.sounds.attack, { object = self.object } )
end
self:fire_weapon( target_pos )
self:set_animation( "punch" )
else
self:set_animation( self.can_fly and "swim" or "walk" )
end
elseif self.attack_type == "melee" then
if cycles % 5 == 0 then
if self.sounds and self.sounds.attack and random( 3 ) == 1 then
minetest.sound_play( self.sounds.attack, { object = self.object } )
end
-- if minetest.line_of_sight( pos, target_pos, 0.5 ) then -- do not hit player through walls!
self.target.obj:punch( self.object, 1.0, {
full_punch_interval = 1.0,
damage_groups = { fleshy=self.damage }
}, vector.direction( self.pos, target_pos ) )
-- end
self:set_animation( "punch" )
else
self:set_animation( self.can_fly and "swim" or "walk" )
end
end
if self.can_fly and cycles % 2 == 0 then
local v = self.new_vel.y
if target_pos.y + 0.5 > self.pos.y then
v = self.stray_velocity
elseif target_pos.y + 2.5 < self.pos.y then
v = -self.stray_velocity
elseif dist < 1.0 then
v = self.stray_velocity
else
v = random_range( -self.stray_velocity, self.stray_velocity )
end
self:set_velocity_vert( v )
end
if random( 5 ) == 1 then -- dodge player by sidestepping at random intervals
self:set_speed_lateral( random( -self.stray_velocity, self.stray_velocity ), 0 )
end
else
if self.can_fly and cycles % 2 == 0 then
local v = self.new_vel.y
-- descend or ascend to slightly above player altitude, but prevent incessant bobbing
if target_pos.y + 0.5 > self.pos.y then
v = target_pos.y - self.pos.y > self.standoff and self.run_velocity or self.stray_velocity
elseif target_pos.y + 2.5 < self.pos.y then
v = target_pos.y - self.pos.y < -self.standoff and -self.run_velocity or -self.stray_velocity
elseif dist > self.attack_range * 2 then
v = self.stray_velocity
elseif random( 10 ) == 1 then
v = random_range( -self.stray_velocity, self.stray_velocity )
end
self:set_velocity_vert( v )
elseif self.can_jump and self.move_result.is_standing and
self.yield_level < 3 and self.move_result.collides_xz then
self.yield_level = self.yield_level + 1
self:set_velocity_vert( 5 )
end
if self:get_target_yaw_delta( ) > rad_30 then
-- defend from turn-strafing
if random( 15 ) == 1 then
self:set_speed( 0 )
end
self:set_animation( "walk" )
elseif self.speed <= 0 then
self:set_speed( self.run_velocity )
self:set_animation( "run" )
end
end
end,
-- animation handler --
set_animation = function ( self, type )
if not self.animation or self.cur_animation == type then
return
end
if type == "stand" then
if self.animation.stand_start and self.animation.stand_end and self.animation.speed_normal then
self.object:set_animation(
{ x = self.animation.stand_start, y = self.animation.stand_end },
self.animation.speed_normal, 0
)
end
elseif type == "walk" then
if self.animation.walk_start and self.animation.walk_end and self.animation.speed_normal then
self.object:set_animation(
{ x = self.animation.walk_start, y = self.animation.walk_end },
self.animation.speed_normal, 0
)
end
elseif type == "run" then
if self.animation.run_start and self.animation.run_end and self.animation.speed_run then
self.object:set_animation(
{ x = self.animation.run_start, y = self.animation.run_end },
self.animation.speed_run, 0
)
end
elseif type == "swim" then
if self.animation.swim_start and self.animation.swim_end and self.animation.speed_swim then
self.object:set_animation(
{ x = self.animation.swim_start, y = self.animation.swim_end },
self.animation.speed_swim, 0
)
end
elseif type == "punch" then
if self.animation.punch_start and self.animation.punch_end and self.animation.speed_normal then
self.object:set_animation(
{ x = self.animation.punch_start, y = self.animation.punch_end },
self.animation.speed_normal, 0
)
end
else
return -- not a valid animation type, so abort
end
self.cur_animation = type
end,
-- damage handler --
handle_damage = function ( self )
-- handle environmental damage (light, water, and lava)
local hp = self.object:get_hp( )
local node = minetest.get_node_above( self.pos, 0.5 )
local time_of_day = minetest.get_timeofday( )
if node.name == "ignore" then return end -- mapblock is not loaded, so abort!
if self.light_damage and self.light_damage > 0 and self.pos.y > 0 and
minetest.get_node_light( self.pos ) > 4 and check_limits( time_of_day, 0.2, 0.8 ) then
hp = hp - self.light_damage
self.object:set_hp( hp )
if hp <= 0 then
smoke_effect( self.pos )
self.object:remove( )
end
end
if self.water_damage and self.water_damage > 0 and minetest.get_item_group( node.name, "water" ) ~= 0 then
hp = hp - self.water_damage
self.object:set_hp( hp )
if hp <= 0 then
smoke_effect( self.pos )
self.object:remove( )
end
end
if self.lava_damage and self.lava_damage > 0 and minetest.get_item_group( node.name, "lava" ) ~= 0 then
hp = hp - self.lava_damage
self.object:set_hp( hp )
if hp <= 0 then
smoke_effect( self.pos )
self.object:remove( )
end
end
end,
-- generic callbacks --
on_step = function( self, dtime, pos, rot, new_vel, old_vel, move_result )
self.pos = pos
self.yaw = rot.y
self.new_vel = new_vel
self.move_result = move_result
local hp = self.object:get_hp( )
if hp == 0 then
self.object:remove( )
return
end
self.timeout = self.timeout - dtime
if self.timeout <= 0 and not self.tamed then
smoke_effect( pos )
self.object:remove( )
return
end
if self.enable_fall_damage and move_result.collides_y and old_vel.y < -5 then
local damage = floor( -old_vel.y - 5 ) + 0.5
self.object:set_hp( hp - damage )
if hp - damage <= 0.0 then
smoke_effect( pos )
self.object:remove( )
return
end
end
if move_result.is_swimming then
local drag = -new_vel.y * liquid_viscosity * 1.5
local buoyancy = self.density - liquid_density
self.object:set_acceleration_vert( world_gravity * buoyancy + drag )
elseif self.is_swimming then
self.object:set_acceleration_vert( ramp( world_gravity, new_vel.y, 2.0 ) ) -- hack to reduce oscilations
end
self.is_swimming = move_result.is_swimming
if self.yield_level > 0 and not move_result.collides_xz then
self.yield_level = 0
end
self.timekeeper.on_step( dtime ) -- run timer callbacks
end,
on_activate = function ( self, staticdata, dtime_s, id )
registry.avatars[ id ] = self.object
if self.receptrons then
AxonObject( self, { fleshy = self.armor } ) -- only inherit from superclass if receptrons exist
end
if self.weapon_params then
TurretShooter( self ) -- only inherit from supereclass if weapon params exist
end
self:set_acceleration_vert( self.gravity )
self:set_velocity_vert( 0 )
self:set_yaw( random( ) * rad_360 )
self.yield_level = 0
self.move_result = { collides_xz = false, collides_y = true, is_standing = true }
self.pos = self.object:get_pos( )
self.action_funcs = {
ignore = self.start_ignore_action,
remark = self.start_remark_action,
search = self.start_search_action,
follow = self.start_follow_action,
escape = self.start_escape_action,
attack = self.start_attack_action,
}
self.watch_players = table.copy( self.watch_players )
self.watch_spawnitems = table.copy( self.watch_spawnitems )
self.watch_wielditems = table.copy( self.watch_wielditems )
if staticdata then
local tmp = minetest.deserialize( staticdata )
if tmp and tmp.lifetimer then
self.timeout = tmp.lifetimer - dtime_s
end
if tmp and tmp.tamed then
self.tamed = tmp.tamed
end
end
self.timeout = self.timeout - dtime_s
if self.timeout <= 0 and not self.is_tamed then
self.object:remove( )
return
end
self.timekeeper = Timekeeper( self )
self.timekeeper.start( 1.5, "damage", self.handle_damage )
if self.after_activate then
self.after_activate( self, id )
end
self:reset_alertness( self.neutral_state )
end,
on_deactivate = function ( self, id )
if self.before_deactivate then
self.before_deactivate( self, id )
end
registry.avatars[ id ] = nil
end,
get_staticdata = function ( self )
return minetest.serialize( {
lifetimer = self.timeout,
is_tamed = self.is_tamed,
} )
end,
on_punch = function ( self, hitter, time_from_last_punch, tool_capabilities, direction, damage )
local hp = self.object:get_hp( )
local pos = self.pos
local tool = hitter:get_wielded_item( )
if self.before_punch then
if not self.before_punch( self, hitter, tool, hp, damage ) then return end
end
if damage == 0 then return end
if hp - damage <= 0 and self.gibbage_params then
local params = self.gibbage_params
local intensity = 0
for k, v in pairs( tool_capabilities.damage_groups ) do
if params.damage_groups[ k ] and v >= params.damage_groups[ k ] then
intensity = max( intensity, v ) -- get maximum intensity among all damage groups
end
end
if intensity > 0 then
for i = 1, #params.pieces do
local obj = minetest.add_entity( vector.offset_y( self.pos, 1.5 ), "mobs:gibbage" )
obj:get_luaentity( ):launch(
intensity, params.textures[ random( #params.textures ) ], params.pieces[ i ], params.sound )
end
else
blood_effect( pos )
end
elseif self.makes_bloodshed_effect and random( 2 ) == 2 then
blood_effect( pos )
end
if self.sounds and self.sounds.damage_hand and self.sounds.damage_tool then
self:play_sound( minetest.registered_tools[ tool:get_name( ) ] and
self.sounds.damage_tool or self.sounds.damage_hand )
else
self:play_sound( "mobs_damage" )
end
if hitter:is_player( ) then
if tool then
tool:add_wear( 100 )
hitter:set_wielded_item( tool )
end
if hp - damage <= self.hp_low then
self:reset_alertness( self.retreat_state, { obj = hitter, pos = hitter:get_pos( ) } )
else
self:reset_alertness( self.defense_state, { obj = hitter, pos = hitter:get_pos( ) } )
end
end
end,
on_death = function( self, killer )
for _, drop in ipairs( self.drops ) do
if random( drop.chance ) == 1 then
minetest.spawn_item( self.pos, drop.name .. " " .. random( drop.min, drop.max ), 1, 5 )
end
end
if not self.makes_bloodshed_effect then
smoke_effect( self.pos )
end
if self.sounds and self.sounds.death then
self:play_sound( self.sounds.death )
end
if killer:is_player( ) then
if self.death_message then
minetest.chat_send_all( string.format( self.death_message, killer:get_player_name( ), self.description ) )
end
minetest.log( "action", name .. " killed by " .. killer:get_player_name( ) )
end
end,
} )
end
mobs.register_spawn = function ( name, def )
local max_object_count = def.max_object_count
local min_height = def.min_height
local max_height = def.max_height
local min_light = def.min_light
local max_light = def.max_light
local can_spawn = def.can_spawn
minetest.register_abm( {
nodenames = def.nodenames,
neighbors = { "air" },
interval = 10,
chance = def.chance,
action = function( pos, node, active_object_count, active_object_count_wider )
if active_object_count_wider > max_object_count then
return
end
local pos2 = vector.offset_y( pos )
if minetest.get_node( pos2 ).name ~= "air" or minetest.get_node_above( pos2 ).name ~= "air" then
return
elseif not check_limits( minetest.get_node_light( pos2 ), min_light, max_light ) then
return
elseif not check_limits( pos2.y, min_height, max_height ) then
return
elseif can_spawn and not can_spawn( pos2 ) then
return
end
minetest.log( "action", "Adding mob " .. name .. " on ".. node.name .." at " .. minetest.pos_to_string( pos ) )
minetest.add_entity( pos2, name )
end
} )
end
mobs.register_spawn_near = function ( name, def )
local nodenames = def.nodenames
local max_light = def.max_light
local min_light = def.min_light
local chance = def.chance
local vert_shift = def.vert_shift
local is_area_safe = def.is_area_safe
local safe_area = VoxelArea:new( { MinEdge = def.safe_edge1, MaxEdge = def.safe_edge2 } )
local can_spawn = def.can_spawn
globaltimer.shift( 0.5 )
globaltimer.start( 10, name, function ( )
for player_name, player in pairs( registry.players ) do
local player_pos = player:get_pos( )
if random( chance ) == 1 and is_area_safe == safe_area:containsp( player_pos ) then
local positions = minetest.find_nodes_in_area_under_air(
vector.offset( player_pos, -10, vert_shift - 5, -10 ),
vector.offset( player_pos, 10, vert_shift + 5, 10 ),
nodenames )
if #positions > 0 then
local y_offset = minetest.registered_entities[ name ].y_offset
local pos = positions[ random( #positions ) ]
local pos2 = vector.offset_y( pos, 2 )
if minetest.get_node( pos2 ).name == "air" and minetest.get_node_above( pos2 ).name == "air" and
check_limits( minetest.get_node_light( pos2 ), min_light, max_light ) and
( not can_spawn or can_spawn( pos2 ) ) then
minetest.log( "action", "Adding mob " .. name .. " near player " ..
player_name .. " at " .. minetest.pos_to_string( pos2 ) )
minetest.add_entity( vector.offset_y( pos2, y_offset ), name )
end
end
end
end
end )
end
mobs.register_spawner_node = function ( name, def )
local chance = def.chance
local min_light = def.min_light
local max_light = def.max_light
local mob_name = def.mob_name
def.on_timer = function ( pos, elapsed )
local y_offset = minetest.registered_entities[ mob_name ].y_offset
local pos2 = vector.offset_y( pos )
if random( chance ) == 1 and check_limits( minetest.get_node_light( pos2 ), min_light, max_light ) and
minetest.get_node( pos2 ).name == "air" and minetest.get_node_above( pos2 ).name == "air" then
minetest.add_entity( vector.offset_y( pos2, y_offset ), mob_name )
minetest.log( "action", "Adding mob " .. mob_name .. " on " .. name .. " at " .. minetest.pos_to_string( pos ) )
end
return true
end
def.after_place_node = function ( pos, player, itemstack )
minetest.get_node_timer( pos ):start( 10 )
end
minetest.register_node( name, def )
end
--------------------
mobs.play_sound = function ( pos, name )
minetest.sound_play( name, { loop = false }, true )
end
mobs.make_noise = function ( pos, radius, group, intensity )
axon.generate_radial_stimulus( pos, radius, 0.0, 0.0, { [group] = intensity }, { avatars = true } )
end
mobs.make_noise_repeat = function ( pos, radius, interval, duration, group, intensity )
globaltimer.start_now( interval, "noise" .. next_noise_id, function ( this, cycles, period, elapsed )
if elapsed >= duration then return true end -- we're finished, so cancel timer
axon.generate_radial_stimulus( pos, radius, 0.0, 0.0, { [group] = intensity }, { avatars = true } )
end )
next_noise_id = next_noise_id + 1
end
mobs.insert_object = function ( id, obj )
registry.objects[ id ] = obj
end
mobs.remove_object = function ( id )
registry.objects[ id ] = nil
end
--------------------
mobs.presets = {
grab_handout = function ( def )
local grab_chance = def.grab_chance
local wait_chance = def.wait_chance
local can_eat = def.can_eat
return function ( self, target_obj, elapsed )
if random( grab_chance ) == 1 then
local target_pos = vector.offset_y( target_obj:get_pos( ) )
local dist = vector.distance( self.pos, target_pos )
if self:get_direct_yaw_delta( target_pos ) < rad_30 and dist <= self.pickup_range then
if target_obj:is_player( ) then
target_obj:get_wielded_item( ):take_item( )
target_obj:set_wielded_item( "" )
elseif target_obj:get_entity_name( ) == "__builtin:item" then
target_obj:remove( )
end
if can_eat then
-- minetest.sound_play( "hbhunger_eat_generic", {
-- object = self.object,
-- max_hear_distance = 10,
-- gain = 1.0
-- } )
minetest.add_particle( {
pos = vector.offset_y( self.pos, 0.5 ),
velocity = { x = 0, y = 0.8, z = 0 },
acceleration = { x = 0, y = 0, z = 0 },
exptime = 4.0,
size = 3,
collisiondetection = false,
vertical = true,
texture = "heart.png",
} )
end
return self.neutral_state
end
end
return random( wait_chance ) == 1 and "follow" or self.offense_state
end
end,
}
--------------------
local emit_defs = { "mobs:griefer_ghost" }
minetest.register_chatcommand( "mobs", {
description = "Spawn random mobs in the area (for testing purposes or just plain fun).",
privs = { server = true },
func = function( player_name, param )
local pos = minetest.get_player_by_name( player_name ):getpos( )
local total = param ~= "" and tonumber( param ) or 10
for count = 1, total do
local index = math.random( #emit_defs )
local y = pos.y + minetest.registered_entities[ emit_defs[ index ] ].y_offset + 2
local x = pos.x + math.random( -5, 5 )
local z = pos.z + math.random( -5, 5 )
minetest.add_entity( { x = x, y = y, z = z }, emit_defs[ index ] )
end
end
} )
--------------------
dofile( minetest.get_modpath( "mobs" ) .. "/extras.lua" )
dofile( minetest.get_modpath( "mobs" ) .. "/monsters.lua" )
dofile( minetest.get_modpath( "mobs" ) .. "/animals.lua" )
-- compatibility for Minetest S3 engine
if not vector.origin then
dofile( minetest.get_modpath( "mobs" ) .. "/compatibility.lua" )
end