local S = minetest.get_translator("rp_mobs") local mod_textures = minetest.get_modpath("rp_textures") ~= nil -- If true, will write the task queues of mobs as their nametag local TASK_DEBUG = false local STATE_DEBUG = false -- Default gravity that affects the mobs local GRAVITY = tonumber(minetest.settings:get("movement_gravity")) or 9.81 -- If true, then only peaceful mobs may spawn local setting_peaceful_only = minetest.settings:get_bool("only_peaceful_mobs", false) -- Time it takes for a mob to die local DYING_TIME = 2 -- Default texture modifier when mob takes damage local DAMAGE_TEXTURE_MODIFIER = "^[colorize:#df2222:180" rp_mobs.GRAVITY_VECTOR = vector.new(0, -GRAVITY, 0) rp_mobs.internal.add_persisted_entity_vars({ "_custom_state", -- table to store mob-specific state variables "_dying", -- true if mob is currently dying (for animation) "_dying_timer", -- time since mob dying started "_killer_player_name", -- if mob was killed by a player, this contains their name. Otherwise nil "_textures_adult", -- persisted textures of mob in adult state "_textures_child", -- persisted textures of mob in child state }) local microtask_to_string = function(microtask) return "Microtask: "..(microtask.label or "") end local task_to_string = function(task) local str = "* Task: "..(task.label or "") local next_microtask = task.microTasks:iterator() local microtask = next_microtask() while microtask do str = str .. "\n** " .. microtask_to_string(microtask) microtask = next_microtask() end return str end local task_queue_to_string = function(task_queue) local str = "" local next_task = task_queue.tasks:iterator() local task = next_task() local first = true while task do if not first then str = str .. "\n" end str = str .. task_to_string(task) task = next_task() first = false end return str end local mob_task_queues_to_string = function(mob) local str = "" local next_task_queue = mob._task_queues:iterator() local task_queue = next_task_queue() local first = true local num = 1 while task_queue do if not first then str = str .. "\n" end str = str .. "Task queue #" .. num .. ":\n" num = num + 1 str = str .. task_queue_to_string(task_queue) task_queue = next_task_queue() first = false end return str end local mob_state_to_string = function(mob) local str = "Mob state:\n" str = str .. "* HP = "..mob.object:get_hp().."\n" local pevars = rp_mobs.internal.get_persisted_entity_vars() for p=1, #pevars do local var = pevars[p] local val = mob[var] local sval if type(val) == "number" then sval = string.format("%.1f", val) else sval = tostring(val) end str = str .. var .." = "..sval.."\n" end return str end local set_debug_nametag = function(mob) local str = "" if STATE_DEBUG then str = str .. mob_state_to_string(mob) end if TASK_DEBUG and not mob._dying then if STATE_DEBUG then str = str .. "\n" end str = str .. mob_task_queues_to_string(mob) end mob.object:set_properties({ nametag = str, }) end -- on_die callback function support local registered_on_dies = {} local trigger_on_die = function(mob, killer) local mobdef = rp_mobs.registered_mobs[mob.name] if not mobdef then error("[rp_mobs] trigger_on_die was called on something that is not a registered mob! name="..tostring(self.name)) end for f=1, #registered_on_dies do local func = registered_on_dies[f] func(mob, killer) end end rp_mobs.register_on_die = function(callback) table.insert(registered_on_dies, callback) end -- Register mob rp_mobs.registered_mobs = {} rp_mobs.register_mob = function(mobname, def) local mdef = table.copy(def) local initprop if def.entity_definition and def.entity_definition.initial_properties then initprop = def.entity_definition.initial_properties else initprop = {} end if not initprop.damage_texture_modifier then initprop.damage_texture_modifier = DAMAGE_TEXTURE_MODIFIER end mdef.entity_definition.initial_properties = initprop mdef.entity_definition._cmi_is_mob = true mdef.entity_definition._description = def.description mdef.entity_definition._tags = table.copy(def.tags or {}) mdef.entity_definition._base_size = table.copy(initprop.visual_size or { x=1, y=1, z=1 }) mdef.entity_definition._base_selbox = table.copy(initprop.selectionbox or { -0.5, -0.5, -0.5, 0.5, 0.5, 0.5, rotate = false }) mdef.entity_definition._base_colbox = table.copy(initprop.collisionbox or { -0.5, -0.5, -0.5, 0.5, 0.5, 0.5}) mdef.entity_definition._default_sounds = table.copy(def.default_sounds or {}) mdef.entity_definition._animations = table.copy(def.animations or {}) mdef.entity_definition._current_animation = nil mdef.entity_definition._dying = false mdef.entity_definition._dying_timer = 0 if def.textures_child then mdef.entity_definition._textures_child = def.textures_child end mdef.entity_definition._textures_adult = initprop.textures if def.front_body_point then mdef.entity_definition._front_body_point = table.copy(def.front_body_point) end if def.path_check_point then mdef.entity_definition._path_check_point = table.copy(def.path_check_point) end mdef.entity_definition._dead_y_offset = def.dead_y_offset rp_mobs.registered_mobs[mobname] = mdef minetest.register_entity(mobname, mdef.entity_definition) end rp_mobs.get_staticdata_default = function(self) local staticdata_table = {} local pevars = rp_mobs.internal.get_persisted_entity_vars() for p=1, #pevars do local pvar = pevars[p] local pvalue = self[pvar] staticdata_table[pvar] = pvalue end local staticdata = minetest.serialize(staticdata_table) return staticdata end rp_mobs.has_tag = function(mob, tag_name) return mob._tags[tag_name] == 1 end rp_mobs.mobdef_has_tag = function(mobname, tag_name) local mobdef = rp_mobs.registered_mobs[mobname] if not mobdef or not mobdef.entity_definition or not mobdef.entity_definition._tags then return false end return mobdef.entity_definition._tags[tag_name] == 1 end local flip_over_collisionbox = function(box, is_child, y_offset) local off if is_child then off = y_offset / 2 else off = y_offset end -- Y box[2] = box[2] + off box[5] = box[2] + (box[6] - box[3]) return box end local get_dying_boxes = function(mob) local props = mob.object:get_properties() local colbox = props.collisionbox if not mob._dead_y_offset then minetest.log("warning", "[rp_mobs_mobs] No dead_y_offset specified for mob '"..mob.name.."'!") end colbox = flip_over_collisionbox(colbox, mob._child, mob._dead_y_offset or 0) local selbox = props.selectionbox return colbox, selbox end rp_mobs.restore_state = function(self, staticdata) local staticdata_table = minetest.deserialize(staticdata) if not staticdata_table then -- Default for empty/invalid staticdata self._custom_state = {} self._temp_custom_state = {} return end for k,v in pairs(staticdata_table) do self[k] = v end if self._child then rp_mobs.set_mob_child_properties(self) end if self._dying then local colbox, selbox = get_dying_boxes(self) self.object:set_properties({ collisionbox = colbox, selectionbox = selbox, damage_texture_modifier = "", makes_footstep_sound = false, }) end -- Make sure the custom state vars are always tables if not self._custom_state then self._custom_state = {} end if not self._temp_custom_state then self._temp_custom_state = {} end end rp_mobs.is_alive = function(mob) if not mob then return false elseif mob._dying then return false else return true end end local get_drops = function(droptable) local to_drop = {} for d=1, #droptable do local drop = droptable[d] if type(drop) == "string" then table.insert(to_drop, drop) elseif type(drop) == "table" then local rnd = math.random(1, drop.chance) if rnd == 1 then local count = math.random(drop.min, drop.max) if count > 0 then drop = drop.name .. " "..count table.insert(to_drop, drop) end end else minetest.log("error", "[rp_mobs] Invalid drop in mob drop table: "..tostring(drop)) end end return to_drop end rp_mobs.spawn_mob_drop = function(pos, item) local obj = minetest.add_item(pos, item) if obj then obj:set_velocity({ x = math.random(-100, 100)/100, y = 5, z = math.random(-100, 100)/100 }) end return obj end rp_mobs.drop_death_items = function(self, pos) if not pos then pos = self.object:get_pos() end local mobdef = rp_mobs.registered_mobs[self.name] if not mobdef then error("[rp_mobs] rp_mobs.drop_death_items was called on something that is not a registered mob! name="..tostring(self.name)) end if not self._child and mobdef.drops then local drops = get_drops(mobdef.drops) for d=1, #drops do rp_mobs.spawn_mob_drop(pos, drops[d]) end end if self._child and mobdef.child_drops then local drops = get_drops(mobdef.child_drops) for d=1, #drops do rp_mobs.spawn_mob_drop(pos, drops[d]) end end if mobdef.drop_func then local drop_func_drops = mobdef.drop_func(self) for d=1, #drop_func_drops do rp_mobs.spawn_mob_drop(pos, drop_func_drops[d]) end end end local get_mob_death_particle_radius = function(self) local colbox = self._base_colbox local x,y,z x = colbox[4]-colbox[1] y = colbox[5]-colbox[2] z = colbox[6]-colbox[3] local radius = x if y > radius then radius = y end if z > radius then radius = z end return radius end rp_mobs.on_death_default = function(self, killer) local radius = get_mob_death_particle_radius(self) local pos = self.object:get_pos() if mod_textures then minetest.add_particlespawner({ amount = 16, time = 0.02, pos = { min = vector.subtract(pos, radius / 2), max = vector.add(pos, radius / 2), }, vel = { min = vector.new(-1, 0, -1), max = vector.new(1, 2, 1), }, acc = vector.zero(), exptime = { min = 0.4, max = 0.8 }, size = { min = 8, max = 12 }, drag = vector.new(1,1,1), texture = { name = "rp_textures_death_smoke_anim_1.png", animation = { type = "vertical_frames", aspect_w = 16, aspect_h = 16, length = -1 }, name = "rp_textures_death_smoke_anim_2.png", animation = { type = "vertical_frames", aspect_w = 16, aspect_h = 16, length = -1 }, name = "rp_textures_death_smoke_anim_1.png^[transformFX", animation = { type = "vertical_frames", aspect_w = 16, aspect_h = 16, length = -1 }, name = "rp_textures_death_smoke_anim_2.png^[transformFX", animation = { type = "vertical_frames", aspect_w = 16, aspect_h = 16, length = -1 }, }, }) end minetest.sound_play({name="rp_sounds_disappear", gain=0.4}, {pos=pos, max_hear_distance=12}, true) rp_mobs.drop_death_items(self) end rp_mobs.on_punch_default = function(self, puncher, time_from_last_punch, tool_capabilities, dir, damage) if self._dying then return true end if not damage then return end if self.object:get_hp() - damage <= 0 then -- This punch kills the mob rp_mobs.die(self, puncher) return true end -- Play default punch/damage sound if damage >= 1 then rp_mobs.default_mob_sound(self, "damage") else rp_mobs.default_mob_sound(self, "punch_no_damage") end end rp_mobs.damage = function(self, damage, no_sound, damager) if damage <= 0 or self._dying then return false end local hp = self.object:get_hp() hp = math.max(0, hp - damage) if hp <= 0 then rp_mobs.die(self, damager) return true else self.object:set_hp(hp) if not no_sound then rp_mobs.default_mob_sound(self, "damage") end end return false end rp_mobs.heal = function(self, heal) if heal <= 0 then return false end if not rp_mobs.is_alive(self) then return false end local hp = self.object:get_hp() local hp_max = self.object:get_properties().hp_max hp = math.min(hp_max, hp + heal) self.object:set_hp(hp) return true end rp_mobs.init_tasks = function(self) self._task_queues = rp_mobs.DoublyLinkedList() self._active_task_queue = nil end rp_mobs.init_mob = function(self) if setting_peaceful_only and not rp_mobs.has_tag(self, "peaceful") then self.object:remove() minetest.log("action", "[mobs] Hostile mob '"..self.name.."' removed at "..minetest.pos_to_string(self.object:get_pos(), 1).." (only peaceful mobs allowed)") return end end rp_mobs.create_task_queue = function(empty_decider, step_decider, start_decider) return { tasks = rp_mobs.DoublyLinkedList(), empty_decider = empty_decider, step_decider = step_decider, start_decider = start_decider, } end rp_mobs.add_task_queue = function(self, task_queue) self._task_queues:append(task_queue) end rp_mobs.add_task_to_task_queue = function(task_queue, task) task_queue.tasks:append(task) task.task_queue = task_queue if task.generateMicroTasks then task:generateMicroTasks() end end rp_mobs.end_current_task_in_task_queue = function(mob, task_queue) local first = task_queue.tasks:getFirst() if first then task_queue.tasks:remove(first) end end rp_mobs.clear_task_queue = function(task_queue) task_queue.tasks:removeAll() end rp_mobs.create_task = function(def) local task if def then task = table.copy(def) else task = {} end task.microTasks = rp_mobs.DoublyLinkedList() return task end rp_mobs.create_microtask = function(def) local mtask if def then mtask = table.copy(def) else mtask = {} end mtask.statedata = {} return mtask end rp_mobs.add_microtask_to_task = function(self, microtask, task) local ret = task.microTasks:append(microtask) microtask.task = task return ret end rp_mobs.handle_tasks = function(self, dtime, moveresult) if not self._task_queues then minetest.log("error", "[rp_mobs] rp_mobs.handle_tasks called before tasks were initialized!") return end if not rp_mobs.is_alive(self) then if TASK_DEBUG or STATE_DEBUG then set_debug_nametag(self) end return end -- Trivial case: No task queues, nothing to do if self._task_queues:isEmpty() then return end local active_task_queue_entry = self._task_queues:getFirst() while active_task_queue_entry do local task_queue_done = false local activeTaskQueue = active_task_queue_entry.data -- Run start decider in very first step if activeTaskQueue.start_decider and not activeTaskQueue.started then activeTaskQueue:start_decider(self) end -- Run empty decider if active task queue is empty local activeTaskEntry if activeTaskQueue.tasks:isEmpty() then if activeTaskQueue.empty_decider then activeTaskQueue:empty_decider(self) end end -- Run step decider if activeTaskQueue.step_decider then activeTaskQueue:step_decider(self, dtime) end -- Mark task queue as started activeTaskQueue.started = true activeTaskEntry = activeTaskQueue.tasks:getFirst() if not activeTaskEntry then -- No more microtasks: Set idle animation if it exists if self._animations and self._animations.idle then rp_mobs.set_animation(self, "idle") end task_queue_done = true end -- Handle current task of active task queue local activeTask if activeTaskEntry then activeTask = activeTaskEntry.data end local activeMicroTaskEntry if not task_queue_done then activeMicroTaskEntry = activeTask.microTasks:getFirst() if not activeMicroTaskEntry then activeTaskQueue.tasks:remove(activeTaskEntry) if TASK_DEBUG or STATE_DEBUG then set_debug_nametag(self) end task_queue_done = true end end local microtaskFinished = false local microtaskSuccess -- Remove microtask if completed local activeMicroTask if not task_queue_done then activeMicroTask = activeMicroTaskEntry.data if not activeMicroTask.has_started and activeMicroTask.on_start then activeMicroTask:on_start(self) end if not activeMicroTask.singlestep then microtaskFinished, microtaskSuccess = activeMicroTask:is_finished(self) if microtaskFinished then if activeMicroTask.on_end then activeMicroTask:on_end(self) end activeTask.microTasks:remove(activeMicroTaskEntry) task_queue_done = true end end end -- Execute microtask -- Set microtask animation before the first step if not task_queue_done and not activeMicroTask.has_started then if activeMicroTask.start_animation then rp_mobs.set_animation(self, activeMicroTask.start_animation) end activeMicroTask.has_started = true end -- on_step: The main microtask logic goes here if not task_queue_done then activeMicroTask:on_step(self, dtime, moveresult) end -- If singlestep is set, finish microtask after its first and only step if not task_queue_done and activeMicroTask.singlestep then microtaskFinished, microtaskSuccess = true, true if activeMicroTask.on_end then activeMicroTask:on_end(self) end activeTask.microTasks:remove(activeMicroTaskEntry) task_queue_done = true end -- If microtask failed, clear the whole task if microtaskFinished == true and microtaskSuccess == false then activeTask.microTasks:removeAll() end -- Select next task queue local nexxt = active_task_queue_entry.nextEntry active_task_queue_entry = nexxt if not active_task_queue_entry then break end end if TASK_DEBUG or STATE_DEBUG then set_debug_nametag(self) end end rp_mobs.die = function(self, killer) if not rp_mobs.is_alive(self) then return end trigger_on_die(self, killer) if killer and killer:is_player() and not self._killer_player_name then self._killer_player_name = killer:get_player_name() end -- Set HP to 1 and _dying to true. -- This indicates the mob is currently dying. -- We can't set HP to 0 yet because that would insta-remove -- the mob (and thus skip the 'dying' delay and animation) self.object:set_hp(1) self._dying = true self._dying_timer = 0 rp_mobs.default_mob_sound(self, "death") rp_mobs.set_animation(self, "dead_static") local colbox, selbox = get_dying_boxes(self) if self._dead_y_offset and self._dead_y_offset < 0 then local repos = self.object:get_pos() local y = math.abs(self._dead_y_offset) repos = vector.offset(repos, 0, y, 0) self.object:set_pos(repos) end self.object:set_properties({ collisionbox = colbox, selectionbox = selbox, damage_texture_modifier = "", makes_footstep_sound = false, }) -- Set roll local roll = math.pi/2 local rot = self.object:get_rotation() rot.z = roll self.object:set_rotation(rot) end rp_mobs.handle_dying = function(self, dtime, moveresult, dying_step) if rp_mobs.is_alive(self) then return end if dying_step then dying_step(self, dtime, moveresult) end -- Trigger actual death when timer runs out self._dying_timer = self._dying_timer + dtime if self._dying_timer >= DYING_TIME then self.object:set_hp(0) end end rp_mobs.register_mob_item = function(mobname, invimg, desc, on_create_capture_item) local place if not desc then desc = rp_mobs.registered_mobs[mobname].description end local entdef = minetest.registered_entities[mobname] if not entdef then minetest.log("error", "[rp_mobs] rp_mobs.register_mob_item was called for mob '"..mobname.."' but it doesn't exist!") return end minetest.register_craftitem(mobname, { description = desc, inventory_image = invimg, groups = { spawn_egg = 1 }, stack_max = 1, on_place = function(itemstack, placer, pointed_thing) if setting_peaceful_only and not rp_mobs.mobdef_has_tag(mobname, "peaceful") then if placer and placer:is_player() then local pname = placer:get_player_name() minetest.chat_send_player(pname, minetest.colorize("#FFFF00", S("Hostile mobs are disabled!"))) end return itemstack end local handled, handled_itemstack = util.on_place_pointed_node_handler(itemstack, placer, pointed_thing) if handled then return handled_itemstack end if pointed_thing.type == "node" then local pos = pointed_thing.above local pname = placer:get_player_name() -- Can't violate protection if minetest.is_protected(pos, pname) and not minetest.check_player_privs(placer, "protection_bypass") then minetest.record_protection_violation(pos, pname) return itemstack end -- Can't place into solid or unknown node local pnode = minetest.get_node(pos) local pdef = minetest.registered_nodes[pnode.name] if not pdef or pdef.walkable then rp_sounds.play_place_failed_sound(placer) return itemstack end -- Get HP and staticdata from metadata local imeta = itemstack:get_meta() local hp = imeta:get_int("hp") local staticdata = imeta:get_string("staticdata") -- Spawn mob pos.y = pos.y + 0.5 local mob = minetest.add_entity(pos, mobname, staticdata) local ent = mob:get_luaentity() if hp > 0 then mob:set_hp(hp) end -- Finalize minetest.log("action", "[rp_mobs] "..pname.." spawns "..mobname.." at "..minetest.pos_to_string(pos, 1)) if not minetest.is_creative_enabled(pname) then itemstack:take_item() end end return itemstack end, _on_create_capture_item = on_create_capture_item, }) end rp_mobs.mob_sound = function(self, sound, keep_pitch) local pitch if not keep_pitch then if self._child then pitch = 1.5 else pitch = 1.0 end pitch = pitch + 0.0025 * math.random(-10,10) end minetest.sound_play(sound, { pitch = pitch, object = self.object, }, true) end rp_mobs.default_mob_sound = function(self, default_sound, keep_pitch) local sound = self._default_sounds[default_sound] if sound then rp_mobs.mob_sound(self, sound, keep_pitch) end end rp_mobs.default_hurt_sound = function(self, keep_pitch) rp_mobs.default_mob_sound(self, "damage", keep_pitch) end rp_mobs.set_animation = function(self, animation_name, animation_speed) local anim = self._animations[animation_name] if not anim then minetest.log("error", "[rp_mobs] set_animation for mob '"..tostring(self.name).."' called with unknown animation_name: "..tostring(animation_name)) return end local anim_speed = animation_speed or anim.default_frame_speed if self._current_animation ~= animation_name then self._current_animation = animation_name self._current_animation_speed = anim_speed self.object:set_animation(anim.frame_range, anim_speed) elseif self._current_animation_speed ~= anim_speed then self.object:set_animation_frame_speed(anim_speed) end end rp_mobs.drag = function(self, dtime, drag, drag_axes) local realvel = self.object:get_velocity() local targetvel = vector.zero() targetvel.y = realvel.y local MOVE_SPEED_MAX_DIFFERENCE = 0.01 for _, axis in pairs(drag_axes) do if drag[axis] ~= 0 and math.abs(realvel[axis]) > MOVE_SPEED_MAX_DIFFERENCE then if realvel[axis] > targetvel[axis] then targetvel[axis] = math.max(0, realvel[axis] - drag[axis]) else targetvel[axis] = math.min(0, realvel[axis] + drag[axis]) end end end self.object:set_velocity(targetvel) end minetest.register_on_chatcommand(function(name, command, params) if not setting_peaceful_only then return end if command == "spawnentity" then local entityname = string.match(params, "[a-zA-Z0-9_:]+") if not entityname or not rp_mobs.registered_mobs[entityname] then return end if not rp_mobs.mobdef_has_tag(entityname, "peaceful") then minetest.chat_send_player(pname, minetest.colorize("#FFFF00", S("Hostile mobs are disabled!"))) return true end end end)