commit 0757a58d16dfa3afe116294da25bf0263370fec2 Author: copygirl Date: Fri Oct 6 18:47:33 2023 +0200 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..09e5417 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 4 + +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/CONCEPT.md b/CONCEPT.md new file mode 100755 index 0000000..1592020 --- /dev/null +++ b/CONCEPT.md @@ -0,0 +1,106 @@ +# Contraptions Concept + +## Progression + +- Assemble wooden contraption inside a rectangle of frames + - Rotate a contraption + - Take a contraption to water + - Use a shovel to propel a contraption on water + - Craft a wooden wheel + - Attach wheels to a contraption + - Construct a wooden rail (gravel + staff?) + - Push a contraption or contraption on a rail + - Attach a handle to a contraption + - Pull a contraption using a handle + - Assemble a hinged contraption + - Rotate a hinged contraption using a handle + - Assemble a contraption inside a cuboid of lode frames + - Craft a lode wheel + - Construct a lode rail (gravel + lode rod?) + - Build a self-propelled contraption + - Ride a contraption going full speed + +## Materials + +### Contraption Tiers + +- Wooden: Plank, Log +- ??? + +### Ground + +Different types of ground apply different amount of friction onto a contraption, slowing it down. These are just some rough guessed values to play around with. + +- dirty: 2 +- plank: 1 +- cobble: 1 +- smooth: 0.5 +- Tarstone: 0.3 +- Water: 0.4 +- Wooden Rail: 0.5 +- Lode Rail: 0.2 + +## Details + +### Assemble wooden contraption inside a rectangle of frames + +``` +++++ ++PP+ + = Wooden Frame ++PP+ P = Plank ++PP+ (Pummel frame with hammer to complete.) +++++ +``` + +A contraption is a connected set of nodes that can be pushed by players to move it. Multiple players actively pushing reduces the cooldown between each push. Larger contraptions are more difficult to push. + +contraptions are affected by gravity. + +contraptions will drag with them any "pushable" nodes (such as items) directly above them. If a contraption is pushed underneath an overhang and items can't follow, they will fall off. + +### Turn a contraption + +??? + +Plaforms can turn gradually, their "forward" direction changing to an angle not perfectly aligned with the grid. + +``` +LLLL LLL +RRRR RRRL LL + R RRLL + RR + L + RL + RL + RL + R + + RL + RL + RL + RL + + RL + RL + RL + RL + + RL + RL + RL + RL +``` + +### Craft a wheel + +??? + +### Attach wheels to a contraption to create a cart + +``` +WPPW P = Wooden contraption + PP W = Wooden Wheel +WPPW (Pummel wheel into contraption to complete.) +``` + +A wheeled contraption is able to build up momentum, loosing it due to passive speed loss and friction. Different surfaces have different amounts of friction. diff --git a/README.md b/README.md new file mode 100644 index 0000000..78afeb0 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# NodeCore Contraptions Mod + +This mod for [NodeCore], a game written for the open source [Minetest] engine, +adds so-called "contraptions" to the game, which are node-based objects that +can be moved around the world as a connected structure. + +Contraptions can be platforms, rafts, carts, boats, doors, minecarts, vehicles +and even trains. Or, at least, that's the plan. This mod is still heavily in +development. Items and players riding on top or inside contraption will be +pushed along with them. + +These contraptions are inspired by the *Minecraft* mod [Create], which allows +creation of similar contraptions, however this implementation will achieve +this effect by using purely nodes in their usual grid. Rotation will be done +in a similar fashion as the vehicles in [Cataclysm: Dark Days Ahead]. + +[NodeCore]: https://content.minetest.net/packages/Warr1024/nodecore/ +[Minetest]: https://minetest.net/ +[Create]: https://modrinth.com/mod/create +[Cataclysm: Dark Days Ahead]: https://cataclysmdda.org/ diff --git a/contraption.lua b/contraption.lua new file mode 100755 index 0000000..f3a72be --- /dev/null +++ b/contraption.lua @@ -0,0 +1,336 @@ +local math, pairs, ipairs, setmetatable, table_insert, print + = math, pairs, ipairs, setmetatable, table.insert, print + +local minetest, vector, nodecore + = minetest, vector, nodecore + +local mod_name = minetest.get_current_modname() +local mod_storage = minetest.get_mod_storage() + +-- This is the object that is returned by this script. +local contraption = {} + +-- Some constants. Should probably be removed. +local AIR = { name = "air" } +local PLANK = { name = "nc_woodwork:plank" } +local CONTRAPTION_WOOD = { name = "nc_contraptions:contraption_wood" } + +-- Counts up to infinity as contraptions get created. +-- Each contraption gets assigned a unique id for its lifetime. +local id_counter = 0 + +-- Table containing the currently active contraptions keyed by their id. +local active_contraptions = {} + +-- TODO: Push along items. +-- TODO: Push along players. +-- TODO: Friction depending on ground. +-- TODO: Gravity. +-- TODO: Water physics! +-- TODO: Rotation. + +-- TODO: Unload and save contraptions when they're in unloaded chunks. +-- TODO: Use meta field `infotext` for displaying debug info about a contraption? + +local function error(...) + local str = "[" .. mod_name .. "] " + for _, v in ipairs({...}) do str = str + tostring(v) end + print(str) +end + +-------------------- +-- EVENT HANDLING -- +-------------------- + +function contraption.on_global_step(delta_time) + for _, contrap in pairs(active_contraptions) do + contrap:update(delta_time) + end +end + +function contraption.on_startup() + local version = mod_storage:get_int("version") + + if version == 0 then + -- Assume that no mod data has been saved yet. + return + elseif version ~= 1 then + error("Unknown mod storage version " .. version) + return + end + + id_counter = mod_storage:get_int("id_counter") + + local contraptions_str = mod_storage:get("contraptions") + local contraptions = minetest.deserialize(contraptions_str) + + for _, id in ipairs(contraptions) do + local str = mod_storage:get("contraption_" .. id) + contraption.load_from_string(id, str) + end +end + +function contraption.on_shutdown() + -- TODO: Only modify mod storage where contraptions have changed / been destroyed. + -- For now we're just going to wipe the storage and re-create it. + mod_storage:from_table(nil) + + mod_storage:set_int("version", 1) + mod_storage:set_int("id_counter", id_counter) + + local contraptions = {} + + for id, contrap in pairs(active_contraptions) do + local str = contrap:save_to_string() + mod_storage:set_string("contraption_" .. id, str) + + table_insert(contraptions, id) + end + + mod_storage:set_string("contraptions", minetest.serialize(contraptions)); +end + +---------------------- +-- PUBLIC FUNCTIONS -- +---------------------- + +function contraption.find(pos) + -- First check if the node at `pos` is a contraption block. + for _, c in pairs(active_contraptions) do + if pos.x >= c.region.min.x and pos.x <= c.region.max.x + and pos.y >= c.region.min.y and pos.y <= c.region.max.y + and pos.z >= c.region.min.z and pos.z <= c.region.max.z + then + local rel_pos = pos - c.region.min + local rel_pos_hash = minetest.hash_node_position(rel_pos) + if c.nodes[rel_pos_hash] then return c end + end + end + return nil +end + +------------------------------------- +-- CONTRAPTION TYPE IMPLEMENTATION -- +------------------------------------- + +local metatable = { __index = contraption } +contraption.metatable = metatable + +function contraption.create(region) + local nodes = {} + local num_nodes = 0 + for x = region.min.x, region.max.x do + for y = region.min.y, region.max.y do + for z = region.min.z, region.max.z do + local pos = vector.new(x, y, z) + local node = minetest.get_node(pos) + if nodecore.match(node, PLANK) then + local offset = pos - region.min + local pos_hash = minetest.hash_node_position(offset) + nodes[pos_hash] = CONTRAPTION_WOOD + num_nodes = num_nodes + 1 + end + end + end + end + + -- Less than 4 nodes don't make a contraption. + if num_nodes < 4 then return nil end + + -- TODO: Shrink region to the minimum required cuboid. + -- In the above code we could just use pos as table key. + + -- TODO: Flood fill algorithm to make sure all nodes are connected. + + id_counter = id_counter + 1 + local result = setmetatable({ + id = id_counter, + region = region, + nodes = nodes, + num_nodes = num_nodes, + }, metatable) + + -- Change nodes from base nodes to their contraption version. + for pos_hash, node in pairs(nodes) do + local rel_pos = minetest.get_position_from_hash(pos_hash) + local abs_pos = vector.add(region.min, rel_pos) + minetest.set_node(abs_pos, node) + end + + active_contraptions[result.id] = result + return result +end + +function contraption.load_from_string(id, str) + local obj = minetest.deserialize(str) + + if obj.version ~= 1 then + error("Unknown contraption version " .. obj.version) + return nil + end + + local result = setmetatable({ + id = id, + + region = obj.region, + nodes = obj.nodes, + num_nodes = obj.num_nodes, + + motion = obj.motion and vector.copy(obj.motion ) or nil, + partial = obj.partial and vector.copy(obj.partial) or nil, + }, metatable) + + active_contraptions[id] = result + return obj +end + +function contraption:save_to_string() + local to_save = { + version = 1, + + region = self.region, + nodes = self.nodes, + num_nodes = self.num_nodes, + + motion = self.motion, + partial = self.partial, + } + return minetest.serialize(to_save) +end + +function contraption:destroy() + active_contraptions[self.id] = nil + + for pos_hash, node in pairs(self.nodes) do + local rel_pos = minetest.get_position_from_hash(pos_hash) + local abs_pos = vector.add(self.region.min, rel_pos) + local def = minetest.registered_nodes[node.name] + minetest.set_node(abs_pos, def.drop_in_place) + end +end + +function contraption:push(pusher, dir) + -- See if the same player has already pushed this contraption recently. + -- If so, do not apply the full amount of motion from the push. + local pusher_name = pusher.get_player_name and pusher:get_player_name() or "" + self.recent_pusher = self.recent_pusher or {} + local delay = self.recent_pusher[pusher_name] + if delay then dir = dir * (1 - math.min(1, delay)) end + self.recent_pusher[pusher_name] = 1.0 -- seconds + + self.motion = self.motion or vector.zero() + self.motion = vector.add(self.motion, dir) +end + +function contraption:update(delta_time) + if not self.motion then return end + + -- Partial motion that accumulates over time, + -- since we can only move in increments of 1. + self.partial = self.partial or vector.zero() + self.partial = self.partial + self.motion * delta_time + + -- If partial motion has increased to more than 1 on + -- one axis, find out which axis has the largest motion .. + local max = 0 + local which = 0 + for i = 1, 3 do + local abs = math.abs(self.partial[i]) + if abs >= 1 and abs > max then + max = abs + which = i + end + end + -- .. and move it 1 step into that direction. + if which ~= 0 then + local step = vector.zero() + step[which] = math.sign(self.partial[which]) + if self:move(step) then + self.partial = self.partial - step + else + -- Reset motion into the direction that we bumped into. + -- Allows for "sliding" along walls instead of stopping outright. + self.motion[which] = 0 + self.partial[which] = 0 + end + end + + -- Apply friction. + local FRICTION = 0.25 + self.motion = self.motion * (1 - FRICTION * delta_time) + + -- If motion is small enough, unset it for performance. + if self.motion:length() < 0.1 then + self.motion = nil + self.partial = nil + end + + if self.recent_pusher then + local has_pushers = false + for pusher, delay in pairs(self.recent_pusher) do + if delay > 0 then + self.recent_pusher[pusher] = math.max(0, delay - delta_time) + has_pushers = true + end + end + -- If there are no active pushers, delete the `recent_pushers` table. + if not has_pushers then self.recent_pusher = nil end + end +end + +function contraption:move(offset) + local to_set = {} -- Nodes that need to be modified. + local to_clear = {} -- Nodes to be cleared (to AIR). + + -- Assume that every node this contraption occupies needs to be cleared. + -- We'll remove entries from `to_clear` when we know a node is moved there. + for pos_hash in pairs(self.nodes) do to_clear[pos_hash] = AIR end + + for old_rel_pos_hash, node in pairs(self.nodes) do + local old_rel_pos = minetest.get_position_from_hash(old_rel_pos_hash) + local old_abs_pos = vector.add(self.region.min, old_rel_pos) + + local new_abs_pos = vector.add(old_abs_pos, offset) + local new_rel_pos = vector.add(old_rel_pos, offset) + local new_rel_pos_hash = minetest.hash_node_position(new_rel_pos) + + -- Node is being moved to this position, no need to clear here. + to_clear[new_rel_pos_hash] = nil + + local self_overlap = self.nodes[new_rel_pos_hash] + if self_overlap then + if not nodecore.match(node, self_overlap) then + to_set[new_rel_pos_hash] = node + end + else + local new_node = minetest.get_node(new_abs_pos) + if nodecore.buildable_to(new_node) then + to_set[new_rel_pos_hash] = node + else + -- We bumped into a wall or something. + return false + end + end + end + + -- Set nodes that need to be changed. + for pos_hash, node in pairs(to_set) do + local rel_pos = minetest.get_position_from_hash(pos_hash) + local abs_pos = vector.add(self.region.min, rel_pos) + minetest.set_node(abs_pos, node) + end + + -- Clear nodes that need to be cleared (to AIR). + for pos_hash, node in pairs(to_clear) do + local rel_pos = minetest.get_position_from_hash(pos_hash) + local abs_pos = vector.add(self.region.min, rel_pos) + minetest.set_node(abs_pos, node) + end + + -- Move the contraption in the world. + self.region.min = self.region.min + offset + self.region.max = self.region.max + offset + return true +end + +return contraption diff --git a/init.lua b/init.lua new file mode 100755 index 0000000..fec963c --- /dev/null +++ b/init.lua @@ -0,0 +1,68 @@ +local minetest, vector, nodecore, include + = minetest, vector, nodecore, include + +local search = include("search") +local contraption = include("contraption") + +local mod_name = minetest.get_current_modname() + +local i_stick = "nc_tree:stick" +local i_staff = "nc_tree:staff" +local i_frame = "nc_woodwork:frame" +local i_form = "nc_woodwork:form" +local i_plank = "nc_woodwork:plank" + +local t_plank = "nc_woodwork_plank.png" +local t_frame_log = "(nc_tree_tree_side.png^[mask:nc_api_storebox_frame.png^[opacity:127)" + +-- Load and save contraptions. +contraption.on_startup() +minetest.register_on_shutdown(contraption.on_shutdown) + +-- `on_global_step` updates and moves around active contraptions. +minetest.register_globalstep(contraption.on_global_step) + +-- Punching a contraption applies some force to it. +-- Multiple players can push a contraption more effectively. +minetest.register_on_punchnode(function(pos, node, puncher, pointed_thing) + local contrap = contraption.find(pos) + if not contrap then return end + + if (not pointed_thing.above) or (not pointed_thing.under) then return end + local dir = vector.subtract(pointed_thing.under, pointed_thing.above) + contrap:push(puncher, dir) +end) + +-- TODO: Provide a method to register additional types of contraption blocks. +minetest.register_node(mod_name .. ":contraption_wood", { + description = "Wooden Contraption", + tiles = { t_plank .. "^" .. t_frame_log }, + sounds = nodecore.sounds("nc_tree_woody"), + groups = { + choppy = 1, + flammable = 2, + fire_fuel = 5, + }, + on_dig = function(pos, node, digger) + local contrap = contraption.find(pos) + if contrap then contrap:destroy() + else minetest.node_dig(pos, node, digger) end + end, + drop_in_place = "nc_woodwork:plank", +}) + +-- TODO: Take priority over or hook into frame-to-form recipe to do our thing instead. +nodecore.register_craft({ + label = "assemble wooden contraption", + action = "pummel", + toolgroups = { scratchy = 3 }, + indexkeys = { i_frame }, + nodes = { { match = i_frame } }, + check = function(pos, data) + data.found_square = search.find_rectangle(pos, i_frame) + return not not data.found_square + end, + after = function(pos, data) + contraption.create(data.found_square) + end, +}) diff --git a/mod.conf b/mod.conf new file mode 100755 index 0000000..659ca02 --- /dev/null +++ b/mod.conf @@ -0,0 +1,3 @@ +name = nc_contraptions +description = Adds movable contraptions to NodeCore. +depends = nc_api_all diff --git a/search.lua b/search.lua new file mode 100755 index 0000000..dc6b304 --- /dev/null +++ b/search.lua @@ -0,0 +1,103 @@ +local util = {} + +local AXES = { + x = vector.new(1, 0, 0), + y = vector.new(0, 1, 0), + z = vector.new(0, 0, 1), +} +util.AXES = AXES; + +-- Gets an array of vectors relative to `pos` of neighbors that have the specified `match`. +local function find_neighbors_with_name(pos, match) + local result = {} + for _, v in pairs(AXES) do + if minetest.get_node(pos - v).name == match then + table.insert(result, -v) + end + end + for _, v in pairs(AXES) do + if minetest.get_node(pos + v).name == match then + table.insert(result, v) + end + end + return result +end +util.find_neighbors_with_name = find_neighbors_with_name; + +-- Moves `pos` towards `dir` while node is still `match`. +-- returns (vector, array) or nil +local function move_until_corner(pos, match, dir) + repeat pos = pos + dir + until minetest.get_node(pos + dir).name ~= match + local neighbors = find_neighbors_with_name(pos, match) + if #neighbors == 2 + then return pos, neighbors + else return nil + end +end + +-- Moves `pos` towards `dir`, checking that each node has exactly two neighbors +-- that of type `match`, until a corner turning towards `expected_corner_dir` is hit. +-- returns vector or nil +local function move_until_corner_with_checks(pos, match, dir, expected_corner_dir) + while true do + pos = pos + dir + + local neighbors = find_neighbors_with_name(pos, match) + if #neighbors ~= 2 then return nil end + + -- Get the "next direction" the piece is going. + local next_dir = neighbors[1] + if next_dir == -dir then next_dir = neighbors[2] end + + if next_dir ~= dir then + if next_dir == expected_corner_dir + then return pos + else return nil + end + end + end +end + +-- Finds a rectangle structure made from the specified node `match`. +-- returns { min = vector, max = vector } or nil +local function find_rectangle(pos, match) + local neighbors = find_neighbors_with_name(pos, match) + if #neighbors ~= 2 then return nil end + + -- If we found a line piece (neighbors are opposite) then move in the + -- direction of the smallest xyz coordinate. + if neighbors[1] == -neighbors[2] then + -- NOTE: Due to how `get_neighbors_with_name` is written, the first element + -- is guaranteed to contain the vector pointing towards negative. + pos, neighbors = move_until_corner(pos, match, neighbors[1]) + if not pos then return nil end + end + + -- We're now guaranteed to be at a corner piece. + -- Move to the corner piece with the smallest xyz coordinate. + while neighbors[1].x + neighbors[1].y + neighbors[1].z < 1 do + pos, neighbors = move_until_corner(pos, match, neighbors[1]) + if not pos then return nil end + end + + -- Find the neighboring corners of the starting corner. + local first_corner = move_until_corner_with_checks(pos, match, neighbors[1], neighbors[2]) + if not first_corner then return nil end + local second_corner = move_until_corner_with_checks(pos, match, neighbors[2], neighbors[1]) + if not second_corner then return nil end + + -- Continue towards the final corner from the new corners. + first_corner = move_until_corner_with_checks(first_corner, match, neighbors[2], -neighbors[1]) + if not first_corner then return nil end + second_corner = move_until_corner_with_checks(second_corner, match, neighbors[1], -neighbors[2]) + if not second_corner then return nil end + + -- Ensure that they have met up at the same location. + if first_corner ~= second_corner then return nil end + + return { min = pos, max = second_corner } +end +util.find_rectangle = find_rectangle; + +return util