Dateien nach "/" hochladen

This commit is contained in:
2026-03-06 10:50:54 +00:00
commit 1862429c38
5 changed files with 2171 additions and 0 deletions

498
ai.lua Normal file
View 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