From 1862429c3810b78fc9bbdfd5b8cef7c2c5e3dd82 Mon Sep 17 00:00:00 2001 From: H5N3RG Date: Fri, 6 Mar 2026 10:50:54 +0000 Subject: [PATCH] Dateien nach "/" hochladen --- ai.lua | 498 ++++++++++++++++ init.lua | 1255 +++++++++++++++++++++++++++++++++++++++ license.txt | 25 + mecha_control_panel.lua | 193 ++++++ mecha_controller.lua | 200 +++++++ 5 files changed, 2171 insertions(+) create mode 100644 ai.lua create mode 100644 init.lua create mode 100644 license.txt create mode 100644 mecha_control_panel.lua create mode 100644 mecha_controller.lua diff --git a/ai.lua b/ai.lua new file mode 100644 index 0000000..4eeac1f --- /dev/null +++ b/ai.lua @@ -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 diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..e789a49 --- /dev/null +++ b/init.lua @@ -0,0 +1,1255 @@ + + + + + + +-- Configurable damage (set in settingtypes.txt) +-- Load AI system and control panel +local modpath = minetest.get_modpath("assorted_mecha") +dofile(modpath .. "/ai.lua") +dofile(modpath .. "/mecha_control_panel.lua") +dofile(modpath .. "/mecha_controller.lua") + + +local MECH_EXPLOSION_DAMAGE = tonumber(minetest.settings:get("assorted_mecha_explosion_damage")) or 100 +local MECH_BULLET_DAMAGE = tonumber(minetest.settings:get("assorted_mecha_bullet_damage")) or 10 + +local function apply_mech_scope(player, mech) + if not player or not mech then return end + + -- Apply FULLSCREEN HUD overlay (scope effect) + local hud_id = player:hud_add({ + type = "image", + position = {x = 0.4974, y = 0.5025}, + offset = {x = 0, y = 0}, + text = mech.scope_texture, + alignment = {x = 0, y = 0}, + scale = {x = -100, y = -100}, + }) + + player:get_meta():set_int("mech_hud_id", hud_id) +end + +local function remove_mech_scope(player) + if not player then return end + + local hud_id = player:get_meta():get_int("mech_hud_id") + if hud_id and hud_id > 0 then + player:hud_remove(hud_id) + end +end + + + + + + + +local function play_step_sound(self, mech) + if self.driver then + minetest.sound_play(mech.step_sound, {pos = self.object:get_pos(), gain = 0.5}) + + + local step_interval = self.driver:get_player_control().sneak and (mech.step_interval * 0.5) or mech.step_interval + + minetest.after(step_interval, function() + if self.driver and (self.object:get_velocity().x ~= 0 or self.object:get_velocity().z ~= 0) then + play_step_sound(self, mech) + end + end) + end +end + + + + + + + + + -- the word "assorted" implies three or more of something. + +local mechs = { + { + name = "mech1", + collisionbox = {-1.5, -0.1, -1.5, 1.5, 4.8, 1.5}, + speed = 4, + driver_visible = false, + scope_texture = "mecha_static.png^scope_1.png", + inventory_image = "spider_spawn.png", + stepheight = 3.2, + player_attach_offset = {x=0, y=1, z=0}, + player_eye_offset = {x=0, y=20, z=16.2}, + visual_size = {x=17.5, y=17.5, z=17.5}, + + description = "(Walking Cannon)\nCategory: (4 Legged Stomper)\n\nControls:\nW-A-S-D - Walk\nShift - Sprint\nE - Fire Cannon", + + + mesh = "mecha_spider_legs.x", + step_sound = "mech1_step", + turn_speed = 2, + walk_animation = {x=25, y=60}, + reverse_walk_animation = {x=61, y=97}, + stand_animation = {x=1, y=1}, + walk_frame_interval = 0.1, + texture = "mecha_head_gun.png", + jump_height = 10, + spawn_height = 0.5, + player_attach_sound = "mech_attach", + player_detach_sound = "mech_detach", + jump_sound = "mech_jump", + head_name = "assorted_mecha:head_spider", + head_offset = {x=0, y=4.75, z=0}, + + has_craftitem = true, + step_interval = 0.5, + }, + + { + name = "mech2", + collisionbox = {-1.4, -0.1, -1.4, 1.4, 4.8, 1.4}, + speed = 3, + driver_visible = false, + scope_texture = "mecha_static.png^scope_1.png", + inventory_image = "fang_spawn.png", + stepheight = 1.2, + player_attach_offset = {x=0, y=1, z=0}, + player_eye_offset = {x=0, y=23, z=12}, + visual_size = {x=30, y=30, z=30}, + + description = "(Fang)\nCategory: (Bipedal Walker)\n\nControls:\nW-A-S-D - Walk\nShift - Sprint\nE - Rapidfire", + + + + mesh = "mecha1_legs.x", + step_sound = "mech1_step", + turn_speed = 1, + walk_animation = {x=0, y=45}, + reverse_walk_animation = {x=45, y=90}, + stand_animation = {x=92, y=92}, + walk_frame_interval = 0.1, + texture = "fang_mecha.png", + jump_height = 10, + air_animation = {x=1, y=5}, + spawn_height = 0.5, + player_attach_sound = "mech_attach", + player_detach_sound = "mech_detach", + jump_sound = "mech_jump", + head_name = "assorted_mecha:head_2", + head_offset = {x=0, y=4.75, z=0}, + + has_craftitem = true, + step_interval = 0.75, + }, + + + + + + + + + { + name = "mech3", + collisionbox = {-1.5, -0.1, -1.5, 1.5, 4.8, 1.5}, + speed = 3, + driver_visible = false, + scope_texture = "mecha_static.png^scope_2.png", + inventory_image = "mecha_icon_default.png", + stepheight = 1.2, + player_attach_offset = {x=0, y=1, z=0}, + player_eye_offset = {x=0, y=28, z=25}, + visual_size = {x=14.5, y=14.5, z=14.5}, + + description = "(new mecha)\nCategory: ( category )\n\nControls:\n WASD or something.\nShift - Sprint\nE - activate head function", + + + mesh = "mecha_spider_legs.x", + step_sound = "mech1_step", + turn_speed = 1, + walk_animation = {x=25, y=60}, + reverse_walk_animation = {x=61, y=97}, + stand_animation = {x=1, y=1}, + walk_frame_interval = 0.1, + texture = "mecha_head_gun.png", + jump_height = 10, + air_animation = {x=1, y=5}, + spawn_height = 0.5, + player_attach_sound = "mech_attach", + player_detach_sound = "mech_detach", + jump_sound = "mech_jump", + head_name = "assorted_mecha:head_2", + head_offset = {x=0, y=3.5, z=0}, + + + has_craftitem = true, + step_interval = 0.5, + }, + + + + +} + +local function register_mech(mech) + local props = { + physical = true, + collisionbox = mech.collisionbox, + visual = "mesh", + mesh = mech.mesh, + textures = {mech.texture}, + visual_size = mech.visual_size, + stepheight = mech.stepheight, + } + + minetest.register_entity("assorted_mecha:" .. mech.name, { + initial_properties = props, + driver = nil, + head_entity = nil, + jump_allowed = true, + animation_timer = 0, + + on_activate = function(self) + self.object:set_armor_groups({immortal=1}) + -- Store mech config on self so AI can access it outside the closure + self._mech_speed = mech.speed + self._mech_walk_anim = mech.walk_animation + self._mech_stand_anim = mech.stand_animation + self._mech_head_name = mech.head_name + self._mech_name = mech.name + -- Initialize AI state (restore from staticdata if available) + assorted_mecha_ai_init(self) + if staticdata and staticdata ~= "" then + local ok, data = pcall(minetest.parse_json, staticdata) + if ok and data and data.ai then + assorted_mecha_ai_deserialize(self, minetest.write_json(data.ai)) + end + end + if self._ai_enabled then + assorted_mecha_ai_start(self) + end + + + if not self.head_entity then + local pos = self.object:get_pos() + pos.y = pos.y + mech.head_offset.y + self.head_entity = minetest.add_entity(pos, mech.head_name) + end + end, + + get_staticdata = function(self) + return minetest.write_json({ai = minetest.parse_json(assorted_mecha_ai_serialize(self))}) + end, + + on_step = function(self, dtime) + + + + if not self.initial_animation_set then + self.object:set_animation(mech.stand_animation, 30, 0) + self.initial_animation_set = true + end + + + if not self.initial_yaw_set then + self.object:set_yaw(0) + self.initial_yaw_set = true + end + + + if not self.initial_step_sound_played then + minetest.after(0.5, function() + minetest.sound_play(mech.step_sound, {pos = self.object:get_pos(), gain = 0.5}) + end) + self.initial_step_sound_played = true + end + + +local vel = self.object:get_velocity() or {x=0, y=0, z=0} +vel.y = vel.y - (dtime * 9.8) +self.object:set_velocity(vel) + + if self.driver then + local control = self.driver:get_player_control() + local yaw = self.object:get_yaw() + +local speed = (control.up and mech.speed or (control.down and -mech.speed or 0)) +local animation_speed = 30 -- Default animation speed + +-- Apply sprint boost when Shift (sneak) is pressed +if control.sneak then + speed = speed * 2 -- Double the normal speed + animation_speed = animation_speed * 2 -- Double animation speed +end + + + local vel = self.object:get_velocity() or {x=0, y=0, z=0} + + vel.x = math.sin(yaw) * -speed + vel.z = math.cos(yaw) * speed + self.object:set_velocity(vel) + + if control.left then + self.object:set_yaw(yaw + mech.turn_speed * dtime) + elseif control.right then + self.object:set_yaw(yaw - mech.turn_speed * dtime) + end + + + if speed ~= 0 and not self.is_playing_step_sound then + self.is_playing_step_sound = true + play_step_sound(self, mech) + elseif speed == 0 then + self.is_playing_step_sound = false + end + + + + -- Sneak+Jump = toggle 3rd-person overview / 1st-person scope + -- (V/zoom key is client-side only and never reaches the server) + local view_combo = control.sneak and control.jump + if view_combo then + if not self.view_combo_pressed then + self.view_combo_pressed = true + local meta = self.driver:get_meta() + local third = meta:get_int("mech_third_person") + if third == 0 then + meta:set_int("mech_third_person", 1) + remove_mech_scope(self.driver) + self.driver:hud_set_flags({crosshair = false, wielditem = false}) + self.driver:set_eye_offset( + {x = 0, y = 14, z = -28}, + {x = 0, y = 5, z = -5} + ) + else + meta:set_int("mech_third_person", 0) + self.driver:hud_set_flags({crosshair = false, wielditem = false}) + self.driver:set_eye_offset(mech.player_eye_offset, {x=0, y=0, z=0}) + apply_mech_scope(self.driver, mech) + end + end + else + self.view_combo_pressed = false + end + + if control.jump and self.jump_allowed and vel.y >= -1 then + self.object:set_velocity({x=vel.x, y=mech.jump_height, z=vel.z}) + self.jump_allowed = false + minetest.after(2, function() self.jump_allowed = true end) + minetest.sound_play(mech.jump_sound, {pos = self.object:get_pos(), gain = 0.5}) + end + + if self.driver then + local control = self.driver:get_player_control() + local speed = control.up and mech.speed or (control.down and -mech.speed or 0) + + + self.animation_timer = self.animation_timer + dtime + local is_moving = speed ~= 0 + +local moving_backward = control.down +local anim_range = moving_backward and mech.reverse_walk_animation or mech.walk_animation + + + if self.animation_timer >= mech.walk_frame_interval then + self.animation_timer = 0 + if is_moving then + self.object:set_animation(anim_range, animation_speed, 0) + + else + self.object:set_animation(mech.stand_animation, 30, 0) + + end + end + end +end + + + if self.head_entity then + local mech_pos = self.object:get_pos() + local head_pos = self.head_entity:get_pos() + + head_pos.x = mech_pos.x + head_pos.y = mech_pos.y + mech.head_offset.y + head_pos.z = mech_pos.z + + self.head_entity:set_pos(head_pos) + self.head_entity:set_yaw(self.head_entity:get_yaw()) + end + end, + + +on_rightclick = function(self, clicker) + -- If AI is active: open control panel, boarding not allowed + if self._ai_enabled then + assorted_mecha_open_panel(self, clicker) + return + end + -- Shift+rightclick opens panel in manual mode + local ctrl = clicker:get_player_control() + if not self.driver and ctrl.sneak then + assorted_mecha_open_panel(self, clicker) + return + end + if self.driver and clicker == self.driver then + clicker:set_detach() + clicker:set_eye_offset({x=0, y=0, z=0}) + minetest.sound_play(mech.player_detach_sound, {pos = self.object:get_pos(), gain = 0.5}) + self.driver = nil + + + clicker:set_properties({visual_size = mech.driver_visible and {x=1, y=1, z=1} or {x=0, y=0, z=0}}) + + + + if self.saved_hp then + clicker:set_hp(self.saved_hp) + self.saved_hp = nil + end + + + +clicker:set_properties({visual_size = {x=1, y=1, z=1}}) + + + remove_mech_scope(clicker) + clicker:hud_set_flags({crosshair = true, wielditem = true}) + clicker:set_eye_offset({x=0, y=0, z=0}, {x=0, y=0, z=0}) + clicker:get_meta():set_int("mech_third_person", 0) + else + clicker:set_attach(self.object, "", mech.player_attach_offset, {x=0, y=0, z=0}) + clicker:set_eye_offset(mech.player_eye_offset) + minetest.sound_play(mech.player_attach_sound, {pos = self.object:get_pos(), gain = 0.5}) + self.driver = clicker + self._driver_name = clicker:get_player_name() + + + + + self.saved_hp = clicker:get_hp() + + + minetest.register_on_player_hpchange(function(player, hp_change) + if player == clicker and player:get_attach() == self.object then + player:set_hp(self.saved_hp) + return 0 + end + return hp_change + end, true) + + + + + clicker:set_properties({visual_size = mech.driver_visible and {x=1, y=1, z=1} or {x=0, y=0, z=0}}) + + + apply_mech_scope(clicker, mech) + -- Scope overlay replaces crosshair; hide ingame crosshair & wielditem + clicker:hud_set_flags({crosshair = false, wielditem = false}) + clicker:get_meta():set_int("mech_third_person", 0) + end +end, + + }) +end + +for _, mech in ipairs(mechs) do + if mech.has_craftitem then + minetest.register_craftitem("assorted_mecha:spawn_" .. mech.name, { + description = "Name: " .. mech.description, + inventory_image = mech.inventory_image, + range = 9, + + on_place = function(itemstack, placer, pointed_thing) + if pointed_thing.type ~= "node" then return itemstack end + local pos = pointed_thing.above + pos.y = pos.y + mech.spawn_height + local mech_entity = minetest.add_entity(pos, "assorted_mecha:" .. mech.name) + if mech_entity and placer then + mech_entity:set_yaw(placer:get_look_horizontal()) + end + if not minetest.is_creative_enabled(placer:get_player_name()) then + itemstack:take_item() + end + return itemstack + end, + }) + end + + register_mech(mech) +end + + + + + + + + + + + + + + + + + + + + + + + + +-----------------------------------------------------------------------------------------------------mecha1 + + + + + + + +local function rotate_offset(offset, yaw) + local rotated_x = offset.x * math.cos(yaw) - offset.z * math.sin(yaw) + local rotated_z = offset.x * math.sin(yaw) + offset.z * math.cos(yaw) + return {x = rotated_x, y = offset.y, z = rotated_z} +end + +local bullet_groups = { + A = { {x = -1.35, y = 3.772, z = 0.85}, {x = 1.35, y = 3.772, z = 0.85} }, + B = { {x = -1.35, y = 3.92, z = 0.85}, {x = 1.35, y = 3.92, z = 0.85} }, + C = { {x = -1.35, y = 4.067, z = 0.85}, {x = 1.35, y = 4.067, z = 0.85} } +} + + + + +local function spawn_bullets(player) + if not player then return end + + local player_pos = player:get_pos() + local player_yaw = player:get_look_horizontal() + local player_dir = player:get_look_dir() -- Correct movement direction + + -- Randomly select one bullet group (A, B, or C) + local group_key = math.random(1, 3) == 1 and "A" or (math.random(1, 2) == 1 and "B" or "C") + local selected_positions = bullet_groups[group_key] + + -- Play sound when a bullet group is picked + minetest.sound_play("mech_fire2", {pos = player_pos, gain = 1.0}) + + -- Spawn bullets from the selected group + for _, offset in ipairs(selected_positions) do + local rotated_offset = rotate_offset(offset, player_yaw) + local bullet_pos = vector.add(player_pos, rotated_offset) + local bullet = minetest.add_entity(bullet_pos, "assorted_mecha:mecha_bullet") + + if bullet then + local _bent = bullet:get_luaentity() + if _bent then _bent.shooter_name = player:get_player_name() end + + local random_x = (math.random() * 0.5) - 0.25 + local random_z = (math.random() * 0.5) - 0.25 + + + local bullet_velocity = vector.multiply(player_dir, 30) + bullet_velocity.x = bullet_velocity.x + random_x + bullet_velocity.z = bullet_velocity.z + random_z + + bullet:set_velocity(bullet_velocity) + bullet:set_yaw(player:get_look_horizontal()) + + +local muzzle_textures = {"muzzle_flash.png", "muzzle_flash2.png"} +local selected_texture = muzzle_textures[math.random(1, #muzzle_textures)] + + +minetest.add_particlespawner({ + amount = 1, + time = 0.1, + minpos = bullet_pos, + maxpos = bullet_pos, + minvel = {x=0, y=0, z=0}, + maxvel = {x=0, y=0, z=0}, + minexptime = 0.1, + maxexptime = 0.1, + minsize = 6, + maxsize = 6, + texture = selected_texture, + glow = 15, +}) + + end + end +end + + + + + + + + + + + +minetest.register_entity("assorted_mecha:head_2", { + initial_properties = { + physical = false, + pointable = false, + + visual = "mesh", + mesh = "mecha_1.x", + glow = 0, + + textures = {"fang_mecha_antiglow.png"}, + visual_size = {x=11.4, y=11.4, z=11.4}, + static_save = false, + }, + + on_activate = function(self, staticdata) + self.object:set_armor_groups({immortal=1}) + + + if not self.glow_entity then + self.glow_entity = minetest.add_entity(self.object:get_pos(), "assorted_mecha:glow_effect") + if self.glow_entity then + self.glow_entity:set_attach(self.object, "", {x=0, y=0, z=0}, {x=0, y=0, z=0}) + end + end + end, + + on_step = function(self, dtime) + local head_pos = self.object:get_pos() + local target_player = nil + + + for _, player in ipairs(minetest.get_connected_players()) do + local player_pos = player:get_pos() + local height_difference = head_pos.y - player_pos.y + local horizontal_distance = vector.distance(vector.new(head_pos.x, 0, head_pos.z), vector.new(player_pos.x, 0, player_pos.z)) + + if math.abs(height_difference - 4.75) < 0.1 and horizontal_distance <= 1 then + target_player = player + break + end + end + + + if target_player then + self.object:set_yaw(target_player:get_look_horizontal()) + + + local control = target_player:get_player_control() + if control.aux1 and not self.is_firing then + self.is_firing = true + local function fire_loop() + if self.is_firing and target_player then + spawn_bullets(target_player) + minetest.after(0.1, fire_loop) + end + end + fire_loop() + elseif not control.aux1 then + self.is_firing = false + end + end +end, + +}) + + +minetest.register_entity("assorted_mecha:glow_effect", { + initial_properties = { + physical = false, + pointable = false, + visual = "mesh", + mesh = "mecha_1.x", + glow = 30, + textures = {"fang_glow.png"}, + visual_size = {x=1, y=1, z=1}, + static_save = false, + }, + + + on_activate = function(self) + self.object:set_armor_groups({immortal=1}) + end, +}) + + + + + +--------------------------------------------------------------------------------------------------mecha2 + + + + +local function rotate_offset(offset, yaw) + local rotated_x = offset.x * math.cos(yaw) - offset.z * math.sin(yaw) + local rotated_z = offset.x * math.sin(yaw) + offset.z * math.cos(yaw) + return {x = rotated_x, y = offset.y, z = rotated_z} +end + + + +minetest.register_entity("assorted_mecha:head_spider", { + initial_properties = { + physical = false, + pointable = false, + + visual = "mesh", + mesh = "mecha_big_gun_head.x", + glow = 0, + + textures = {"mecha_head_gun_anti.png"}, + visual_size = {x=14, y=14, z=14}, + static_save = false, + }, + + on_activate = function(self, staticdata) + self.object:set_armor_groups({immortal=1}) + + + if not self.glow_entity then + self.glow_entity = minetest.add_entity(self.object:get_pos(), "assorted_mecha:spider_glow_effect") + if self.glow_entity then + self.glow_entity:set_attach(self.object, "", {x=0, y=0, z=0}, {x=0, y=0, z=0}) + end + end +end, + + + + + + + + + + +on_step = function(self, dtime) + local head_pos = self.object:get_pos() + local target_player = nil + + + for _, player in ipairs(minetest.get_connected_players()) do + local player_pos = player:get_pos() + local height_difference = head_pos.y - player_pos.y + local horizontal_distance = vector.distance(vector.new(head_pos.x, 0, head_pos.z), vector.new(player_pos.x, 0, player_pos.z)) + + if math.abs(height_difference - 4.75) < 0.1 and horizontal_distance <= 1 then + target_player = player + break + end + end + + + if target_player then + self.object:set_yaw(target_player:get_look_horizontal()) + + + local control = target_player:get_player_control() + if control.aux1 and not self.is_firing then + self.is_firing = true + + + local shoot_speed = 15 + self.object:set_animation({x=24, y=38}, shoot_speed, 0) + + if self.glow_entity then + self.glow_entity:set_animation({x=24, y=38}, shoot_speed, 0) -- Sync glow animation + end + + + local yaw = target_player:get_look_horizontal() + local pitch = target_player:get_look_vertical() + local dir = vector.new(-math.sin(yaw) * math.cos(pitch), -math.sin(pitch), math.cos(yaw) * math.cos(pitch)) + + -- **Bullet offset relative to the mech** + local bullet_offset = {x=0, y=-0.75, z=2.75} -- Adjust positioning + local rotated_offset = rotate_offset(bullet_offset, yaw) + local bullet_pos = vector.add(head_pos, rotated_offset) + + + + +local bullet = minetest.add_entity(bullet_pos, "assorted_mecha:plasma_bolt") +if bullet then + local _pent = bullet:get_luaentity() + if _pent then _pent.shooter_name = target_player:get_player_name() end + bullet:set_velocity(vector.multiply(dir, 40)) + minetest.sound_play("plasma_rifle", {pos = bullet_pos, gain = 3.0}) + bullet:set_yaw(target_player:get_look_horizontal()) + + -- **Spawn muzzle flash particles at bullet position** + local muzzle_textures = {"plasma_pop.png"} + local selected_texture = muzzle_textures[math.random(1, #muzzle_textures)] + + minetest.add_particlespawner({ + amount = 1, + time = 0.1, + minpos = bullet_pos, + maxpos = bullet_pos, + minvel = {x=0, y=0, z=0}, + maxvel = {x=0, y=0, z=0}, + minexptime = 0.1, + maxexptime = 0.1, + minsize = 12, + maxsize = 12, + texture = selected_texture, + glow = 15, + }) +end + + + + + + minetest.after(0.6, function() + self.object:set_animation({x=0.5, y=0.9}, 1, 0) + + if self.glow_entity then + self.glow_entity:set_animation({x=0.5, y=0.9}, 1, 0) + end + end) + + -- Cooldown of 2 seconds before firing again + minetest.after(2, function() + self.is_firing = false + end) + end + end +end, + + + + +}) + + +minetest.register_entity("assorted_mecha:spider_glow_effect", { + initial_properties = { + physical = false, + pointable = false, + visual = "mesh", + mesh = "mecha_big_gun_head.x", + glow = 30, + textures = {"mecha_head_gun_glow.png"}, + visual_size = {x=1, y=1, z=1}, + static_save = false, + }, + + + on_activate = function(self) + self.object:set_armor_groups({immortal=1}) + end, +}) + +--------------------------------------------------------------------------------------------------stuff + + + +minetest.register_entity("assorted_mecha:mecha_bullet", { + initial_properties = { + physical = true, + visual_size = {x=0.1, y=0.1, z=0.1}, + visual = "sprite", + textures = {"mecha_bullet.png"}, + glow = 30, + collisionbox = {0, 0, 0, 0, 0, 0}, + }, + + on_activate = function(self) + self.shooter_name = nil + minetest.after(3, function() self.object:remove() end) + end, + + on_step = function(self, dtime) + local pos = self.object:get_pos() + if not pos then return end + + local function is_liquid(name) + local def = minetest.registered_nodes[name] + return def and def.liquidtype and def.liquidtype ~= "none" + end + + local blood = {"mech_blood_1.png","mech_blood_2.png","mech_blood_3.png","mech_blood_4.png"} + + -- Entity check FIRST + for _, obj in ipairs(minetest.get_objects_inside_radius(pos, 1.0)) do + if obj ~= self.object then + local skip = false + if obj:is_player() then + if obj:get_player_name() == self.shooter_name then skip = true end + else + local ent = obj:get_luaentity() + if ent then + local n = ent.name or "" + if (self.shooter_name and ent._driver_name == self.shooter_name) + or string.find(n, "plasma_bolt") + or string.find(n, "mecha_bullet") + or string.find(n, "node_shard") + or string.find(n, "head_spider") + or string.find(n, "head_2") + or string.find(n, "glow_effect") then + skip = true + end + else + skip = true + end + end + if not skip then + minetest.add_particlespawner({ + amount = 15, time = 0.3, + minpos = pos, maxpos = pos, + minvel = {x=-2,y=3,z=-2}, maxvel = {x=2,y=5,z=2}, + minacc = {x=0,y=-15,z=0}, maxacc = {x=0,y=-20,z=0}, + minexptime = 1, maxexptime = 1, + minsize = 1, maxsize = 1.5, glow = 0, + texture = blood[math.random(1,#blood)], + }) + if obj:is_player() then + obj:set_hp(math.max(0, obj:get_hp() - MECH_BULLET_DAMAGE)) + else + obj:punch(obj, 1.0, {full_punch_interval=1.0, damage_groups={fleshy=MECH_BULLET_DAMAGE}}) + end + self.object:remove() + return + end + end + end + + -- Node check + local node = minetest.get_node(pos) + if node.name ~= "air" and not is_liquid(node.name) and node.name ~= "ignore" then + minetest.add_particlespawner({ + amount = 25, time = 0.3, + minpos = pos, maxpos = pos, + minvel = {x=-2,y=1,z=-2}, maxvel = {x=2,y=3,z=2}, + minexptime = 0.4, maxexptime = 0.8, + minsize = 4, maxsize = 6, + texture = "smoke_puff.png", glow = 0, + }) + minetest.add_particlespawner({ + amount = 25, time = 0.3, + minpos = pos, maxpos = pos, + minvel = {x=-2,y=3,z=-2}, maxvel = {x=2,y=5,z=2}, + minacc = {x=0,y=-15,z=0}, maxacc = {x=0,y=-20,z=0}, + minexptime = 0.3, maxexptime = 0.6, + minsize = 4, maxsize = 4, + texture = "spark.png", glow = 30, + }) + minetest.sound_play("bullet", {pos = pos, gain = 1.5, max_hear_distance = 32}) + minetest.set_node(pos, {name = "air"}) + local shard_obj = minetest.add_entity(pos, "assorted_mecha:node_shard") + if shard_obj then + local shard = shard_obj:get_luaentity() + if shard then + shard.original_node = node.name + shard_obj:set_properties({wield_item = node.name}) + shard_obj:set_velocity({ + x = (math.random()-0.5)*8, + y = 4 + math.random()*4, + z = (math.random()-0.5)*8, + }) + end + end + self.object:remove() + return + end + end, +}) +minetest.register_entity("assorted_mecha:node_shard", { + initial_properties = { + physical = true, + collide_with_objects = false, + collisionbox = {-0.3, -0.3, -0.3, 0.3, 0.3, 0.3}, + visual = "wielditem", + visual_size = {x = 0.5, y = 0.5}, + textures = {""}, + }, + + on_activate = function(self) + self.timer = 0 + self.rotation = {x = 0, y = 0, z = 0} + self.spin_rate = { + x = math.rad(math.random(-180, 180)), + y = math.rad(math.random(-180, 180)), + z = math.rad(math.random(-180, 180)), + } + + -- gravity + self.object:set_acceleration({x = 0, y = -3.5, z = 0}) + end, + + on_step = function(self, dtime) + self.timer = self.timer + dtime + + -- Visual spin + self.rotation.x = self.rotation.x + self.spin_rate.x * dtime + self.rotation.y = self.rotation.y + self.spin_rate.y * dtime + self.rotation.z = self.rotation.z + self.spin_rate.z * dtime + self.object:set_rotation(self.rotation) + + local pos = self.object:get_pos() + local radius = 1.2 + local nearby_node_found = false + + for dx = -1, 1 do + for dy = -1, 0 do + for dz = -1, 1 do + local check_pos = vector.add(pos, {x = dx, y = dy, z = dz}) + local dist = vector.distance(pos, check_pos) + local node = minetest.get_node_or_nil(check_pos) + if node and node.name ~= "air" and dist <= radius then + nearby_node_found = true + break + end + end + if nearby_node_found then break end + end + if nearby_node_found then break end + end + + +if nearby_node_found or self.timer >= 8 then + minetest.add_item(pos, self.original_node or "default:dirt") + + -- **Play original node's place sound before removal** + if self.original_node then + local node_def = minetest.registered_nodes[self.original_node] + if node_def and node_def.sounds and node_def.sounds.place then + minetest.sound_play(node_def.sounds.place.name, {pos = pos, gain = 0.5}) + end + end + + self.object:remove() +end + + + end, +}) + + + + + + + + + + +minetest.register_entity("assorted_mecha:plasma_bolt", { + initial_properties = { + physical = true, + visual_size = {x = 0.2, y = 0.2, z = 0.2}, + visual = "sprite", + textures = {"plasma_bolt.png"}, + glow = 50, + collisionbox = {0, 0, 0, 0, 0, 0}, + }, + + on_activate = function(self) + self.shooter_name = nil + minetest.after(3, function() + if self.object then self.object:remove() end + end) + end, + + on_step = function(self, dtime) + local pos = self.object:get_pos() + if not pos then return end + + local function is_liquid(name) + local def = minetest.registered_nodes[name] + return def and def.liquidtype and def.liquidtype ~= "none" + end + + local function do_explosion(epos) + minetest.add_particlespawner({ + amount = 45, time = 0.3, + minpos = epos, maxpos = epos, + minvel = {x=-4,y=2,z=-4}, maxvel = {x=4,y=6,z=4}, + minexptime = 0.8, maxexptime = 1.6, + minsize = 8, maxsize = 12, + texture = "smoke_puff.png", glow = 0, + }) + minetest.add_particlespawner({ + amount = 45, time = 0.3, + minpos = epos, maxpos = epos, + minvel = {x=-4,y=6,z=-4}, maxvel = {x=4,y=10,z=4}, + minacc = {x=0,y=-15,z=0}, maxacc = {x=0,y=-20,z=0}, + minexptime = 0.6, maxexptime = 1.2, + minsize = 8, maxsize = 8, + texture = "spark.png", glow = 30, + }) + minetest.sound_play("bomber_ex", {pos = epos, gain = 2.0, max_hear_distance = 64}) + + local radius = 6 + for dx = -radius, radius do + for dy = -radius, radius do + for dz = -radius, radius do + if dx*dx + dy*dy + dz*dz <= radius*radius then + local tpos = vector.add(epos, {x=dx, y=dy, z=dz}) + local tnode = minetest.get_node(tpos) + if tnode.name ~= "air" + and not is_liquid(tnode.name) + and tnode.name ~= "ignore" then + minetest.set_node(tpos, {name = "air"}) + local so = minetest.add_entity(tpos, "assorted_mecha:node_shard") + if so then + local se = so:get_luaentity() + if se then + se.original_node = tnode.name + so:set_properties({wield_item = tnode.name}) + so:set_velocity({ + x = (math.random()-0.5)*10, + y = 5 + math.random()*6, + z = (math.random()-0.5)*10, + }) + end + end + end + end + end + end + end + + for _, obj in ipairs(minetest.get_objects_inside_radius(epos, radius)) do + if obj:is_player() then + if obj:get_player_name() ~= self.shooter_name then + obj:set_hp(math.max(0, obj:get_hp() - MECH_EXPLOSION_DAMAGE)) + end + else + local ent = obj:get_luaentity() + if ent then + local n = ent.name or "" + local own = self.shooter_name and ent._driver_name == self.shooter_name + local proj = string.find(n,"plasma_bolt") or string.find(n,"mecha_bullet") or string.find(n,"node_shard") + if not own and not proj then + obj:punch(obj, 1.0, {full_punch_interval=1.0, damage_groups={fleshy=MECH_EXPLOSION_DAMAGE}}) + end + end + end + end + self.object:remove() + end + + -- Entity check FIRST (prevents tunneling) + for _, obj in ipairs(minetest.get_objects_inside_radius(pos, 1.5)) do + if obj ~= self.object then + local skip = false + if obj:is_player() then + if obj:get_player_name() == self.shooter_name then skip = true end + else + local ent = obj:get_luaentity() + if ent then + local n = ent.name or "" + if (self.shooter_name and ent._driver_name == self.shooter_name) + or string.find(n,"plasma_bolt") + or string.find(n,"mecha_bullet") + or string.find(n,"node_shard") + or string.find(n,"head_spider") + or string.find(n,"head_2") + or string.find(n,"glow_effect") then + skip = true + end + else + skip = true + end + end + if not skip then + do_explosion(pos) + return + end + end + end + + -- Node impact + local node = minetest.get_node(pos) + if node.name ~= "air" and not is_liquid(node.name) and node.name ~= "ignore" then + do_explosion(pos) + return + end + end, +}) + + + + + + + + + + + +-- Chat command to open the control panel of the nearest mech +-- Usage: /mecha_panel (finds nearest mech within 8 nodes) +minetest.register_chatcommand("mecha_panel", { + params = "", + description = "Opens the control panel of the nearest mech", + func = function(name) + local player = minetest.get_player_by_name(name) + if not player then return false, "Player not found" end + local pos = player:get_pos() + local closest = nil + local closest_dist = math.huge + for _, obj in ipairs(minetest.get_objects_inside_radius(pos, 8)) do + if not obj:is_player() then + local ent = obj:get_luaentity() + if ent and ent.name and string.find(ent.name, "assorted_mecha:mech") then + local d = vector.distance(pos, obj:get_pos()) + if d < closest_dist then + closest = ent + closest_dist = d + end + end + end + end + if closest then + assorted_mecha_open_panel(closest, player) + return true, "" + else + return false, "No mech within range (8 nodes)" + end + end, +}) + +minetest.register_chatcommand("clear_foliage", { + params = "", + description = "Removes all trees, logs, leaves, or saplings within a 40-block radius.", + privs = {server = true}, + func = function(name) + local player = minetest.get_player_by_name(name) + if not player then return end + + local pos = player:get_pos() + local radius = 40 + + + for x = -radius, radius do + for y = -radius, radius do + for z = -radius, radius do + local check_pos = vector.add(pos, {x=x, y=y, z=z}) + local node = minetest.get_node(check_pos) + local node_name = node.name + + -- Remove nodes containing keywords + if string.find(node_name, "tree") or + string.find(node_name, "log") or + string.find(node_name, "leaves") or + string.find(node_name, "sapling") then + minetest.set_node(check_pos, {name = "air"}) + end + end + end + end + + minetest.chat_send_player(name, "Cleared all trees, logs, leaves, and saplings within 40 blocks!") + end +}) \ No newline at end of file diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..0ac4cc6 --- /dev/null +++ b/license.txt @@ -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. + diff --git a/mecha_control_panel.lua b/mecha_control_panel.lua new file mode 100644 index 0000000..676fbd5 --- /dev/null +++ b/mecha_control_panel.lua @@ -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) diff --git a/mecha_controller.lua b/mecha_controller.lua new file mode 100644 index 0000000..010bd75 --- /dev/null +++ b/mecha_controller.lua @@ -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)