-- Advanced NPC by Zorman2000 -- Based on original NPC by Tenplus1 local S = mobs.intllib npc = {} -- Constants npc.FEMALE = "female" npc.MALE = "male" npc.age = { adult = "adult", child = "child" } npc.INVENTORY_ITEM_MAX_STACK = 99 npc.ANIMATION_STAND_START = 0 npc.ANIMATION_STAND_END = 79 npc.ANIMATION_SIT_START = 81 npc.ANIMATION_SIT_END = 160 npc.ANIMATION_LAY_START = 162 npc.ANIMATION_LAY_END = 166 npc.ANIMATION_WALK_START = 168 npc.ANIMATION_WALK_END = 187 npc.ANIMATION_MINE_START = 189 npc.ANIMATION_MINE_END =198 npc.direction = { north = 0, east = 1, south = 2, west = 3, north_east = 4, north_west = 5, south_east = 6, south_west = 7 } npc.action_state = { none = 0, executing = 1, interrupted = 2 } npc.log_level = { INFO = true, WARNING = true, ERROR = true, DEBUG = false, DEBUG_ACTION = false, DEBUG_SCHEDULE = false } npc.texture_check = { timer = 0, interval = 2 } --------------------------------------------------------------------------------------- -- General functions --------------------------------------------------------------------------------------- -- Logging function npc.log(level, message) if npc.log_level[level] then minetest.log("[advanced_npc] "..level..": "..message) end end -- NPC chat function npc.chat(npc_name, player_name, message) minetest.chat_send_player(player_name, npc_name..": "..message) end -- Simple wrapper over minetest.add_particle() -- Copied from mobs_redo/api.lua function npc.effect(pos, amount, texture, min_size, max_size, radius, gravity, glow) radius = radius or 2 min_size = min_size or 0.5 max_size = max_size or 1 gravity = gravity or -10 glow = glow or 0 minetest.add_particlespawner({ amount = amount, time = 0.25, minpos = pos, maxpos = pos, 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, glow = glow, }) end -- Gets name of player or NPC function npc.get_entity_name(entity) if entity:is_player() then return entity:get_player_name() else return entity:get_luaentity().name end end -- Returns the item "wielded" by player or NPC -- TODO: Implement NPC function npc.get_entity_wielded_item(entity) if entity:is_player() then return entity:get_wielded_item() end end --------------------------------------------------------------------------------------- -- Spawning functions --------------------------------------------------------------------------------------- -- These functions are used at spawn time to determine several -- random attributes for the NPC in case they are not already -- defined. On a later phase, pre-defining many of the NPC values -- will be allowed. local function get_random_name(sex) local i = math.random(#npc.data.FIRST_NAMES[sex]) return npc.data.FIRST_NAMES[sex][i] end local function initialize_inventory() return { [1] = "", [2] = "", [3] = "", [4] = "", [5] = "", [6] = "", [7] = "", [8] = "", [9] = "", [10] = "", [11] = "", [12] = "", [13] = "", [14] = "", [15] = "", [16] = "", } end -- This function checks for "female" text on the texture name local function is_female_texture(textures) for i = 1, #textures do if string.find(textures[i], "female") ~= nil then return true end end return false end function npc.assign_sex_from_texture(self) if is_female_texture(self.base_texture) then return npc.FEMALE else return npc.MALE end end local function get_random_texture(sex, age) local textures = {} local filtered_textures = {} -- Find textures by sex and age if age == npc.age.adult then --minetest.log("Registered: "..dump(minetest.registered_entities["advanced_npc:npc"])) textures = minetest.registered_entities["advanced_npc:npc"].texture_list elseif age == npc.age.child then textures = minetest.registered_entities["advanced_npc:npc"].child_texture end for i = 1, #textures do local current_texture = textures[i][1] if (sex == npc.MALE and string.find(current_texture, sex) and not string.find(current_texture, npc.FEMALE)) or (sex == npc.FEMALE and string.find(current_texture, sex)) then table.insert(filtered_textures, current_texture) end end -- Check if filtered textures is empty if filtered_textures == {} then return textures[1][1] end return filtered_textures[math.random(1,#filtered_textures)] end function npc.get_random_texture_from_array(age, sex, textures) local filtered_textures = {} for i = 1, #textures do local current_texture = textures[i] -- Filter by age if (sex == npc.MALE and string.find(current_texture, sex) and not string.find(current_texture, npc.FEMALE) and ((age == npc.age.adult and not string.find(current_texture, npc.age.child)) or (age == npc.age.child and string.find(current_texture, npc.age.child)) ) ) or (sex == npc.FEMALE and string.find(current_texture, sex) and ((age == npc.age.adult and not string.find(current_texture, npc.age.child)) or (age == npc.age.child and string.find(current_texture, npc.age.child)) ) ) then table.insert(filtered_textures, current_texture) end end -- Check if there are no textures if #filtered_textures == 0 then -- Return whole array for re-evaluation npc.log("DEBUG", "No textures found, returning original array") return textures end return filtered_textures[math.random(1, #filtered_textures)] end -- Choose whether NPC can have relationships. Only 30% of NPCs -- cannot have relationships local function can_have_relationships(is_child) -- Children can't have relationships if is_child then return false end local chance = math.random(1,10) return chance > 3 end -- Choose a maximum of two items that the NPC will have at spawn time -- These items are chosen from the favorite items list. local function choose_spawn_items(self) local number_of_items_to_add = math.random(1, 2) local number_of_items = #npc.FAVORITE_ITEMS[self.sex].phase1 for i = 1, number_of_items_to_add do npc.add_item_to_inventory( self, npc.FAVORITE_ITEMS[self.sex].phase1[math.random(1, number_of_items)].item, math.random(1,5) ) end -- Add currency to the items spawned with. Will add 5-10 tier 3 -- currency items local currency_item_count = math.random(5, 10) npc.add_item_to_inventory(self, npc.trade.prices.currency.tier3.string, currency_item_count) -- For test --npc.add_item_to_inventory(self, "default:tree", 10) --npc.add_item_to_inventory(self, "default:cobble", 10) --npc.add_item_to_inventory(self, "default:diamond", 2) --npc.add_item_to_inventory(self, "default:mese_crystal", 2) --npc.add_item_to_inventory(self, "flowers:rose", 2) --npc.add_item_to_inventory(self, "advanced_npc:marriage_ring", 2) --npc.add_item_to_inventory(self, "flowers:geranium", 2) --npc.add_item_to_inventory(self, "mobs:meat", 2) --npc.add_item_to_inventory(self, "mobs:leather", 2) --npc.add_item_to_inventory(self, "default:sword_stone", 2) --npc.add_item_to_inventory(self, "default:shovel_stone", 2) --npc.add_item_to_inventory(self, "default:axe_stone", 2) --minetest.log("Initial inventory: "..dump(self.inventory)) end -- Spawn function. Initializes all variables that the -- NPC will have and choose random, starting values function npc.initialize(entity, pos, is_lua_entity, npc_stats, occupation_name) npc.log("INFO", "Initializing NPC at "..minetest.pos_to_string(pos)) -- Get variables local ent = entity if not is_lua_entity then ent = entity:get_luaentity() end -- Avoid NPC to be removed by mobs_redo API ent.remove_ok = false -- Determine sex and age -- If there's no previous NPC data, sex and age will be randomly chosen. -- - Sex: Female or male will have each 50% of spawning -- - Age: 90% chance of spawning adults, 10% chance of spawning children. -- If there is previous data then: -- - Sex: The unbalanced sex will get a 75% chance of spawning -- - Example: If there's one male, then female will have 75% spawn chance. -- - If there's male and female, then each have 50% spawn chance. -- - Age: For each two adults, the chance of spawning a child next will be 50% -- If there's a child for two adults, the chance of spawning a child goes to -- 40% and keeps decreasing unless two adults have no child. -- Use NPC stats if provided if npc_stats then -- Default chances local male_s, male_e = 0, 50 local female_s, female_e = 51, 100 local adult_s, adult_e = 0, 85 local child_s, child_e = 86, 100 -- Determine sex probabilities if npc_stats[npc.FEMALE].total > npc_stats[npc.MALE].total then male_e = 75 female_s, female_e = 76, 100 elseif npc_stats[npc.FEMALE].total < npc_stats[npc.MALE].total then male_e = 25 female_s, female_e = 26, 100 end -- Determine age probabilities if npc_stats["adult_total"] >= 2 then if npc_stats["adult_total"] % 2 == 0 and (npc_stats["adult_total"] / 2 > npc_stats["child_total"]) then child_s,child_e = 26, 100 adult_e = 25 else child_s, child_e = 61, 100 adult_e = 60 end end -- Get sex and age based on the probabilities local sex_chance = math.random(1, 100) local age_chance = math.random(1, 100) local selected_sex = "" local selected_age = "" -- Select sex if male_s <= sex_chance and sex_chance <= male_e then selected_sex = npc.MALE elseif female_s <= sex_chance and sex_chance <= female_e then selected_sex = npc.FEMALE end -- Set sex for NPC ent.sex = selected_sex -- Select age if adult_s <= age_chance and age_chance <= adult_e then selected_age = npc.age.adult elseif child_s <= age_chance and age_chance <= child_e then selected_age = npc.age.child ent.visual_size = { x = 0.65, y = 0.65 } ent.collisionbox = {-0.10,-0.50,-0.10, 0.10,0.40,0.10} ent.is_child = true -- For mobs_redo ent.child = true end -- Store the selected age ent.age = selected_age -- Set texture accordingly local selected_texture = get_random_texture(selected_sex, selected_age) --minetest.log("Selected texture: "..dump(selected_texture)) -- Store selected texture due to the need to restore it later ent.selected_texture = selected_texture -- Set texture and base texture ent.textures = {selected_texture} ent.base_texture = {selected_texture} else -- Get sex based on texture. This is a 50% chance for -- each sex as there's same amount of textures for male and female. -- Do not spawn child as first NPC ent.sex = npc.assign_sex_from_texture(ent) ent.age = npc.age.adult end -- Initialize all gift data ent.gift_data = { -- Choose favorite items. Choose phase1 per default favorite_items = npc.relationships.select_random_favorite_items(ent.sex, "phase1"), -- Choose disliked items. Choose phase1 per default disliked_items = npc.relationships.select_random_disliked_items(ent.sex), -- Enable/disable gift item hints dialogue lines enable_gift_items_hints = true } -- Flag that determines if NPC can have a relationship ent.can_have_relationship = can_have_relationships(ent.is_child) --ent.infotext = "Interested in relationships: "..dump(ent.can_have_relationship) -- Flag to determine if NPC can receive gifts ent.can_receive_gifts = ent.can_have_relationship -- Initialize relationships object ent.relationships = {} -- Determines if NPC is married or not ent.is_married_to = nil -- Initialize dialogues ent.dialogues = npc.dialogue.select_random_dialogues_for_npc(ent, "phase1") -- Declare NPC inventory ent.inventory = initialize_inventory() -- Choose items to spawn with choose_spawn_items(ent) -- Flags: generic booleans or functions that help drive functionality ent.flags = {} -- Declare trade data ent.trader_data = { -- Type of trader trader_status = npc.trade.get_random_trade_status(), -- Current buy offers buy_offers = {}, -- Current sell offers sell_offers = {}, -- Items to buy change timer change_offers_timer = 0, -- Items to buy change timer interval change_offers_timer_interval = 60, -- Trading list: a list of item names the trader is expected to trade in. -- It is mostly related to its occupation. -- If empty, the NPC will revert to casual trading -- If not, it will try to sell those that it have, and buy the ones it not. trade_list = {}, -- Custom trade allows to specify more than one payment -- and a custom prompt (instead of the usual buy or sell prompts) custom_trades = {} } -- Initialize trading offers for NPC --npc.trade.generate_trade_offers_by_status(ent) -- if ent.trader_data.trader_status == npc.trade.CASUAL then -- select_casual_trade_offers(ent) -- end -- Actions data ent.actions = { -- The queue is a queue of actions to be performed on each interval queue = {}, -- Current value of the action timer action_timer = 0, -- Determines the interval for each action in the action queue -- Default is 1. This can be changed via actions action_interval = npc.actions.default_interval, -- Avoid the execution of the action timer action_timer_lock = false, -- Defines the state of the current action current_action_state = npc.action_state.none, -- Store information about action on state before lock state_before_lock = { -- State of the mobs_redo API freeze = false, -- State of execution action_state = npc.action_state.none, -- Action executed while on lock interrupted_action = {} }, -- Walking variables -- required for implementing accurate movement code walking = { -- Defines whether NPC is walking to specific position or not is_walking = false, -- Path that the NPC is following path = {}, -- Target position the NPC is supposed to walk to in this step. NOTE: -- This is NOT the end of the path, but the next position in the path -- relative to the last position target_pos = {} } } -- This flag is checked on every step. If it is true, the rest of -- Mobs Redo API is not executed ent.freeze = nil -- This map will hold all the places for the NPC -- Map entries should be like: "bed" = {x=1, y=1, z=1} ent.places_map = {} -- Schedule data ent.schedules = { -- Flag to enable or disable the schedules functionality enabled = true, -- Lock for when executing a schedule lock = false, -- Queue of schedules executed -- Used to calculate dependencies temp_executed_queue = {}, -- An array of schedules, meant to be one per day at some point -- when calendars are implemented. Allows for only 7 schedules, -- one for each day of the week generic = {}, -- An array of schedules, meant to be for specific dates in the -- year. Can contain as many as possible. The keys will be strings -- in the format MM:DD date_based = {}, -- The following holds the check parameters provided by the -- current schedule current_check_params = {} } -- If occupation name given, override properties with -- occupation values and initialize schedules if occupation_name and occupation_name ~= "" and ent.age == npc.age.adult then -- Set occupation name ent.occupation_name = occupation_name -- Override relevant values npc.occupations.initialize_occupation_values(ent, occupation_name) end -- Nametag is initialized to blank ent.nametag = "" -- Set name ent.npc_name = get_random_name(ent.sex) -- Set ID ent.npc_id = tostring(math.random(1000, 9999))..":"..ent.npc_name -- TODO: Remove this - do inside occupation -- Dedicated trade test -- ent.trader_data.trade_list = { -- ["default:tree"] = {}, -- ["default:cobble"] = {}, -- ["default:wood"] = {}, -- ["default:diamond"] = {}, -- ["default:mese_crystal"] = {}, -- ["flowers:rose"] = {}, -- ["advanced_npc:marriage_ring"] = {}, -- ["flowers:geranium"] = {}, -- ["mobs:meat"] = {}, -- ["mobs:leather"] = {}, -- ["default:sword_stone"] = {}, -- ["default:shovel_stone"] = {}, -- ["default:axe_stone"] = {} -- } -- Generate trade offers npc.trade.generate_trade_offers_by_status(ent) -- Add a custom trade offer -- local offer1 = npc.trade.create_custom_sell_trade_offer("Do you want me to fix your steel sword?", "Fix steel sword", "Fix steel sword", "default:sword_steel", {"default:sword_steel", "default:iron_lump 5"}) -- table.insert(ent.trader_data.custom_trades, offer1) --local offer2 = npc.trade.create_custom_sell_trade_offer("Do you want me to fix your mese sword?", "Fix mese sword", "Fix mese sword", "default:sword_mese", {"default:sword_mese", "default:copper_lump 10"}) --table.insert(ent.trader_data.custom_trades, offer2) -- Set initialized flag on ent.initialized = true --npc.log("WARNING", "Spawned entity: "..dump(ent)) npc.log("INFO", "Successfully initialized NPC with name "..dump(ent.npc_name) ..", sex: "..ent.sex..", is child: "..dump(ent.is_child) ..", texture: "..dump(ent.textures)) -- Refreshes entity ent.object:set_properties(ent) end --------------------------------------------------------------------------------------- -- Trading functions --------------------------------------------------------------------------------------- function npc.generate_trade_list_from_inventory(self) local list = {} for i = 1, #self.inventory do list[npc.get_item_name(self.inventory[i])] = {} end self.trader_data.trade_list = list end function npc.set_trading_status(self, status) --minetest.log("Trader_data: "..dump(self.trader_data)) -- Set status self.trader_data.trader_status = status -- Re-generate trade offers npc.trade.generate_trade_offers_by_status(self) end --------------------------------------------------------------------------------------- -- Inventory functions --------------------------------------------------------------------------------------- -- NPCs inventories are restrained to 16 slots. -- Each slot can hold one item up to 99 count. -- Utility function to get item name from a string function npc.get_item_name(item_string) return ItemStack(item_string):get_name() end -- Utility function to get item count from a string function npc.get_item_count(item_string) return ItemStack(item_string):get_count() end -- Add an item to inventory. Returns true if add successful -- These function can be used to give items to other NPCs -- given that the "self" variable can be any NPC function npc.add_item_to_inventory(self, item_name, count) -- Check if NPC already has item local existing_item = npc.inventory_contains(self, item_name) if existing_item ~= nil and existing_item.item_string ~= nil then -- NPC already has item. Get count and see local existing_count = npc.get_item_count(existing_item.item_string) if (existing_count + count) < npc.INVENTORY_ITEM_MAX_STACK then -- Set item here self.inventory[existing_item.slot] = npc.get_item_name(existing_item.item_string).." "..tostring(existing_count + count) return true else --Find next free slot for i = 1, #self.inventory do if self.inventory[i] == "" then -- Found slot, set item self.inventory[i] = item_name.." "..tostring((existing_count + count) - npc.INVENTORY_ITEM_MAX_STACK) return true end end -- No free slot found return false end else -- Find a free slot for i = 1, #self.inventory do if self.inventory[i] == "" then -- Found slot, set item self.inventory[i] = item_name.." "..tostring(count) return true end end -- No empty slot found return false end end -- Same add method but with itemstring for convenience function npc.add_item_to_inventory_itemstring(self, item_string) local item_name = npc.get_item_name(item_string) local item_count = npc.get_item_count(item_string) npc.add_item_to_inventory(self, item_name, item_count) end -- Checks if an item is contained in the inventory. Returns -- the item string or nil if not found function npc.inventory_contains(self, item_name) for key,value in pairs(self.inventory) do if value ~= "" and string.find(value, item_name) then return {slot=key, item_string=value} end end -- Item not found return nil end -- Removes the item from an NPC inventory and returns the item -- with its count (as a string, e.g. "default:apple 2"). Returns -- nil if unable to get the item. function npc.take_item_from_inventory(self, item_name, count) local existing_item = npc.inventory_contains(self, item_name) if existing_item ~= nil then -- Found item local existing_count = npc.get_item_count(existing_item.item_string) local new_count = existing_count if existing_count - count < 0 then -- Remove item first self.inventory[existing_item.slot] = "" -- TODO: Support for retrieving from next stack. Too complicated -- and honestly might be unecessary. return item_name.." "..tostring(new_count) else new_count = existing_count - count if new_count == 0 then self.inventory[existing_item.slot] = "" else self.inventory[existing_item.slot] = item_name.." "..new_count end return item_name.." "..tostring(count) end else -- Not able to take item because not found return nil end end -- Same take method but with itemstring for convenience function npc.take_item_from_inventory_itemstring(self, item_string) local item_name = npc.get_item_name(item_string) local item_count = npc.get_item_count(item_string) npc.take_item_from_inventory(self, item_name, item_count) end --------------------------------------------------------------------------------------- -- Flag functionality --------------------------------------------------------------------------------------- -- TODO: Consider removing them as they are pretty simple and straight forward. -- Generic variables or function that help drive some functionality for the NPC. function npc.add_flag(self, flag_name, value) self.flags[flag_name] = value end function npc.update_flag(self, flag_name, value) self.flags[flag_name] = value end function npc.get_flag(self, flag_name) return self.flags[flag_name] end --------------------------------------------------------------------------------------- -- Dialogue functionality --------------------------------------------------------------------------------------- function npc.start_dialogue(self, clicker, show_married_dialogue) -- Call dialogue function as normal npc.dialogue.start_dialogue(self, clicker, show_married_dialogue) -- Check and update relationship if needed npc.relationships.dialogue_relationship_update(self, clicker) end --------------------------------------------------------------------------------------- -- Action functionality --------------------------------------------------------------------------------------- -- This function adds a function to the action queue. -- Actions should be added in strict order for tasks to work as expected. function npc.add_action(self, action, arguments) local action_entry = {action=action, args=arguments, is_task=false} table.insert(self.actions.queue, action_entry) end -- This function adds task actions in-place, as opposed to -- at the end of the queue. This allows for continued order function npc.add_task(self, task, args) local action_entry = {action=task, args=args, is_task=true} table.insert(self.actions.queue, action_entry) end -- This function removes the first action in the action queue -- and then executes it function npc.execute_action(self) npc.log("DEBUG_ACTION", "Current actions queue: "..dump(self.actions.queue)) -- Check if an action was interrupted if self.actions.current_action_state == npc.action_state.interrupted then npc.log("DEBUG_ACTION", "Re-inserting interrupted action for NPC: '"..dump(self.npc_name).."': "..dump(self.actions.state_before_lock.interrupted_action)) -- Insert into queue the interrupted action table.insert(self.actions.queue, 1, self.actions.state_before_lock.interrupted_action) -- Clear the action self.actions.state_before_lock.interrupted_action = {} -- Clear the position self.actions.state_before_lock.pos = {} end local result = nil if table.getn(self.actions.queue) == 0 then -- Set state to none self.actions.current_action_state = npc.action_state.none -- Keep state the same if there are no more actions in actions queue return self.freeze end local action_obj = self.actions.queue[1] -- Check if action is null if action_obj.action == nil then return end -- Check if action is an schedule check if action_obj.action == "schedule_check" then -- Remove table entry table.remove(self.actions.queue, 1) -- Execute schedule check npc.schedule_check(self) -- Return return false end -- If the entry is a task, then push all this new operations in -- stack fashion if action_obj.is_task == true then npc.log("DEBUG_ACTION", "Executing task for NPC '"..dump(self.npc_name).."': "..dump(action_obj)) -- Backup current queue local backup_queue = self.actions.queue -- Remove this "task" action from queue table.remove(self.actions.queue, 1) -- Clear queue self.actions.queue = {} -- Now, execute the task with its arguments result = npc.actions.execute(self, action_obj.action, action_obj.args) --result = action_obj.action(self, action_obj.args) -- After all new actions has been added by task, add the previously -- queued actions back for i = 1, #backup_queue do table.insert(self.actions.queue, backup_queue[i]) end else npc.log("DEBUG_ACTION", "Executing action for NPC '"..dump(self.npc_name).."': "..dump(action_obj)) -- Store the action that is being executed self.actions.state_before_lock.interrupted_action = action_obj -- Store current position self.actions.state_before_lock.pos = self.object:getpos() -- Execute action as normal result = npc.actions.execute(self, action_obj.action, action_obj.args) -- Remove task table.remove(self.actions.queue, 1) -- Set state self.actions.current_action_state = npc.action_state.executing end return result end function npc.lock_actions(self) -- Avoid re-locking if already locked if self.actions.action_timer_lock == true then return end local pos = self.object:getpos() if self.freeze == false then -- Round current pos to avoid the NPC being stopped on positions -- where later on can't walk to the correct positions -- Choose which position is to be taken as start position if self.actions.state_before_lock.pos ~= {} then pos = vector.round(self.actions.state_before_lock.pos) else pos = vector.round(self.object:getpos()) end pos.y = self.object:getpos().y end -- Stop NPC npc.actions.execute(self, npc.actions.cmd.STAND, {pos=pos}) -- Avoid all timer execution self.actions.action_timer_lock = true -- Reset timer so that it has some time after interaction is done self.actions.action_timer = 0 -- Check if there are is an action executing if self.actions.current_action_state == npc.action_state.executing and self.freeze == false then -- Store the current action state self.actions.state_before_lock.action_state = self.actions.current_action_state -- Set current action state to interrupted self.actions.current_action_state = npc.action_state.interrupted end -- Store the current freeze variable self.actions.state_before_lock.freeze = self.freeze -- Freeze mobs_redo API self.freeze = false npc.log("DEBUG_ACTION", "Locking NPC "..dump(self.npc_id).." actions") end function npc.unlock_actions(self) -- Allow timers to execute self.actions.action_timer_lock = false -- Restore the value of self.freeze self.freeze = self.actions.state_before_lock.freeze if table.getn(self.actions.queue) == 0 then -- Allow mobs_redo API to execute since action queue is empty self.freeze = true end npc.log("DEBUG_ACTION", "Unlocked NPC "..dump(self.npc_id).." actions") end --------------------------------------------------------------------------------------- -- Schedule functionality --------------------------------------------------------------------------------------- -- Schedules allow the NPC to do different things depending on the time of the day. -- The time of the day is in 24 hours and is consistent with the Minetest Game -- /time command. Hours will be written as numbers: 1 for 1:00, 13 for 13:00 or 1:00 PM -- The API is as following: a schedule can be created for a specific date or for a -- day of the week. A date is a string in the format MM:DD npc.schedule_types = { ["generic"] = "generic", ["date_based"] = "date_based" } npc.schedule_properties = { put_item = "put_item", put_multiple_items = "put_multiple_items", take_item = "take_item", trader_status = "trader_status", can_receive_gifts = "can_receive_gifts", flag = "flag", enable_gift_items_hints = "enable_gift_items_hints", set_trade_list = "set_trade_list" } local function get_time_in_hours() return minetest.get_timeofday() * 24 end -- Create a schedule on a NPC. -- Schedule types: -- - Generic: Returns nil if there are already -- seven schedules, one for each day of the -- week or if the schedule attempting to add -- already exists. The date parameter is the -- day of the week it represents as follows: -- - 1: Monday -- - 2: Tuesday -- - 3: Wednesday -- - 4: Thursday -- - 5: Friday -- - 6: Saturday -- - 7: Sunday -- - Date-based: The date parameter should be a -- string of the format "MM:DD". If it already -- exists, function retuns nil function npc.create_schedule(self, schedule_type, date) if schedule_type == npc.schedule_types.generic then -- Check that there are no more than 7 schedules if #self.schedules.generic == 7 then -- Unable to add schedule return nil elseif #self.schedules.generic < 7 then -- Check schedule doesn't exists already if self.schedules.generic[date] == nil then -- Add schedule self.schedules.generic[date] = {} else -- Schedule already present return nil end end elseif schedule_type == npc.schedule_types.date then -- Check schedule doesn't exists already if self.schedules.date_based[date] == nil then -- Add schedule self.schedules.date_based[date] = {} else -- Schedule already present return nil end end end function npc.delete_schedule(self, schedule_type, date) -- Delete schedule by setting entry to nil self.schedules[schedule_type][date] = nil end -- Schedule entries API -- Allows to add, get, update and delete entries from each -- schedule. Attempts to be as safe-fail as possible to avoid crashes. -- Actions is an array of actions and tasks that the NPC -- will perform at the scheduled time on the scheduled date function npc.add_schedule_entry(self, schedule_type, date, time, check, actions) -- Check that schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Add schedule entry if check == nil then self.schedules[schedule_type][date][time] = actions else self.schedules[schedule_type][date][time].check = check end else -- No schedule found, need to be created for date return nil end end function npc.get_schedule_entry(self, schedule_type, date, time) -- Check if schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Return schedule return self.schedules[schedule_type][date][time] else -- Schedule for date not found return nil end end function npc.update_schedule_entry(self, schedule_type, date, time, check, actions) -- Check schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Check that a schedule entry for that time exists if self.schedules[schedule_type][date][time] ~= nil then -- Set the new actions if check == nil then self.schedules[schedule_type][date][time] = actions else self.schedules[schedule_type][date][time].check = check end else -- Schedule not found for specified time return nil end else -- Schedule not found for date return nil end end function npc.delete_schedule_entry(self, schedule_type, date, time) -- Check schedule for date exists if self.schedules[schedule_type][date] ~= nil then -- Remove schedule entry by setting to nil self.schedules[schedule_type][date][time] = nil else -- Schedule not found for date return nil end end function npc.schedule_change_property(self, property, args) if property == npc.schedule_properties.trader_status then -- Get status from args local status = args.status -- Set status to NPC npc.set_trading_status(self, status) elseif property == npc.schedule_properties.put_item then local itemstring = args.itemstring -- Add item npc.add_item_to_inventory_itemstring(self, itemstring) elseif property == npc.schedule_properties.put_multiple_items then local itemlist = args.itemlist for i = 1, #itemlist do local itemlist_entry = itemlist[i] local current_itemstring = itemlist[i].name if itemlist_entry.random == true then current_itemstring = current_itemstring .." "..dump(math.random(itemlist_entry.min, itemlist_entry.max)) else current_itemstring = current_itemstring.." "..tostring(itemlist_entry.count) end -- Add item to inventory npc.add_item_to_inventory_itemstring(self, current_itemstring) end elseif property == npc.schedule_properties.take_item then local itemstring = args.itemstring -- Add item npc.take_item_from_inventory_itemstring(self, itemstring) elseif property == npc.schedule_properties.can_receive_gifts then local value = args.can_receive_gifts -- Set status self.can_receive_gifts = value elseif property == npc.schedule_properties.flag then local action = args.action if action == "set" then -- Adds or overwrites an existing flag and sets it to the given value self.flags[args.flag_name] = args.flag_value elseif action == "reset" then -- Sets value of flag to false or to 0 local flag_type = type(self.flags[args.flag_name]) if flag_type == "number" then self.flags[args.flag_name] = 0 elseif flag_type == "boolean" then self.flags[args.flag_name] = false end end elseif property == npc.schedule_properties.enable_gift_item_hints then self.gift_data.enable_gift_items_hints = args.value elseif property == npc.schedule_properties.set_trade_list then -- Insert items for i = 1, #args.items do -- Insert entry into trade list self.trader_data.trade_list[args.items[i].name] = { max_item_buy_count = args.items[i].buy, max_item_sell_count = args.items[i].sell, amount_to_keep = args.items[i].keep } end end end function npc.add_schedule_check(self) table.insert(self.actions.queue, {action="schedule_check", args={}, is_task=false}) end function npc.enqueue_schedule_action(self, entry) if entry.task ~= nil then -- Add task npc.add_task(self, entry.task, entry.args) elseif entry.action ~= nil then -- Add action npc.add_action(self, entry.action, entry.args) elseif entry.property ~= nil then -- Change NPC property npc.schedule_change_property(self, entry.property, entry.args) end end -- Range: integer, radius in which nodes will be searched. Recommended radius is -- between 1-3 -- Nodes: array of node names -- Actions: map of node names to entries {action=, args={}}. -- Arguments can be empty - the check function will try to determine most -- arguments anyways (like pos and dir). -- Special node "any" will execute those actions on any node except the -- already specified ones. -- None-action: array of entries {action=, args={}}. -- Will be executed when no node is found. function npc.schedule_check(self) npc.log("DEBUG_SCHEDULE", "Prev Actions queue: "..dump(self.actions.queue)) local range = self.schedules.current_check_params.range local walkable_nodes = self.schedules.current_check_params.walkable_nodes local nodes = self.schedules.current_check_params.nodes local actions = self.schedules.current_check_params.actions local none_actions = self.schedules.current_check_params.none_actions -- Get NPC position local start_pos = self.object:getpos() -- Search nodes local found_nodes = npc.places.find_node_nearby(start_pos, nodes, range) -- Check if any node was found npc.log("DEBUG_SCHEDULE", "Found nodes using radius: "..dump(found_nodes)) if found_nodes and #found_nodes > 0 then local node_pos local node -- Check if there is preference to act on nodes already acted upon if self.schedules.current_check_params.prefer_last_acted_upon_node == true then -- Find a node other than the acted upon - try 3 times for i = 1, #found_nodes do node_pos = found_nodes[i] -- Get node info node = minetest.get_node(node_pos) if node.name == self.schedules.current_check_params.last_node_acted_upon then break end end else -- Pick a random node to act upon node_pos = found_nodes[math.random(1, #found_nodes)] -- Get node info node = minetest.get_node(node_pos) end -- Save this node as the last acted upon self.schedules.current_check_params.last_node_acted_upon = node.name -- Set node as a place -- Note: Code below isn't *adding* a node, but overwriting the -- place with "schedule_target_pos" place type npc.log("DEBUG_SCHEDULE", "Found "..dump(node.name).." at pos: "..minetest.pos_to_string(node_pos)) npc.places.add_shared_accessible_place( self, {owner="", node_pos=node_pos}, npc.places.PLACE_TYPE.SCHEDULE.TARGET, true, walkable_nodes) -- Get actions related to node and enqueue them for i = 1, #actions[node.name] do local args = {} local action -- Calculate arguments for the following supported actions: -- - Dig -- - Place -- - Walk step -- - Walk to position -- - Use furnace if actions[node.name][i].action == npc.actions.cmd.DIG then -- Defaults: items will be added to inventory if not specified -- otherwise, and protection will be respected, if not specified -- otherwise args = { pos = node_pos, add_to_inventory = actions[node.name][i].args.add_to_inventory or true, bypass_protection = actions[node.name][i].args.bypass_protection or false } npc.add_action(self, actions[node.name][i].action, args) elseif actions[node.name][i].action == npc.actions.cmd.PLACE then -- Position: providing node_pos is because the currently planned -- behavior for placing nodes is replacing digged nodes. A NPC farmer, -- for instance, might dig a plant node and plant another one on the -- same position. -- Defaults: items will be taken from inventory if existing, -- if not will be force-placed (item comes from thin air) -- Protection will be respected args = { pos = actions[node.name][i].args.pos or node_pos, source = actions[node.name][i].args.source or npc.actions.take_from_inventory_forced, node = actions[node.name][i].args.node, bypass_protection = actions[node.name][i].args.bypass_protection or false } --minetest.log("Enqueue dig action with args: "..dump(args)) npc.add_action(self, actions[node.name][i].action, args) elseif actions[node.name][i].action == npc.actions.cmd.ROTATE then -- Set arguments args = { dir = actions[node.name][i].dir, start_pos = actions[node.name][i].start_pos or {x=start_pos.x, y=node_pos.y, z=start_pos.z}, end_pos = actions[node.name][i].end_pos or node_pos } -- Enqueue action npc.add_action(self, actions[node.name][i].action, args) elseif actions[node.name][i].action == npc.actions.cmd.WALK_STEP then -- Defaults: direction is calculated from start node to node_pos. -- Speed is default wandering speed. Target pos is node_pos -- Calculate dir if dir is random local dir = npc.actions.get_direction(start_pos, node_pos) minetest.log("actions: "..dump(actions[node.name][i])) if actions[node.name][i].args.dir == "random" then dir = math.random(0,7) elseif type(actions[node.name][i].args.dir) == "number" then dir = actions[node.name][i].args.dir end args = { dir = dir, speed = actions[node.name][i].args.speed or npc.actions.one_nps_speed, target_pos = actions[node.name][i].args.target_pos or node_pos } npc.add_action(self, actions[node.name][i].action, args) elseif actions[node.name][i].task == npc.actions.cmd.WALK_TO_POS then -- Optimize walking -- since distances can be really short, -- a simple walk_step() action can do most of the times. For -- this, however, we need to calculate direction -- First of all, check distance local distance = vector.distance(start_pos, node_pos) if distance < 3 then -- Will do walk_step based instead if distance > 1 then args = { dir = npc.actions.get_direction(start_pos, node_pos), speed = npc.actions.one_nps_speed } -- Enqueue walk step npc.add_action(self, npc.actions.cmd.WALK_STEP, args) end -- Add standing action to look at node npc.add_action(self, npc.actions.cmd.STAND, {dir = npc.actions.get_direction(self.object:getpos(), node_pos)} ) else -- Set end pos to be node_pos args = { end_pos = actions[node.name][i].args.end_pos or node_pos, walkable = actions[node.name][i].args.walkable or walkable_nodes or {} } -- Enqueue npc.add_task(self, actions[node.name][i].task, args) end elseif actions[node.name][i].task == npc.actions.cmd.USE_FURNACE then -- Defaults: pos is node_pos. Freeze is true args = { pos = actions[node.name][i].args.pos or node_pos, item = actions[node.name][i].args.item, freeze = actions[node.name][i].args.freeze or true } npc.add_task(self, actions[node.name][i].task, args) else -- Action or task that is not supported for value calculation npc.enqueue_schedule_action(self, actions[node.name][i]) end end -- Increase execution count self.schedules.current_check_params.execution_count = self.schedules.current_check_params.execution_count + 1 -- Enqueue next schedule check if self.schedules.current_check_params.execution_count < self.schedules.current_check_params.execution_times then npc.add_schedule_check(self) end npc.log("DEBUG_SCHEDULE", "Actions queue: "..dump(self.actions.queue)) else -- No nodes found, enqueue none_actions for i = 1, #none_actions do -- Add start_pos to none_actions none_actions[i].args["start_pos"] = start_pos -- Enqueue actions npc.add_action(self, none_actions[i].action, none_actions[i].args) end -- Increase execution count self.schedules.current_check_params.execution_count = self.schedules.current_check_params.execution_count + 1 -- Enqueue next schedule check if self.schedules.current_check_params.execution_count < self.schedules.current_check_params.execution_times then npc.add_schedule_check(self) end -- No nodes found npc.log("DEBUG_SCHEDULE", "Actions queue: "..dump(self.actions.queue)) end end --------------------------------------------------------------------------------------- -- NPC Definition --------------------------------------------------------------------------------------- mobs:register_mob("advanced_npc:npc", { type = "npc", passive = false, damage = 3, attack_type = "dogfight", attacks_monsters = true, -- Added group attack group_attack = true, -- Pathfinder = 2 to make NPCs more smart when attacking pathfinding = 2, hp_min = 10, hp_max = 20, armor = 100, collisionbox = {-0.20,0,-0.20, 0.20,1.8,0.20}, --collisionbox = {-0.20,-1.0,-0.20, 0.20,0.8,0.20}, --collisionbox = {-0.35,-1.0,-0.35, 0.35,0.8,0.35}, visual = "mesh", mesh = "character.b3d", drawtype = "front", textures = { {"npc_male1.png"}, {"npc_male2.png"}, {"npc_male3.png"}, {"npc_male4.png"}, {"npc_male5.png"}, {"npc_male6.png"}, {"npc_male7.png"}, {"npc_male8.png"}, {"npc_male9.png"}, {"npc_male10.png"}, {"npc_male11.png"}, {"npc_male12.png"}, {"npc_male13.png"}, {"npc_male14.png"}, {"npc_female1.png"}, -- female by nuttmeg20 {"npc_female2.png"}, {"npc_female3.png"}, {"npc_female4.png"}, {"npc_female5.png"}, {"npc_female6.png"}, {"npc_female7.png"}, {"npc_female8.png"}, {"npc_female9.png"}, {"npc_female10.png"}, {"npc_female11.png"}, }, child_texture = { {"npc_child_male1.png"}, {"npc_child_female1.png"}, }, makes_footstep_sound = true, sounds = {}, -- Added walk chance walk_chance = 30, -- Added stepheight stepheight = 0.6, walk_velocity = 1, run_velocity = 3, jump = false, drops = { {name = "default:wood", chance = 1, min = 1, max = 3}, {name = "default:apple", chance = 2, min = 1, max = 2}, {name = "default:axe_stone", chance = 5, min = 1, max = 1}, }, water_damage = 0, lava_damage = 2, light_damage = 0, --follow = {"farming:bread", "mobs:meat", "default:diamond"}, view_range = 15, owner = "", order = "follow", --order = "stand", fear_height = 3, animation = { speed_normal = 30, speed_run = 30, stand_start = 0, stand_end = 79, walk_start = 168, walk_end = 187, run_start = 168, run_end = 187, punch_start = 200, punch_end = 219, }, on_rightclick = function(self, clicker) -- Rotate NPC toward its clicker npc.dialogue.rotate_npc_to_player(self) -- Get information from clicker local item = clicker:get_wielded_item() local name = clicker:get_player_name() npc.log("DEBUG", "Right-clicked NPC: "..dump(self)) -- Receive gift or start chat. If player has no item in hand -- then it is going to start chat directly --minetest.log("self.can_have_relationship: "..dump(self.can_have_relationship)..", self.can_receive_gifts: "..dump(self.can_receive_gifts)..", table: "..dump(item:to_table())) if self.can_have_relationship and self.can_receive_gifts and item:to_table() ~= nil then -- Get item name local item = minetest.registered_items[item:get_name()] local item_name = item.description -- Show dialogue to confirm that player is giving item as gift npc.dialogue.show_yes_no_dialogue( self, "Do you want to give "..item_name.." to "..self.npc_name.."?", npc.dialogue.POSITIVE_GIFT_ANSWER_PREFIX..item_name, function() npc.relationships.receive_gift(self, clicker) end, npc.dialogue.NEGATIVE_ANSWER_LABEL, function() npc.start_dialogue(self, clicker, true) end, name ) else npc.start_dialogue(self, clicker, true) end end, do_custom = function(self, dtime) if self.initialized == nil then -- Initialize NPC if spawned using the spawn egg built in from -- mobs_redo. This functionality will be removed in the future in -- favor of a better manual spawning method with customization npc.log("WARNING", "Initializing NPC from entity step. This message should only be appearing if an NPC is being spawned from inventory with egg!") npc.initialize(self, self.object:getpos(), true) self.tamed = false self.owner = nil else -- NPC is initialized, check other variables -- Check child texture issues if self.is_child then -- Check texture npc.texture_check.timer = npc.texture_check.timer + dtime if npc.texture_check.timer > npc.texture_check.interval then -- Reset timer npc.texture_check.timer = 0 -- Set hornytimer to zero every 60 seconds so that children -- don't grow automatically self.hornytimer = 0 -- Set correct textures self.texture = {self.selected_texture} self.base_texture = {self.selected_texture} self.object:set_properties(self) npc.log("WARNING", "Corrected textures on NPC child "..dump(self.npc_name)) -- Set interval to large interval so this code isn't called frequently npc.texture_check.interval = 60 end end end -- Timer function for casual traders to reset their trade offers self.trader_data.change_offers_timer = self.trader_data.change_offers_timer + dtime -- Check if time has come to change offers if self.trader_data.trader_status == npc.trade.CASUAL and self.trader_data.change_offers_timer >= self.trader_data.change_offers_timer_interval then -- Reset timer self.trader_data.change_offers_timer = 0 -- Re-select casual trade offers npc.trade.generate_trade_offers_by_status(self) end -- Timer function for gifts for i = 1, #self.relationships do local relationship = self.relationships[i] -- Gift timer check if relationship.gift_timer_value < relationship.gift_interval then relationship.gift_timer_value = relationship.gift_timer_value + dtime elseif relationship.talk_timer_value < relationship.gift_interval then -- Relationship talk timer - only allows players to increase relationship -- by talking on the same intervals as gifts relationship.talk_timer_value = relationship.talk_timer_value + dtime else -- Relationship decrease timer if relationship.relationship_decrease_timer_value < relationship.relationship_decrease_interval then relationship.relationship_decrease_timer_value = relationship.relationship_decrease_timer_value + dtime else -- Check if married to decrease half if relationship.phase == "phase6" then -- Avoid going below the marriage phase limit if (relationship.points - 0.5) >= npc.relationships.RELATIONSHIP_PHASE["phase5"].limit then relationship.points = relationship.points - 0.5 end else relationship.points = relationship.points - 1 end relationship.relationship_decrease_timer_value = 0 --minetest.log(dump(self)) end end end -- Action queue timer -- Check if actions and timers aren't locked if self.actions.action_timer_lock == false then -- Increment action timer self.actions.action_timer = self.actions.action_timer + dtime if self.actions.action_timer >= self.actions.action_interval then -- Reset action timer self.actions.action_timer = 0 -- Check if NPC is walking if self.actions.walking.is_walking == true then -- Move NPC to expected position to ensure not getting lost local pos = self.actions.walking.target_pos self.object:moveto({x=pos.x, y=pos.y, z=pos.z}) end -- Execute action self.freeze = npc.execute_action(self) -- Check if there are still remaining actions in the queue if self.freeze == nil and table.getn(self.actions.queue) > 0 then self.freeze = false end end end -- Schedule timer -- Check if schedules are enabled if self.schedules.enabled == true then -- Get time of day local time = get_time_in_hours() -- Check if time is an hour if ((time % 1) < dtime) and self.schedules.lock == false then -- Activate lock to avoid more than one entry to this code self.schedules.lock = true -- Get integer part of time time = (time) - (time % 1) -- Check if there is a schedule entry for this time -- Note: Currently only one schedule is supported, for day 0 npc.log("DEBUG_SCHEDULE", "Time: "..dump(time)) local schedule = self.schedules.generic[0] if schedule ~= nil then -- Check if schedule for this time exists if schedule[time] ~= nil then npc.log("DEBUG_SCHEDULE", "Adding actions to action queue") -- Add to action queue all actions on schedule for i = 1, #schedule[time] do -- Check if schedule has a check function if schedule[time][i].check then -- Add parameters for check function and run for first time npc.log("DEBUG", "NPC "..dump(self.npc_id).." is starting check on "..minetest.pos_to_string(self.object:getpos())) local check_params = schedule[time][i] -- Calculates how many times check will be executed local execution_times = check_params.count if check_params.random_execution_times then execution_times = math.random(check_params.min_count, check_params.max_count) end -- Set current parameters self.schedules.current_check_params = { range = check_params.range, walkable_nodes = check_params.walkable_nodes, nodes = check_params.nodes, actions = check_params.actions, none_actions = check_params.none_actions, prefer_last_acted_upon_node = check_params.prefer_last_acted_upon_node or false, last_node_acted_upon = "", execution_count = 0, execution_times = execution_times } -- Enqueue the schedule check npc.add_schedule_check(self) else npc.log("DEBUG_SCHEDULE", "Executing schedule entry for NPC "..dump(self.npc_id)..": " ..dump(schedule[time][i])) -- Run usual schedule entry -- Check chance local execution_chance = math.random(1, 100) if not schedule[time][i].chance or (schedule[time][i].chance and execution_chance <= schedule[time][i].chance) then -- Check if entry has dependency on other entry local dependencies_met = nil if schedule[time][i].depends then dependencies_met = npc.utils.array_is_subset_of_array( self.schedules.temp_executed_queue, schedule[time][i].depends) end -- Check for dependencies being met if dependencies_met == nil or dependencies_met == true then -- Add tasks if schedule[time][i].task ~= nil then -- Add task npc.add_task(self, schedule[time][i].task, schedule[time][i].args) elseif schedule[time][i].action ~= nil then -- Add action npc.add_action(self, schedule[time][i].action, schedule[time][i].args) elseif schedule[time][i].property ~= nil then -- Change NPC property npc.schedule_change_property(self, schedule[time][i].property, schedule[time][i].args) end -- Backward compatibility check if self.schedules.temp_executed_queue then -- Add into execution queue to meet dependency table.insert(self.schedules.temp_executed_queue, i) end end else -- TODO: Change to debug npc.log("DEBUG", "Skipping schedule entry for time "..dump(time)..": "..dump(schedule[time][i])) end end end -- Clear execution queue self.schedules.temp_executed_queue = {} npc.log("DEBUG", "New action queue: "..dump(self.actions.queue)) end end else -- Check if lock can be released if (time % 1) > dtime + 0.1 then -- Release lock self.schedules.lock = false end end end return self.freeze end }) -- Spawn -- mobs:spawn({ -- name = "advanced_npc:npc", -- nodes = {"advanced_npc:plotmarker_auto_spawner", "mg_villages:plotmarker"}, -- min_light = 3, -- active_object_count = 1, -- interval = 5, -- chance = 1, -- --max_height = 0, -- on_spawn = npc.initialize -- }) ------------------------------------------------------------------------- -- Item definitions ------------------------------------------------------------------------- mobs:register_egg("advanced_npc:npc", S("NPC"), "default_brick.png", 1) -- compatibility mobs:alias_mob("mobs:npc", "advanced_npc:npc") -- Marriage ring minetest.register_craftitem("advanced_npc:marriage_ring", { description = S("Marriage Ring"), inventory_image = "marriage_ring.png", }) -- Marriage ring craft recipe minetest.register_craft({ output = "advanced_npc:marriage_ring", recipe = { {"", "", ""}, {"", "default:diamond", ""}, {"", "default:gold_ingot", ""} }, })