-- Laser "physics". This file contains the code that propagates -- the lasers, updates the laser nodes and the map. -- Max. number of steps in the laser travel algorithm local MAX_LASER_ITERATIONS = 10000 -- TODO: Don't use these variables, instead transport it between functions local burning_cache = {} local destroy_cache = {} -- This propagates a *single laser* by a *single step* and checks what -- to do with the next node. This will either add the laser, stop the -- laser propagation (due to collision), or do a special event in case we hit a -- laser block (mirror, crystal, etc.). -- This function only works on VoxelManip data! -- Parameters: -- * `pos`: start position -- * `dir`: laser direction -- * `colorcode`: laser colorcode -- * `varea`: VoxelArea for the VoxelManip workable area -- * `vdata`: VoxelManip data table gotten with `get_data` -- * `vdata_p2`: VoxelManip data table, but for param2 -- -- Returns a list of the *next* laser positions and direction, each entry is in format: -- { pos, dir, colorcode } -- This indicates where to spawn the next lasers (pos), where they are headed -- towards (dir) and what color they are (colorcode). -- Some laser blocks may spawn multiple lasers (crystal, transmissive mirror), -- this is why it must be a list. -- If the laser terminates (e.g. due to collision), returns false instead. function lzr_laser.add_laser(pos, dir, colorcode, varea, vdata, vdata_p2) -- Check if laser is still within the playfield if pos.x < lzr_globals.PLAYFIELD_START.x or pos.x > lzr_globals.PLAYFIELD_END.x or pos.y < lzr_globals.PLAYFIELD_START.y or pos.y > lzr_globals.PLAYFIELD_END.y or pos.z < lzr_globals.PLAYFIELD_START.z or pos.z > lzr_globals.PLAYFIELD_END.z then return false end local vi = varea:indexp(pos) local content_id = vdata[vi] local param2 = vdata_p2[vi] local nodename = minetest.get_name_from_content_id(content_id) local ld = minetest.get_item_group(nodename, "laser_destroys") -- Laser through air or destroyable block if content_id == minetest.CONTENT_AIR or ld == 1 then local dirs = lzr_laser.vector_and_color_to_dirs(dir, colorcode) local dirstring = lzr_laser.dirs_to_dirstring(dirs) vdata[vi] = minetest.get_content_id("lzr_laser:laser_"..dirstring) if ld == 1 then table.insert(destroy_cache, {pos=pos, nodename=nodename}) end -- Just advance straight ahead pos = vector.add(pos, dir) return {{pos, dir, colorcode}} -- Burning block elseif ld == 2 then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Only burn in-game (for editor convenience) if lzr_gamestate.get_state() == lzr_gamestate.LEVEL then vdata[vi] = minetest.get_content_id(active) table.insert(burning_cache, pos) end else minetest.log("error", "[lzr_laser] Node definition of "..nodename.." has laser_destroys=2 but no _lzr_active") end -- Laser collides return false -- Laser through laser (laser intersection) elseif minetest.get_item_group(nodename, "laser") > 0 then local laser_group = minetest.get_item_group(nodename, "laser") local dirstring_old = lzr_laser.laser_group_to_dirstring(laser_group) local dirs_new = lzr_laser.vector_and_color_to_dirs(dir, colorcode) local dirstring_new = lzr_laser.dirs_to_dirstring(dirs_new) local place_dirstring = lzr_laser.dirstring_or(dirstring_old, dirstring_new) vdata[vi] = minetest.get_content_id("lzr_laser:laser_"..place_dirstring) -- Advance straight ahead pos = vector.add(pos, dir) return {{pos, dir, colorcode}} -- Laser through skull elseif minetest.get_item_group(nodename, "skull_shy") > 0 or minetest.get_item_group(nodename, "skull_cursed") > 0 then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Activate skull node vdata[vi] = minetest.get_content_id(def._lzr_active) end pos = vector.add(pos, dir) return {{pos, dir, colorcode}} -- Mirror laser elseif minetest.get_item_group(nodename, "mirror") > 0 then local mirror_dir = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir) if mirror_dir then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Activate mirror node and mix color local old_colorcode = minetest.get_item_group(nodename, "laser_block_color") local new_colorcode = bit.bor(colorcode, old_colorcode) vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode) end -- Set new pos and dir after calculating mirror direction pos = vector.add(pos, mirror_dir) dir = mirror_dir return {{pos, dir, colorcode}} else return false end -- Mirror and split laser elseif minetest.get_item_group(nodename, "transmissive_mirror") > 0 then local mirror_dir, mirror_side = lzr_laser.get_mirrored_laser_dir(nodename, param2, dir) if mirror_dir then local def = minetest.registered_nodes[nodename] local state = def._lzr_transmissive_mirror_state if not state then minetest.log("error", "[lzr_laser] Transmissive mirror node '"..nodename.."' does not have _lzr_transmissive_mirror_state!") return false end -- Combine transmissive mirror state if mirror_side == true then -- Combine current state with 0X (X = incoming colorcode) state = lzr_laser.dirstring_or(state, "0"..colorcode) else -- Combine current state with X0 (X = incoming colorcode) state = lzr_laser.dirstring_or(state, colorcode.."0") end local is_takable = minetest.get_item_group(nodename, "takable") ~= 0 local active if is_takable then active = "lzr_laser:transmissive_mirror_"..state.."_takable" else active = "lzr_laser:transmissive_mirror_"..state end -- Set new transmissive node state vdata[vi] = minetest.get_content_id(active) -- Report new laser positions and directions. -- This always will report 2 new lasers. -- The laser goes right through ... local pos_straight = vector.add(pos, dir) local dir_straight = dir -- ... and it is also deflected local pos_mirrored = vector.add(pos, mirror_dir) local dir_mirrored = mirror_dir return { -- The laser that went straight through {pos_straight, dir_straight, colorcode}, -- The mirrored laser {pos_mirrored, dir_mirrored, colorcode} } else -- Laser came from the wrong side so it ends here return false end -- Crystal: Spread laser to all directions elseif minetest.get_item_group(nodename, "crystal") > 0 then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if minetest.get_item_group(nodename, "laser_block_color") == colorcode then return false end if active then -- Activate node and mix color local old_colorcode = minetest.get_item_group(nodename, "laser_block_color") local new_colorcode = bit.bor(colorcode, old_colorcode) vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode) end -- Set dirs to spread laser towards local dirs = { vector.new(0, -1, 0), vector.new(0, 1, 0), vector.new(-1, 0, 0), vector.new(1, 0, 0), vector.new(0, 0, -1), vector.new(0, 0, 1), } -- Don't spread to the direction we came from! local fromdir = vector.multiply(dir, -1) for d=1, #dirs do if vector.equals(dirs[d], fromdir) then table.remove(dirs, d) break end end local output = {} for d=1, #dirs do table.insert(output, { vector.add(pos, dirs[d]), dirs[d], colorcode }) end return output -- Detector elseif minetest.get_item_group(nodename, "detector") > 0 then local detected = lzr_laser.check_detector(pos, nodename, dir, colorcode) if detected then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Activate node vdata[vi] = minetest.get_content_id(active) end end -- Laser ends here return false -- Hollow barrel elseif minetest.get_item_group(nodename, "hollow_barrel") > 0 then -- Laser can through the hollow part local axis = lzr_laser.get_barrel_axis(param2) if (dir.x ~= 0 and axis == "x") or (dir.y ~= 0 and axis == "y") or (dir.z ~= 0 and axis == "z") then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Combine color local old_colorcode = minetest.get_item_group(nodename, "laser_block_color") local new_colorcode = bit.bor(colorcode, old_colorcode) -- Activate barrel node vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode) end pos = vector.add(pos, dir) return {{pos, dir, colorcode}} else return false end -- Pane elseif minetest.get_item_group(nodename, "pane") > 0 then -- Laser can through the pane local axis = lzr_laser.get_pane_axis(param2) if (dir.x ~= 0 and axis == "x") or (dir.y ~= 0 and axis == "y") or (dir.z ~= 0 and axis == "z") then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Combine color local old_colorcode = minetest.get_item_group(nodename, "laser_block_color") local new_colorcode = bit.bor(colorcode, old_colorcode) -- Activate pane node vdata[vi] = minetest.get_content_id(active.."_"..new_colorcode) end pos = vector.add(pos, dir) return {{pos, dir, colorcode}} else return false end -- An open empty chest, or a slab elseif minetest.get_item_group(nodename, "chest_open") > 0 or minetest.get_item_group(nodename, "slab") == 1 then -- Laser can go into it from above -- (this also works for slab because it cannot rotate) if dir.y < 0 then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Laser beam from above goes into chest/slab vdata[vi] = minetest.get_content_id(active.."_"..colorcode) end end -- Laser ends here return false -- Palm leaves elseif minetest.get_item_group(nodename, "palm_leaves") > 0 then -- Laser can go into it from below if dir.y > 0 then local def = minetest.registered_nodes[nodename] local active = def._lzr_active if active then -- Laser beam from below vdata[vi] = minetest.get_content_id(active.."_"..colorcode) end end -- Laser ends here return false -- Anything else terminates the laser else return false end end -- Emit a laser from an emitter and starts laser propagation. -- * `pos`: position of emitter -- * `colorcode`: laser colorcode -- * `varea`, `vdata`, `vdata_p2`: See `lzr_laser.add_laser` function lzr_laser.emit_laser(pos, colorcode, varea, vdata, vdata_p2) local vi = varea:indexp(pos) local content_id = vdata[vi] local nodename = minetest.get_name_from_content_id(content_id) if minetest.get_item_group(nodename, "emitter") == 0 then minetest.log("error", "[lzr_laser] lzr_laser.emit_laser was called at invalid pos!") return false end local param2 = vdata_p2[vi] local dir = minetest.facedir_to_dir(param2) dir = vector.multiply(dir, -1) local i_pos = vector.add(pos, dir) lzr_laser.travel_laser(i_pos, dir, colorcode, varea, vdata, vdata_p2) end -- Spawns and propagates a single laser from `pos` step-by-step -- until either all laser beams that have been created during -- travel have terminated, or the algorithm took too many -- iterations (which is probably an error pointing to an infinite -- laser loop; or the map is just very complex). -- * `pos`: position of laser -- * `dir`: direction towards the laser will travel to initially -- * `colorcode`: laser colorcode -- * `varea`, `vdata`, `vdata_p2`: See `lzr_laser.add_laser` function lzr_laser.travel_laser(pos, dir, colorcode, varea, vdata, vdata_p2) local i_pos = table.copy(pos) local cond = true -- This is a list of all currently "travelling lasers" that -- spawned from the initial laser. Each travelling laser -- has a position, direction and colorcode. -- This list initializes with a single position, from where we start. -- Each time add_laser is called, the travelling laser is removed, but -- any new travelling lasers returned from add_laser will be added to the -- list. -- This essentially is a depth-first search. local next_lasers = {{i_pos, dir, colorcode}} local i = 0 while cond do i = i + 1 -- Halt execution for very long loops to prevent freezing the game if i > MAX_LASER_ITERATIONS then minetest.log("error", "[lzr_laser] lzr_laser.travel_laser aborted (too many iterations!)") break end -- Get next laser and propagate it by one step local next_laser = next_lasers[1] local add_laser_result = lzr_laser.add_laser(next_laser[1], next_laser[2], next_laser[3], varea, vdata, vdata_p2) table.remove(next_lasers, 1) if add_laser_result ~= false then for a=1, #add_laser_result do table.insert(next_lasers, add_laser_result[a]) end end -- When the table is empty, this means all travelling lasers have terminated. Success! if #next_lasers == 0 then break end end end -- Remove all lasers in the given area and disable all laser blocks -- (e.g. mirrors, detectors) -- * pos1: Minimum position of area -- * pos2: Maximum position of area -- * ignore_emitters: If true, won't disable emitters -- * varea, vdata: See lzr_laser.add_laser function lzr_laser.clear_lasers_in_area(pos1, pos2, ignore_emitters, varea, vdata) for vi=1, #vdata do local cid = vdata[vi] local nodename = minetest.get_name_from_content_id(cid) if minetest.get_item_group(nodename, "laser") ~= 0 then vdata[vi] = minetest.CONTENT_AIR elseif minetest.get_item_group(nodename, "laser_block") ~= 0 then local def = minetest.registered_nodes[nodename] local is_ignored_emitter = false if ignore_emitters ~= true then is_ignored_emitter = minetest.get_item_group(nodename, "emitter") > 0 end if def and not is_ignored_emitter then local inactive = def._lzr_inactive if inactive then vdata[vi] = minetest.get_content_id(inactive) end end end end end -- Emit lasers from all *active* emitters in area. -- * pos1: Minimum position of area -- * pos2: Maximum position of area -- * ignore_emitters -- * varea, vdata, vdata_p2: See lzr_laser.add_laser function lzr_laser.emit_lasers_in_area(pos1, pos2, varea, vdata, vdata_p2) local emitters = minetest.find_nodes_in_area(pos1, pos2, {"group:emitter"}) for e=1, #emitters do local epos = emitters[e] local vi = varea:indexp(epos) local emitter_cid = vdata[vi] local emittername = minetest.get_name_from_content_id(emitter_cid) local is_active = minetest.get_item_group(emittername, "emitter") == 2 local colorcode = minetest.get_item_group(emittername, "emitter_color") if is_active and colorcode ~= 0 then lzr_laser.emit_laser(emitters[e], colorcode, varea, vdata, vdata_p2) end end end -- Returns true if all detectors in area between pos1 and pos2 are active. -- If there are no detectors, returns true. function lzr_laser.check_detectors_in_area(pos1, pos2) local detectors = minetest.find_nodes_in_area(pos1, pos2, {"group:detector"}) for d=1, #detectors do local dpos = detectors[d] local detector = minetest.get_node(dpos) local is_active = minetest.get_item_group(detector.name, "detector") == 2 if not is_active then return false end end return true end -- Returns true if there are no unclaimed treasures remaining in area function lzr_laser.check_treasures_in_area(pos1, pos2) local closed_chests = minetest.find_nodes_in_area(pos1, pos2, {"group:chest_closed"}) return #closed_chests > 0 end -- Returns the number of treasures found in current level in area function lzr_laser.count_found_treasures(pos1, pos2) return #minetest.find_nodes_in_area(pos1, pos2, {"group:chest_open_treasure"}) end -- Returns true if player has no detectors in inventory -- * player: Player ObjectRef -- * detector_placed: Set to true if the player has *just* placed a detector (default: false) function lzr_laser.check_inventory_detectors(player, detector_placed) local inv = player:get_inventory() local count = 0 for i=1, inv:get_size("main") do local item = inv:get_stack("main", i) if minetest.get_item_group(item:get_name(), "detector") ~= 0 then count = count + item:get_count() if (detector_placed and count > 1) or (not detector_placed) then return false end end end return true end -- Returns true if ALL detectors (both in level and in inventory) are active -- * detector_placed: Set to true if the player has *just* placed a detector (default: false) function lzr_laser.check_all_detectors(detector_placed) local cond_area = lzr_laser.check_detectors_in_area(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) local cond_inventory = true local player = minetest.get_player_by_name("singleplayer") if player then cond_inventory = lzr_laser.check_inventory_detectors(player, detector_placed) end return cond_area and cond_inventory end -- Returns true if current level is won function lzr_laser.check_level_won() return not lzr_laser.check_treasures_in_area(lzr_globals.PLAYFIELD_START, lzr_globals.PLAYFIELD_END) end -- Returns a table of all detector states in the given area, -- indexed by the VoxelArea index of a VoxelManip and the value -- being either true for active, false for inactive and nil -- for any position without a detector. local function get_detector_states_in_area(pos1, pos2, varea, vdata) local states = {} for vi=1, #vdata do local cid = vdata[vi] local nodename = minetest.get_name_from_content_id(cid) local dstate = minetest.get_item_group(nodename, "detector") if dstate == 1 then states[vi] = false elseif dstate == 2 then states[vi] = true end end return states end -- Completely recalculate all lasers function lzr_laser.full_laser_update(pos1, pos2) local benchmark_time_1 = minetest.get_us_time() burning_cache = {} destroy_cache = {} local vmanip = minetest.get_voxel_manip(pos1, pos2) local vpos1, vpos2 = vmanip:get_emerged_area() local varea = VoxelArea:new({MinEdge = vpos1, MaxEdge = vpos2}) local vdata = vmanip:get_data() local vdata_p2 = vmanip:get_param2_data() -- Remember the old state of the map and the state of -- detectors, then compare it to the new detector state. -- Used for the detector sound effect. local detector_states_old = get_detector_states_in_area(pos1, pos2, varea, vdata) -- << THE MAIN LASER UPDATE HAPPENS HERE >> -- The update is simple: We first remove all lasers, then -- re-emit everything. This *COULD* be made more efficient -- by only recalculating the lasers at the points where -- they got changed tho. lzr_laser.clear_lasers_in_area(pos1, pos2, nil, varea, vdata) lzr_laser.emit_lasers_in_area(pos1, pos2, varea, vdata, vdata_p2) -- Required for the detector sound effect. local detector_states_new = get_detector_states_in_area(pos1, pos2, varea, vdata) -- Write laser changes to map vmanip:set_data(vdata) vmanip:set_param2_data(vdata_p2) vmanip:write_to_map() -- Post-map update changes for stuff that the VManip can't do -- Play detector sound for all detectors that have changed their state for vindex, state_new in pairs(detector_states_new) do local state_old = detector_states_old[vindex] local pos = varea:position(vindex) if state_new == true and state_old == false then minetest.sound_play({name="lzr_laser_detector_activate", gain=0.7}, {pos=pos}, true) elseif state_new == false and state_old == true then minetest.sound_play({name="lzr_laser_detector_deactivate", gain=0.7}, {pos=pos}, true) end end -- Trigger node burning for nodes burned by laser for b=1, #burning_cache do local node = minetest.get_node(burning_cache[b]) local def = minetest.registered_nodes[node.name] if def and def.on_construct then def.on_construct(burning_cache[b]) end end burning_cache = {} -- Trigger animation and sound effect for nodes destroyed by laser for d=1, #destroy_cache do local dpos = destroy_cache[d].pos local nodename = destroy_cache[d].nodename local def = minetest.registered_nodes[nodename] if def and def.sounds and def.sounds.dug then minetest.sound_play(def.sounds.dug, {pos=dpos}, true) end minetest.add_particlespawner({ amount = 12, time = 0.001, minpos = vector.subtract(dpos, vector.new(0.5, 0.5, 0.5)), maxpos = vector.add(dpos, vector.new(0.5, 0.5, 0.5)), minvel = vector.new(-0.5, -0.2, -0.5), maxvel = vector.new(0.5, 0.2, 0.5), minacc = vector.new(0, -lzr_globals.GRAVITY, 0), maxacc = vector.new(0, -lzr_globals.GRAVITY, 0), minsize = 0.8, maxsize = 0.8, minexptime = 0.5, maxexptime = 0.55, node = {name=nodename}, }) end destroy_cache = {} -- Print benchmark time local benchmark_time_2 = minetest.get_us_time() local diff = benchmark_time_2 - benchmark_time_1 minetest.log("info", "[lzr_laser] lzr_laser.full_laser_update took "..diff.." µs.") end