-- assorted_mecha/ai.lua -- AI system: GUARD mode (tactical combat) and FREERUN mode (patrol) local AI_TICK = 0.25 local OPTIMAL_DIST = 12 local DIST_TOLERANCE = 4 local STRAFE_SPEED = 2.5 local APPROACH_SPEED = 3.0 local STATE_DURATION_MIN = 1.0 local STATE_DURATION_MAX = 3.2 local STUCK_CHECK_INTERVAL = 2.0 local STUCK_THRESHOLD = 0.8 local WAYPOINT_REACH_DIST = 2.5 local SHOOT_COOLDOWN_PLASMA = 2.0 local SHOOT_COOLDOWN_BULLET = 0.12 local FREERUN_RADIUS = 30 local FREERUN_Y_RANGE = 4 -- ── Helpers ─────────────────────────────────────────────────────────────────── local function is_valid_obj(obj) return obj ~= nil and obj:get_pos() ~= nil end local function is_hostile_mob(obj) if obj:is_player() then return false end local ent = obj:get_luaentity() if not ent then return false end local n = ent.name or "" if string.find(n, "assorted_mecha:") then return false end if n == "__builtin:item" or n == "__builtin:falling_node" then return false end local hp = obj:get_hp() return hp and hp > 0 end -- LoS check: cast from shot_origin to target, ignoring the mech itself local function has_los(shot_origin, target_pos, mech_obj) local ray = minetest.raycast(shot_origin, target_pos, true, false) for hit in ray do if hit.type == "node" then return false end if hit.type == "object" and hit.ref ~= mech_obj then -- hit a different object before reaching target if hit.ref:get_pos() then local hit_dist = vector.distance(shot_origin, hit.ref:get_pos()) local target_dist = vector.distance(shot_origin, target_pos) if hit_dist < target_dist - 1 then return false end end end end return true end -- Weapon type: mech1 uses plasma (head_spider), mech2/3 use bullets (head_2) local function get_weapon(self_ent) if self_ent._ai_weapon then return self_ent._ai_weapon end local head_name = self_ent._mech_head_name or "" if string.find(head_name, "head_spider") then self_ent._ai_weapon = "plasma" else self_ent._ai_weapon = "bullet" end return self_ent._ai_weapon end local function fire_plasma(self_ent, dir) local pos = self_ent.object:get_pos() local yaw = self_ent.object:get_yaw() local bpos = { x = pos.x - math.sin(yaw) * 2.5, y = pos.y + 4.0, z = pos.z + math.cos(yaw) * 2.5, } local bolt = minetest.add_entity(bpos, "assorted_mecha:plasma_bolt") if bolt then local e = bolt:get_luaentity() if e then e.shooter_name = self_ent._ai_owner end bolt:set_velocity(vector.multiply(dir, 40)) bolt:set_yaw(yaw) minetest.sound_play("plasma_rifle", {pos=bpos, gain=2.0, max_hear_distance=48}) end end local function fire_bullet(self_ent, dir) local pos = self_ent.object:get_pos() local yaw = self_ent.object:get_yaw() local bpos = { x = pos.x - math.sin(yaw) * 2.0, y = pos.y + 4.0, z = pos.z + math.cos(yaw) * 2.0, } local spread = { x = dir.x + (math.random()-0.5)*0.08, y = dir.y + (math.random()-0.5)*0.04, z = dir.z + (math.random()-0.5)*0.08, } local b = minetest.add_entity(bpos, "assorted_mecha:mecha_bullet") if b then local e = b:get_luaentity() if e then e.shooter_name = self_ent._ai_owner end b:set_velocity(vector.multiply(vector.normalize(spread), 30)) b:set_yaw(yaw) minetest.sound_play("mech_fire2", {pos=bpos, gain=0.7, max_hear_distance=28}) end end local function try_fire(self_ent, target_pos) local now = minetest.get_gametime() local weapon = get_weapon(self_ent) local cd = weapon == "plasma" and SHOOT_COOLDOWN_PLASMA or SHOOT_COOLDOWN_BULLET if (now - (self_ent._ai_last_shot or 0)) < cd then return false end local my_pos = self_ent.object:get_pos() local aim = {x=target_pos.x, y=target_pos.y+1.2, z=target_pos.z} local origin = {x=my_pos.x, y=my_pos.y+4.0, z=my_pos.z} if not has_los(origin, aim, self_ent.object) then return false end local dir = vector.normalize(vector.subtract(aim, origin)) self_ent._ai_last_shot = now if weapon == "plasma" then fire_plasma(self_ent, dir) else fire_bullet(self_ent, dir) end return true end -- Animation helpers local function anim_walk(self_ent) local anim = self_ent._mech_walk_anim or {x=25, y=60} self_ent.object:set_animation(anim, 30, 0) end local function anim_stand(self_ent) local anim = self_ent._mech_stand_anim or {x=1, y=1} self_ent.object:set_animation(anim, 30, 0) end -- Point head entity toward world yaw (fixes inconsistent upper body rotation) local function aim_head(self_ent, yaw) if self_ent.head_entity and is_valid_obj(self_ent.head_entity) then self_ent.head_entity:set_yaw(yaw) end end -- Face toward a position, returns yaw local function face_pos(self_ent, target_pos) local my_pos = self_ent.object:get_pos() local dx = target_pos.x - my_pos.x local dz = target_pos.z - my_pos.z local yaw = math.atan2(-dx, dz) self_ent.object:set_yaw(yaw) aim_head(self_ent, yaw) return yaw end -- ── Target scanning ─────────────────────────────────────────────────────────── local function scan_target(self_ent) local my_pos = self_ent.object:get_pos() local radius = self_ent._ai_scan_radius or 20 local best, best_dist = nil, math.huge for _, cand in ipairs(minetest.get_objects_inside_radius(my_pos, radius)) do if cand ~= self_ent.object and is_valid_obj(cand) then local skip = false if cand:is_player() then local pname = cand:get_player_name() if self_ent._ai_protect_owner and pname == self_ent._ai_owner then skip = true elseif not self_ent._ai_attack_players then skip = true else -- if enemy list is empty: attack ALL players (except owner) -- if enemy list has entries: only attack listed players local enemies = self_ent._ai_enemy_players or {} if #enemies > 0 then local found = false for _, en in ipairs(enemies) do if en == pname then found = true; break end end if not found then skip = true end end end else if not (self_ent._ai_attack_mobs and is_hostile_mob(cand)) then skip = true end end if not skip then local d = vector.distance(my_pos, cand:get_pos()) if d < best_dist then best = cand best_dist = d end end end end return best, best_dist end -- ── Movement ────────────────────────────────────────────────────────────────── local function move_toward(self_ent, target_pos, speed) local my_pos = self_ent.object:get_pos() local dir = vector.normalize({ x = target_pos.x - my_pos.x, y = 0, z = target_pos.z - my_pos.z, }) local vel = self_ent.object:get_velocity() or {x=0,y=0,z=0} self_ent.object:set_velocity({x=dir.x*speed, y=vel.y, z=dir.z*speed}) local yaw = math.atan2(-dir.x, dir.z) self_ent.object:set_yaw(yaw) aim_head(self_ent, yaw) anim_walk(self_ent) end local function move_strafe(self_ent, target_pos, cw, speed) local my_pos = self_ent.object:get_pos() local to_t = vector.normalize({x=target_pos.x-my_pos.x, y=0, z=target_pos.z-my_pos.z}) local s = cw and 1 or -1 local perp = {x=-to_t.z*s, y=0, z=to_t.x*s} local vel = self_ent.object:get_velocity() or {x=0,y=0,z=0} self_ent.object:set_velocity({x=perp.x*speed, y=vel.y, z=perp.z*speed}) -- face target while strafing local yaw = math.atan2(-to_t.x, to_t.z) self_ent.object:set_yaw(yaw) aim_head(self_ent, yaw) anim_walk(self_ent) end local function move_away(self_ent, target_pos, speed) local my_pos = self_ent.object:get_pos() local away = vector.normalize({x=my_pos.x-target_pos.x, y=0, z=my_pos.z-target_pos.z}) local vel = self_ent.object:get_velocity() or {x=0,y=0,z=0} self_ent.object:set_velocity({x=away.x*speed, y=vel.y, z=away.z*speed}) -- still face the target local yaw = math.atan2(-(target_pos.x-my_pos.x), target_pos.z-my_pos.z) self_ent.object:set_yaw(yaw) aim_head(self_ent, yaw) anim_walk(self_ent) end local function stop(self_ent) local vel = self_ent.object:get_velocity() or {x=0,y=0,z=0} self_ent.object:set_velocity({x=0, y=vel.y, z=0}) anim_stand(self_ent) end -- ── GUARD mode ──────────────────────────────────────────────────────────────── local function pick_guard_state(dist) local r = math.random() if dist < OPTIMAL_DIST - DIST_TOLERANCE then if r < 0.50 then return "back_off" elseif r < 0.75 then return "strafe_cw" else return "strafe_ccw" end elseif dist > OPTIMAL_DIST + DIST_TOLERANCE then if r < 0.40 then return "close_in" elseif r < 0.70 then return "strafe_cw" else return "strafe_ccw" end else if r < 0.45 then return "strafe_cw" elseif r < 0.88 then return "strafe_ccw" else return "idle" end end end local function guard_tick(self_ent, dtime, target) if not target or not is_valid_obj(target) then self_ent._ai_target = nil stop(self_ent) return end local my_pos = self_ent.object:get_pos() local target_pos = target:get_pos() local dist = vector.distance(my_pos, target_pos) if dist > (self_ent._ai_scan_radius or 20) * 1.3 then self_ent._ai_target = nil stop(self_ent) return end -- state timer self_ent._ai_state_timer = (self_ent._ai_state_timer or 0) - dtime if self_ent._ai_state_timer <= 0 then self_ent._ai_guard_state = pick_guard_state(dist) self_ent._ai_state_timer = STATE_DURATION_MIN + math.random() * (STATE_DURATION_MAX - STATE_DURATION_MIN) -- random direction flip for unpredictability if math.random() < 0.3 then if self_ent._ai_guard_state == "strafe_cw" then self_ent._ai_guard_state = "strafe_ccw" elseif self_ent._ai_guard_state == "strafe_ccw" then self_ent._ai_guard_state = "strafe_cw" end end end local state = self_ent._ai_guard_state or "idle" if state == "strafe_cw" then move_strafe(self_ent, target_pos, true, STRAFE_SPEED) elseif state == "strafe_ccw" then move_strafe(self_ent, target_pos, false, STRAFE_SPEED) elseif state == "back_off" then move_away(self_ent, target_pos, APPROACH_SPEED) elseif state == "close_in" then move_toward(self_ent, target_pos, APPROACH_SPEED) else face_pos(self_ent, target_pos) stop(self_ent) end try_fire(self_ent, target_pos) end -- ── FREERUN mode ────────────────────────────────────────────────────────────── local function pick_waypoint(self_ent) local spawn = self_ent._ai_spawn_pos or self_ent.object:get_pos() self_ent._ai_spawn_pos = self_ent._ai_spawn_pos or spawn local angle = math.random() * math.pi * 2 local dist = 8 + math.random() * (FREERUN_RADIUS - 8) local wp = { x = spawn.x + math.cos(angle) * dist, y = spawn.y, z = spawn.z + math.sin(angle) * dist, } local node = minetest.get_node_or_nil(wp) if not node then minetest.emerge_area(vector.subtract(wp,5), vector.add(wp,5), function() end) return nil end -- scan for solid ground near spawn Y for dy = FREERUN_Y_RANGE, -FREERUN_Y_RANGE*2, -1 do local check = {x=wp.x, y=spawn.y+dy, z=wp.z} local n = minetest.get_node_or_nil(check) local na = minetest.get_node_or_nil({x=check.x, y=check.y+1, z=check.z}) if n and na then local def = minetest.registered_nodes[n.name] local defa = minetest.registered_nodes[na.name] if def and def.walkable ~= false and defa and defa.walkable == false then if math.abs(check.y+1 - spawn.y) <= FREERUN_Y_RANGE then return {x=wp.x, y=check.y+1, z=wp.z} end end end end return nil end local function freerun_tick(self_ent, dtime) -- target in range? switch to guard behavior temporarily local target = scan_target(self_ent) if target then self_ent._ai_target = target guard_tick(self_ent, dtime, target) return end self_ent._ai_target = nil local my_pos = self_ent.object:get_pos() -- stuck detection self_ent._ai_stuck_timer = (self_ent._ai_stuck_timer or 0) + dtime if self_ent._ai_stuck_timer >= STUCK_CHECK_INTERVAL then self_ent._ai_stuck_timer = 0 local last = self_ent._ai_last_pos if last and self_ent._ai_waypoint then if vector.distance(my_pos, last) < STUCK_THRESHOLD then self_ent._ai_waypoint = nil -- abandon, pick new end end self_ent._ai_last_pos = {x=my_pos.x, y=my_pos.y, z=my_pos.z} end if self_ent._ai_waypoint then local wp = self_ent._ai_waypoint local flat_dist = math.sqrt( (my_pos.x-wp.x)^2 + (my_pos.z-wp.z)^2 ) if flat_dist <= WAYPOINT_REACH_DIST then self_ent._ai_waypoint = nil stop(self_ent) else local speed = self_ent._mech_speed or 3 move_toward(self_ent, wp, speed) end else local wp = pick_waypoint(self_ent) if wp then self_ent._ai_waypoint = wp else stop(self_ent) end end end -- ── Public API ──────────────────────────────────────────────────────────────── function assorted_mecha_ai_init(self_ent) if self_ent._ai_initialized then return end self_ent._ai_enabled = false self_ent._ai_mode = "guard" self_ent._ai_attack_mobs = true self_ent._ai_attack_players = false self_ent._ai_protect_owner = true self_ent._ai_owner = "" self_ent._ai_scan_radius = 20 self_ent._ai_enemy_players = {} self_ent._ai_last_shot = 0 self_ent._ai_running = false self_ent._ai_guard_state = "idle" self_ent._ai_state_timer = 0 self_ent._ai_waypoint = nil self_ent._ai_spawn_pos = self_ent.object and self_ent.object:get_pos() or nil self_ent._ai_initialized = true end function assorted_mecha_ai_serialize(self_ent) return minetest.write_json({ enabled = self_ent._ai_enabled, mode = self_ent._ai_mode or "guard", attack_mobs = self_ent._ai_attack_mobs, attack_players = self_ent._ai_attack_players, protect_owner = self_ent._ai_protect_owner, owner = self_ent._ai_owner or "", scan_radius = self_ent._ai_scan_radius or 20, enemy_players = self_ent._ai_enemy_players or {}, spawn_pos = self_ent._ai_spawn_pos, }) end function assorted_mecha_ai_deserialize(self_ent, json_str) assorted_mecha_ai_init(self_ent) if not json_str or json_str == "" then return end local ok, data = pcall(minetest.parse_json, json_str) if not ok or not data then return end self_ent._ai_enabled = data.enabled == true self_ent._ai_mode = data.mode or "guard" self_ent._ai_attack_mobs = data.attack_mobs ~= false self_ent._ai_attack_players = data.attack_players == true self_ent._ai_protect_owner = data.protect_owner ~= false self_ent._ai_owner = data.owner or "" self_ent._ai_scan_radius = tonumber(data.scan_radius) or 20 self_ent._ai_enemy_players = data.enemy_players or {} self_ent._ai_spawn_pos = data.spawn_pos end function assorted_mecha_ai_tick(self_ent, dtime) if not self_ent or not self_ent.object then return end if not self_ent.object:get_pos() then return end if not self_ent._ai_enabled then self_ent._ai_running = false return end if self_ent.driver then minetest.after(AI_TICK, function() if self_ent and self_ent.object and self_ent.object:get_pos() then assorted_mecha_ai_tick(self_ent, AI_TICK) end end) return end if self_ent._ai_mode == "freerun" then freerun_tick(self_ent, dtime) else local target = scan_target(self_ent) self_ent._ai_target = target guard_tick(self_ent, dtime, target) end minetest.after(AI_TICK, function() if self_ent and self_ent.object and self_ent.object:get_pos() then assorted_mecha_ai_tick(self_ent, AI_TICK) end end) end function assorted_mecha_ai_start(self_ent) if self_ent._ai_running then return end self_ent._ai_running = true self_ent._ai_spawn_pos = self_ent._ai_spawn_pos or self_ent.object:get_pos() minetest.after(AI_TICK, function() if self_ent and self_ent.object and self_ent.object:get_pos() then assorted_mecha_ai_tick(self_ent, AI_TICK) end end) end