Dateien nach "/" hochladen
This commit is contained in:
498
ai.lua
Normal file
498
ai.lua
Normal file
@@ -0,0 +1,498 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user