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
|
||||||
25
license.txt
Normal file
25
license.txt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2024 Soundwavez
|
||||||
|
|
||||||
|
media:
|
||||||
|
[MIT] Soundwavez, minetest game and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
193
mecha_control_panel.lua
Normal file
193
mecha_control_panel.lua
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
-- assorted_mecha/mecha_control_panel.lua
|
||||||
|
-- Per-mech configuration UI, opened via the remote control item
|
||||||
|
-- State is stored directly in self_ent._ai_* fields (no persistent meta on entities)
|
||||||
|
|
||||||
|
local FORMNAME = "assorted_mecha:control_panel"
|
||||||
|
|
||||||
|
if not assorted_mecha_panel_refs then
|
||||||
|
assorted_mecha_panel_refs = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_formspec(ent, clicker_name)
|
||||||
|
local ai_on = ent._ai_enabled == true
|
||||||
|
local atk_mob = ent._ai_attack_mobs ~= false
|
||||||
|
local atk_ply = ent._ai_attack_players == true
|
||||||
|
local prot = ent._ai_protect_owner ~= false
|
||||||
|
local radius = tonumber(ent._ai_scan_radius) or 20
|
||||||
|
local owner = ent._ai_owner or ""
|
||||||
|
local mode = ent._ai_mode or "guard"
|
||||||
|
local enemies = ent._ai_enemy_players or {}
|
||||||
|
local enemy_set = {}
|
||||||
|
for _, n in ipairs(enemies) do enemy_set[n] = true end
|
||||||
|
|
||||||
|
local player_list = {}
|
||||||
|
for _, p in ipairs(minetest.get_connected_players()) do
|
||||||
|
local pn = p:get_player_name()
|
||||||
|
if pn ~= clicker_name then table.insert(player_list, pn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local fs = "formspec_version[4]"
|
||||||
|
.. "size[9,10.5]"
|
||||||
|
.. "bgcolor[#1a1a2e;true]"
|
||||||
|
.. "box[0,0;9,0.8;#0f3460]"
|
||||||
|
.. "label[0.3,0.5;MECHA CONTROL PANEL]"
|
||||||
|
|
||||||
|
-- AI on/off status
|
||||||
|
fs = fs .. "box[0.2,1.0;8.6,1.3;#16213e]"
|
||||||
|
if ai_on then
|
||||||
|
fs = fs
|
||||||
|
.. "box[3.4,1.05;5.2,1.2;#0d4f1a]"
|
||||||
|
.. "label[3.7,1.7;ACTIVE - boarding disabled]"
|
||||||
|
.. "button[0.5,1.1;2.7,0.9;toggle_ai;Deactivate]"
|
||||||
|
else
|
||||||
|
fs = fs
|
||||||
|
.. "box[3.4,1.05;5.2,1.2;#4f1a0d]"
|
||||||
|
.. "label[3.7,1.7;MANUAL - mech is boardable]"
|
||||||
|
.. "button[0.5,1.1;2.7,0.9;toggle_ai;Activate AI]"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Owner
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.2,2.45;8.6,0.7;#16213e]"
|
||||||
|
.. "label[0.5,2.85;Owner: " .. (owner ~= "" and owner or "(none)") .. "]"
|
||||||
|
.. "button[6.5,2.5;2.1,0.55;claim_owner;Claim]"
|
||||||
|
|
||||||
|
-- Owner protection
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.2,3.3;8.6,0.7;#16213e]"
|
||||||
|
.. "checkbox[0.5,3.7;cb_protect;Protect owner (no friendly fire);"
|
||||||
|
.. (prot and "true" or "false") .. "]"
|
||||||
|
|
||||||
|
-- AI mode
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.2,4.1;8.6,0.75;#16213e]"
|
||||||
|
.. "label[0.5,4.4;AI Mode:]"
|
||||||
|
.. "button[2.5,4.15;2.9,0.55;mode_guard;" .. (mode == "guard" and "[GUARD]" or "Guard") .. "]"
|
||||||
|
.. "button[5.5,4.15;2.9,0.55;mode_freerun;" .. (mode == "freerun" and "[FREERUN]" or "Freerun") .. "]"
|
||||||
|
|
||||||
|
-- Attack targets
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.2,5.0;8.6,1.65;#16213e]"
|
||||||
|
.. "label[0.5,5.35;Attack targets:]"
|
||||||
|
.. "checkbox[0.5,5.75;cb_mobs;Hostile mobs and entities;"
|
||||||
|
.. (atk_mob and "true" or "false") .. "]"
|
||||||
|
.. "checkbox[0.5,6.25;cb_players;Other players (PvP);"
|
||||||
|
.. (atk_ply and "true" or "false") .. "]"
|
||||||
|
|
||||||
|
-- Scan radius
|
||||||
|
local bar_val = math.floor((radius / 50) * 1000)
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.2,6.85;8.6,1.05;#16213e]"
|
||||||
|
.. "label[0.5,7.2;Scan radius: " .. radius .. " nodes]"
|
||||||
|
.. "scrollbar[0.5,7.4;8.0,0.45;horizontal;radius_bar;" .. bar_val .. "]"
|
||||||
|
|
||||||
|
-- Enemy player list (only shown when PvP is enabled)
|
||||||
|
if atk_ply then
|
||||||
|
fs = fs .. "box[0.2,8.05;8.6,1.6;#16213e]"
|
||||||
|
.. "label[0.5,8.4;Mark as enemy:]"
|
||||||
|
if #player_list > 0 then
|
||||||
|
local col, row = 0, 0
|
||||||
|
for _, pname in ipairs(player_list) do
|
||||||
|
local x = 0.5 + col * 4.3
|
||||||
|
local y = 8.75 + row * 0.55
|
||||||
|
fs = fs .. "checkbox[" .. x .. "," .. y .. ";enemy_" .. pname
|
||||||
|
.. ";" .. pname .. ";" .. (enemy_set[pname] and "true" or "false") .. "]"
|
||||||
|
col = col + 1
|
||||||
|
if col >= 2 then col = 0; row = row + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
fs = fs .. "label[0.5,8.8;(No other players online)]"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save / Cancel
|
||||||
|
fs = fs
|
||||||
|
.. "box[0,9.9;9,0.6;#0f3460]"
|
||||||
|
.. "button_exit[0.5,9.95;3.5,0.85;save;Save]"
|
||||||
|
.. "button_exit[5.0,9.95;3.5,0.85;cancel;Cancel]"
|
||||||
|
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
function assorted_mecha_open_panel(ent, clicker)
|
||||||
|
if not ent or not clicker then return end
|
||||||
|
local cname = clicker:get_player_name()
|
||||||
|
assorted_mecha_panel_refs[cname] = ent
|
||||||
|
minetest.show_formspec(cname, FORMNAME, build_formspec(ent, cname))
|
||||||
|
end
|
||||||
|
|
||||||
|
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||||
|
if formname ~= FORMNAME then return false end
|
||||||
|
local pname = player:get_player_name()
|
||||||
|
local ent = assorted_mecha_panel_refs[pname]
|
||||||
|
if not ent or not ent.object or not ent.object:get_pos() then
|
||||||
|
assorted_mecha_panel_refs[pname] = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.toggle_ai then
|
||||||
|
ent._ai_enabled = not ent._ai_enabled
|
||||||
|
if ent._ai_enabled then
|
||||||
|
assorted_mecha_ai_start(ent)
|
||||||
|
else
|
||||||
|
ent._ai_running = false
|
||||||
|
end
|
||||||
|
minetest.show_formspec(pname, FORMNAME, build_formspec(ent, pname))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.claim_owner then
|
||||||
|
ent._ai_owner = pname
|
||||||
|
minetest.show_formspec(pname, FORMNAME, build_formspec(ent, pname))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.cancel then
|
||||||
|
assorted_mecha_panel_refs[pname] = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.mode_guard then
|
||||||
|
ent._ai_mode = "guard"
|
||||||
|
ent._ai_waypoint = nil
|
||||||
|
minetest.show_formspec(pname, FORMNAME, build_formspec(ent, pname))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.mode_freerun then
|
||||||
|
ent._ai_mode = "freerun"
|
||||||
|
minetest.show_formspec(pname, FORMNAME, build_formspec(ent, pname))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.save then
|
||||||
|
if fields.cb_mobs ~= nil then ent._ai_attack_mobs = fields.cb_mobs == "true" end
|
||||||
|
if fields.cb_players ~= nil then ent._ai_attack_players = fields.cb_players == "true" end
|
||||||
|
if fields.cb_protect ~= nil then ent._ai_protect_owner = fields.cb_protect == "true" end
|
||||||
|
|
||||||
|
if fields.radius_bar then
|
||||||
|
local bar_val = tonumber(string.match(fields.radius_bar, "CHG:(%d+)") or
|
||||||
|
string.match(fields.radius_bar, "(%d+)") or "400")
|
||||||
|
local r = math.max(5, math.min(50, math.floor((bar_val / 1000) * 50)))
|
||||||
|
ent._ai_scan_radius = r
|
||||||
|
end
|
||||||
|
|
||||||
|
local enemies = {}
|
||||||
|
for k, v in pairs(fields) do
|
||||||
|
if string.sub(k, 1, 6) == "enemy_" and v == "true" then
|
||||||
|
table.insert(enemies, string.sub(k, 7))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ent._ai_enemy_players = enemies
|
||||||
|
|
||||||
|
if ent._ai_enabled and not ent._ai_running then
|
||||||
|
assorted_mecha_ai_start(ent)
|
||||||
|
end
|
||||||
|
|
||||||
|
assorted_mecha_panel_refs[pname] = nil
|
||||||
|
minetest.chat_send_player(pname, "[Mecha] Settings saved.")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end)
|
||||||
200
mecha_controller.lua
Normal file
200
mecha_controller.lua
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
-- assorted_mecha/mecha_controller.lua
|
||||||
|
-- Remote control item for managing all nearby mechs
|
||||||
|
|
||||||
|
local FORMNAME = "assorted_mecha:controller"
|
||||||
|
|
||||||
|
if not assorted_mecha_controller_refs then
|
||||||
|
assorted_mecha_controller_refs = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find all main mech entities within radius (no owner filter)
|
||||||
|
local function find_mechs_nearby(player, radius)
|
||||||
|
local pos = player:get_pos()
|
||||||
|
local mechs = {}
|
||||||
|
for _, obj in ipairs(minetest.get_objects_inside_radius(pos, radius or 512)) do
|
||||||
|
if not obj:is_player() then
|
||||||
|
local ent = obj:get_luaentity()
|
||||||
|
if ent and ent.name and string.match(ent.name, "^assorted_mecha:mech%d+$") then
|
||||||
|
table.insert(mechs, {ent = ent, pos = obj:get_pos()})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mechs
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_overview(pname, mechs, selected_idx)
|
||||||
|
local fs = "formspec_version[4]"
|
||||||
|
.. "size[10,9.5]"
|
||||||
|
.. "bgcolor[#1a1a2e;true]"
|
||||||
|
.. "box[0,0;10,0.8;#0f3460]"
|
||||||
|
.. "label[0.3,0.5;MECHA REMOTE | " .. pname .. "]"
|
||||||
|
|
||||||
|
if #mechs == 0 then
|
||||||
|
fs = fs
|
||||||
|
.. "box[0.5,1.5;9,1.2;#16213e]"
|
||||||
|
.. "label[0.8,2.0;No mechs found within 512 nodes.]"
|
||||||
|
.. "label[0.8,2.55;Move closer to your mech and reopen.]"
|
||||||
|
else
|
||||||
|
local rows = "Type,AI,Owner,Position"
|
||||||
|
for _, entry in ipairs(mechs) do
|
||||||
|
local ent = entry.ent
|
||||||
|
local p = entry.pos
|
||||||
|
local typ = string.gsub(ent.name, "assorted_mecha:", "")
|
||||||
|
local ai = ent._ai_enabled and "ON" or "off"
|
||||||
|
local own = (ent._ai_owner and ent._ai_owner ~= "") and ent._ai_owner or "-"
|
||||||
|
local pstr = string.format("%d/%d/%d", math.floor(p.x), math.floor(p.y), math.floor(p.z))
|
||||||
|
rows = rows .. "," .. typ .. "," .. ai .. "," .. own .. "," .. pstr
|
||||||
|
end
|
||||||
|
|
||||||
|
-- selected_idx is 1-based index into mechs table (nil = none)
|
||||||
|
local table_selected = selected_idx and (selected_idx + 1) or 1
|
||||||
|
|
||||||
|
fs = fs
|
||||||
|
.. "tablecolumns[text,align=left,width=4;text,align=center,width=2;text,align=center,width=3;text,align=left,width=4]"
|
||||||
|
.. "table[0.3,0.9;9.4,6.8;mech_table;" .. rows .. ";" .. table_selected .. "]"
|
||||||
|
.. "box[0,7.8;10,0.05;#16213e]"
|
||||||
|
.. "button[0.3,7.9;2.9,0.75;open_panel;Configure]"
|
||||||
|
.. "button[3.3,7.9;2.9,0.75;toggle_ai;Toggle AI]"
|
||||||
|
.. "button[6.3,7.9;3.4,0.75;claim_selected;Claim]"
|
||||||
|
.. "box[0,8.75;10,0.05;#16213e]"
|
||||||
|
.. "button[0.3,8.85;4.0,0.55;claim_all;Claim all nearby]"
|
||||||
|
end
|
||||||
|
|
||||||
|
fs = fs .. "button_exit[7.5,8.85;2.2,0.55;close;Close]"
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_overview(player, selected_idx)
|
||||||
|
local pname = player:get_player_name()
|
||||||
|
local ref = assorted_mecha_controller_refs[pname] or {}
|
||||||
|
local mechs = find_mechs_nearby(player)
|
||||||
|
-- preserve selection across refreshes
|
||||||
|
ref.mechs = mechs
|
||||||
|
ref.selected = selected_idx or ref.selected
|
||||||
|
assorted_mecha_controller_refs[pname] = ref
|
||||||
|
minetest.show_formspec(pname, FORMNAME, build_overview(pname, mechs, ref.selected))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Register the remote control item
|
||||||
|
minetest.register_craftitem("assorted_mecha:mecha_controller", {
|
||||||
|
description = "Mecha Remote Control\nLeft-click: open mech manager\nRight-click on mech: configure directly",
|
||||||
|
inventory_image = "mecha_controller.png",
|
||||||
|
stack_max = 1,
|
||||||
|
|
||||||
|
on_use = function(itemstack, user, pointed_thing)
|
||||||
|
if user and user:is_player() then
|
||||||
|
open_overview(user)
|
||||||
|
end
|
||||||
|
return itemstack
|
||||||
|
end,
|
||||||
|
|
||||||
|
on_place = function(itemstack, placer, pointed_thing)
|
||||||
|
if pointed_thing.type == "object" then
|
||||||
|
local obj = pointed_thing.ref
|
||||||
|
if obj and not obj:is_player() then
|
||||||
|
local ent = obj:get_luaentity()
|
||||||
|
if ent and ent.name and string.match(ent.name, "^assorted_mecha:mech%d+$") then
|
||||||
|
assorted_mecha_open_panel(ent, placer)
|
||||||
|
return itemstack
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return itemstack
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Formspec receive
|
||||||
|
minetest.register_on_player_receive_fields(function(player, formname, fields)
|
||||||
|
if formname ~= FORMNAME then return false end
|
||||||
|
local pname = player:get_player_name()
|
||||||
|
local ref = assorted_mecha_controller_refs[pname]
|
||||||
|
if not ref then return true end
|
||||||
|
local mechs = ref.mechs or {}
|
||||||
|
|
||||||
|
if fields.quit or fields.close then
|
||||||
|
assorted_mecha_controller_refs[pname] = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update selected index whenever table is interacted with
|
||||||
|
-- Luanti table field format on click: "CHG:N" where N is row (1=header, 2=first data row)
|
||||||
|
if fields.mech_table then
|
||||||
|
local row = tonumber(string.match(fields.mech_table, "%a+:(%d+)"))
|
||||||
|
if row then
|
||||||
|
local idx = row - 1 -- subtract header row
|
||||||
|
if idx >= 1 and idx <= #mechs then
|
||||||
|
ref.selected = idx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_selected_ent()
|
||||||
|
local idx = ref.selected
|
||||||
|
if idx and mechs[idx] and mechs[idx].ent then
|
||||||
|
-- verify entity still alive
|
||||||
|
if mechs[idx].ent.object and mechs[idx].ent.object:get_pos() then
|
||||||
|
return mechs[idx].ent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.open_panel then
|
||||||
|
local ent = get_selected_ent()
|
||||||
|
if ent then
|
||||||
|
assorted_mecha_controller_refs[pname] = nil
|
||||||
|
assorted_mecha_open_panel(ent, player)
|
||||||
|
else
|
||||||
|
minetest.chat_send_player(pname, "[Remote] Select a mech first.")
|
||||||
|
open_overview(player, ref.selected)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.toggle_ai then
|
||||||
|
local ent = get_selected_ent()
|
||||||
|
if ent then
|
||||||
|
ent._ai_enabled = not ent._ai_enabled
|
||||||
|
if ent._ai_enabled then
|
||||||
|
assorted_mecha_ai_start(ent)
|
||||||
|
minetest.chat_send_player(pname, "[Remote] AI enabled.")
|
||||||
|
else
|
||||||
|
ent._ai_running = false
|
||||||
|
minetest.chat_send_player(pname, "[Remote] AI disabled.")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
minetest.chat_send_player(pname, "[Remote] Select a mech first.")
|
||||||
|
end
|
||||||
|
open_overview(player, ref.selected)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.claim_selected then
|
||||||
|
local ent = get_selected_ent()
|
||||||
|
if ent then
|
||||||
|
ent._ai_owner = pname
|
||||||
|
minetest.chat_send_player(pname, "[Remote] Mech claimed.")
|
||||||
|
else
|
||||||
|
minetest.chat_send_player(pname, "[Remote] Select a mech first.")
|
||||||
|
end
|
||||||
|
open_overview(player, ref.selected)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if fields.claim_all then
|
||||||
|
local count = 0
|
||||||
|
for _, entry in ipairs(mechs) do
|
||||||
|
if entry.ent and entry.ent.object and entry.ent.object:get_pos() then
|
||||||
|
entry.ent._ai_owner = pname
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
minetest.chat_send_player(pname, "[Remote] Claimed " .. count .. " mech(s).")
|
||||||
|
open_overview(player, ref.selected)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-render on any table click to keep selection visible
|
||||||
|
open_overview(player, ref.selected)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
Reference in New Issue
Block a user