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

1255
init.lua Normal file

File diff suppressed because it is too large Load Diff

25
license.txt Normal file
View 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
View 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
View 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)