Composition tool (#6)
Some checks failed
luacheck / luacheck (push) Has been cancelled
test / build (push) Has been cancelled

* persist template metadata

* composition tool

* composition utils

* wip

* wip

* use swap_node for handles

* formspec

* wip

* set composition origin to first min-pos

* composition duplication and we area

* wallmounted replacement

---------

Co-authored-by: BuckarooBanzay <BuckarooBanzay@users.noreply.github.com>
This commit is contained in:
Buckaroo Banzai 2025-01-04 14:03:09 +01:00 committed by GitHub
parent 4b7be7442a
commit 3466f84540
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 537 additions and 244 deletions

View File

@ -1,7 +1,8 @@
std = "minetest+min"
globals = {
"pick_and_place"
"pick_and_place",
"worldedit"
}
read_globals = {

View File

@ -48,4 +48,46 @@ function pick_and_place.get_outer_corners(pos1, pos2)
{ x=pos2.x, y=pos2.y, z=pos1.z },
{ x=pos2.x, y=pos2.y, z=pos2.z }
}
end
function pick_and_place.get_replacement_nodeid(ctx, metadata)
local group = metadata.fields.group
local selected_name
if group and group ~= "" and ctx[group] then
-- group placement
selected_name = metadata.inventory.main[ctx[group]]
else
-- random placement
local replacement_names = {}
for _, name in ipairs(metadata.inventory.main) do
if name ~= "" then
table.insert(replacement_names, name)
end
end
if #replacement_names == 0 then
-- no replacement
return
end
local i = math.random(#replacement_names)
selected_name = replacement_names[i]
-- set group context
if group and group ~= "" then
ctx[group] = i
end
end
local stack = ItemStack(selected_name)
local nodename = stack:get_name()
if not minetest.registered_nodes[nodename] then
-- node not found
return
end
local nodeid = minetest.get_content_id(nodename)
return nodeid
end

330
composition.lua Normal file
View File

@ -0,0 +1,330 @@
local function serialize_composition(composition)
local serialized_data = minetest.serialize(composition)
local compressed_data = minetest.compress(serialized_data, "deflate")
return minetest.encode_base64(compressed_data)
end
local function deserialize_composition(str)
local compressed_data = minetest.decode_base64(str)
local serialized_data = minetest.decompress(compressed_data, "deflate")
return minetest.deserialize(serialized_data)
end
-- playername -> tool-id
local active_tools = {}
-- tool-id -> composition
local compositions = {}
-- TODO: sync inventory with global state
local function get_current_composition_tool(playername)
local id = active_tools[playername]
if not id then
return
end
local player = minetest.get_player_by_name(playername)
local inv = player:get_inventory()
local list = inv:get_list("main")
for _, itemstack in ipairs(list) do
if itemstack:get_name() == "pick_and_place:composition" then
local meta = itemstack:get_meta()
local stack_id = meta:get_string("id")
if stack_id == id then
-- match
return itemstack
end
end
end
end
local function set_current_composition_tool(playername, new_itemstack)
local id = active_tools[playername]
if not id then
return
end
local player = minetest.get_player_by_name(playername)
local inv = player:get_inventory()
local list = inv:get_list("main")
for i, itemstack in ipairs(list) do
if itemstack:get_name() == "pick_and_place:composition" then
local meta = itemstack:get_meta()
local stack_id = meta:get_string("id")
if stack_id == id then
-- match
inv:set_stack("main", i, new_itemstack)
return
end
end
end
end
local function update(itemstack, playername, state)
local meta = itemstack:get_meta()
pick_and_place.update_composition_tool(meta)
local id = meta:get_string("id")
if state then
meta:set_string("state", state)
if state == "record" then
meta:set_string("color", "#ff0000") -- red
active_tools[playername] = id
local data = meta:get_string("data")
if data ~= "" then
-- existing composition
compositions[id] = deserialize_composition(data)
else
-- new composition
compositions[id] = { entries = {} }
end
else
meta:set_string("color", "#0000ff") -- blue
active_tools[playername] = nil
compositions[id] = nil
end
end
-- TODO: check state
pick_and_place.update_composition_tool(meta)
end
function pick_and_place.update_composition_tool(meta)
local id = meta:get_string("id")
if id == "" then
-- initialize
id = pick_and_place.create_id()
meta:set_string("id", id)
end
local name = meta:get_string("name")
local data = meta:get_string("data")
local bytes = #data
local entries = meta:get_int("entries")
local desc = string.format("Composition tool '%s' (id: %s, %d entries, %d bytes)", name, id, entries, bytes)
meta:set_string("description", desc)
end
function pick_and_place.update_composition_fields(itemstack, playername, fields)
local meta = itemstack:get_meta()
meta:set_string("name", fields.name)
update(itemstack, playername)
end
function pick_and_place.record_composition(itemstack, playername)
update(itemstack, playername, "record")
end
function pick_and_place.pause_composition(itemstack, playername)
update(itemstack, playername, "pause")
end
function pick_and_place.play_composition(itemstack, playername)
local meta = itemstack:get_meta()
local origin = meta:get_string("origin")
local pos = minetest.string_to_pos(origin)
if not pos then
return
end
local data = meta:get_string("data")
if data ~= "" then
local composition = deserialize_composition(data)
local success, msg = pick_and_place.start_playback(playername, pos, composition)
if not success then
minetest.chat_send_player(playername, msg)
end
end
end
function pick_and_place.mark_composition_area(itemstack, playername)
local meta = itemstack:get_meta()
local origin = minetest.string_to_pos(meta:get_string("origin"))
if not origin then
return
end
local data = meta:get_string("data")
if not data then
return
end
local composition = deserialize_composition(data)
if not composition then
return
end
if not minetest.get_modpath("worldedit") then
return
end
if composition.min_pos then
local pos1 = vector.add(origin, composition.min_pos)
worldedit.pos1[playername] = pos1
worldedit.mark_pos1(playername);
end
if composition.max_pos then
local pos2 = vector.add(origin, composition.max_pos)
worldedit.pos2[playername] = pos2
worldedit.mark_pos2(playername);
end
end
function pick_and_place.duplicate_composition_tool(itemstack, playername)
itemstack = ItemStack(itemstack)
local meta = itemstack:get_meta()
meta:set_string("id", pick_and_place.create_id())
pick_and_place.update_composition_tool(meta)
local player = minetest.get_player_by_name(playername)
local inv = player:get_inventory()
inv:add_item("main", itemstack)
end
function pick_and_place.set_composition_origin(itemstack, playername)
local player = minetest.get_player_by_name(playername)
if not player then
return
end
local pos = vector.round(player:get_pos())
local meta = itemstack:get_meta()
meta:set_string("origin", minetest.pos_to_string(pos))
end
function pick_and_place.tp_composition_origin(itemstack, playername)
local player = minetest.get_player_by_name(playername)
if not player then
return
end
local meta = itemstack:get_meta()
local origin = meta:get_string("origin")
local pos = minetest.string_to_pos(origin)
if not pos then
return
end
player:set_pos(pos)
end
local function track_min_max_pos(composition, pos)
if not composition.min_pos then
composition.min_pos = vector.copy(pos)
elseif pos.x < composition.min_pos.x then
composition.min_pos.x = pos.x
elseif pos.y < composition.min_pos.y then
composition.min_pos.y = pos.y
elseif pos.z < composition.min_pos.z then
composition.min_pos.z = pos.z
end
if not composition.max_pos then
composition.max_pos = vector.copy(pos)
elseif pos.x > composition.max_pos.x then
composition.max_pos.x = pos.x
elseif pos.y > composition.max_pos.y then
composition.max_pos.y = pos.y
elseif pos.z > composition.max_pos.z then
composition.max_pos.z = pos.z
end
end
function pick_and_place.record_removal(playername, pos1, pos2)
local itemstack = get_current_composition_tool(playername)
if not itemstack then
return
end
local meta = itemstack:get_meta()
local id = meta:get_string("id")
local origin = minetest.string_to_pos(meta:get_string("origin"))
if not origin then
return
end
local composition = compositions[id]
if not composition or #composition.entries == 0 then
return
end
local rel_pos1 = vector.subtract(pos1, origin)
local rel_pos2 = vector.subtract(pos2, origin)
track_min_max_pos(composition, rel_pos1)
track_min_max_pos(composition, rel_pos2)
-- search and remove exact pos1/2 matches
local entry_removed = false
for i, entry in ipairs(composition.entries) do
if vector.equals(entry.pos1, rel_pos1) and vector.equals(entry.pos2, rel_pos2) then
-- remove matching entry
table.remove(composition.entries, i)
entry_removed = true
break
end
end
if not entry_removed then
-- non-aligned removal, just record
table.insert(composition.entries, {
type = "remove",
pos1 = rel_pos1,
pos2 = rel_pos2
})
end
meta:set_string("entries", #composition.entries)
meta:set_string("data", serialize_composition(composition))
set_current_composition_tool(playername, itemstack)
end
function pick_and_place.record_placement(playername, pos1, pos2, rotation, name, id)
local itemstack = get_current_composition_tool(playername)
if not itemstack then
return
end
local meta = itemstack:get_meta()
local tool_id = meta:get_string("id")
local origin = minetest.string_to_pos(meta:get_string("origin"))
if not origin then
-- set origin to pos1
origin = vector.copy(pos1)
meta:set_string("origin", minetest.pos_to_string(origin))
end
local composition = compositions[tool_id]
local rel_pos1 = vector.subtract(pos1, origin)
local rel_pos2 = vector.subtract(pos2, origin)
track_min_max_pos(composition, rel_pos1)
track_min_max_pos(composition, rel_pos2)
-- search and remove exact pos1/2 matches
for i, entry in ipairs(composition.entries) do
if vector.equals(entry.pos1, rel_pos1) and vector.equals(entry.pos2, rel_pos2) then
-- remove matching entry
table.remove(composition.entries, i)
break
end
end
table.insert(composition.entries, {
type = "place",
pos1 = rel_pos1,
pos2 = rel_pos2,
rotation = rotation,
name = name,
id = id
})
meta:set_string("entries", #composition.entries)
meta:set_string("data", serialize_composition(composition))
set_current_composition_tool(playername, itemstack)
end

88
composition_tool.lua Normal file
View File

@ -0,0 +1,88 @@
local FORMSPEC_NAME = "pick_and_place:composition"
local function get_formspec(_, meta)
local name = minetest.formspec_escape(meta:get_string("name"))
local id = meta:get_string("id")
local origin = meta:get_string("origin")
local data = meta:get_string("data")
local bytes = #data
local entries = meta:get_int("entries")
local state = meta:get_string("state")
return [[
size[10,5]
real_coordinates[true]
label[0.1,0.5;Name]
field[2,0;6,1;name;;]] .. name .. [[]
button_exit[8,0;2,1;save;Save]
label[0.1,1.5;Origin]
label[2.1,1.5;]] .. (origin ~= "" and origin or "<not set>") .. [[]
button_exit[6,1;2,1;set_origin;Set origin]
button_exit[8,1;2,1;tp_origin;Teleport]
label[0.1,2.5;Stats]
label[2,2.5;]] .. "ID: " .. id .. " Entries: " .. entries .. " / " .. bytes .. " bytes" .. [[]
label[0.1,3.5;Status]
label[2,3.5;]] .. "Not active" .. [[]
label[0.1,4.5;Actions]
button_exit[2,4;2,1;]] .. (state == "record" and "pause;Pause" or "record;Record") .. [[]
button_exit[4,4;2,1;playback;Playback]
button_exit[6,4;2,1;mark_area;Mark Area]
button_exit[8,4;2,1;duplicate;Duplicate]
]]
end
minetest.register_tool("pick_and_place:composition", {
description = "Composition tool (new)",
inventory_image = "pick_and_place_composition.png",
stack_max = 1,
range = 0,
color = "#0000ff",
on_use = function(itemstack, player)
local meta = itemstack:get_meta()
pick_and_place.update_composition_tool(meta)
local playername = player:get_player_name()
minetest.show_formspec(playername, FORMSPEC_NAME, get_formspec(player, meta))
return itemstack
end
})
minetest.register_on_player_receive_fields(function(player, formname, fields)
if formname ~= FORMSPEC_NAME then
return false
end
local itemstack = player:get_wielded_item()
if itemstack:get_name() ~= "pick_and_place:composition" then
return true
end
local playername = player:get_player_name()
if fields.save then
pick_and_place.update_composition_fields(itemstack, playername, fields)
elseif fields.record then
pick_and_place.record_composition(itemstack, playername)
elseif fields.pause then
pick_and_place.pause_composition(itemstack, playername)
elseif fields.playback then
pick_and_place.play_composition(itemstack, playername)
elseif fields.set_origin then
pick_and_place.set_composition_origin(itemstack, playername)
elseif fields.tp_origin then
pick_and_place.tp_composition_origin(itemstack, playername)
elseif fields.mark_area then
pick_and_place.mark_composition_area(itemstack, playername)
elseif fields.duplicate then
pick_and_place.duplicate_composition_tool(itemstack, playername)
end
player:set_wielded_item(itemstack)
return true
end)

View File

@ -54,7 +54,7 @@ function pick_and_place.configure(pos1, pos2, name, id)
for _, cpos in ipairs(pick_and_place.get_outer_corners(pos1, pos2)) do
local node = minetest.get_node(cpos)
if node.name == "air" or node.name == "pick_and_place:handle" then
minetest.set_node(cpos, { name = "pick_and_place:handle" })
minetest.swap_node(cpos, { name = "pick_and_place:handle" })
local meta = minetest.get_meta(cpos)
-- relative positions

View File

@ -12,6 +12,7 @@ dofile(MP .. "/schematic_flip.lua")
dofile(MP .. "/schematic_orient.lua")
dofile(MP .. "/schematic_transpose.lua")
dofile(MP .. "/replacement.lua")
dofile(MP .. "/replacement_wallmounted.lua")
dofile(MP .. "/pointed.lua")
dofile(MP .. "/configure.lua")
dofile(MP .. "/remove.lua")
@ -23,9 +24,10 @@ dofile(MP .. "/create_tool.lua")
dofile(MP .. "/configure_tool.lua")
dofile(MP .. "/pick_tool.lua")
dofile(MP .. "/place_tool.lua")
dofile(MP .. "/composition_tool.lua")
dofile(MP .. "/composition.lua")
dofile(MP .. "/preview.lua")
dofile(MP .. "/craft.lua")
dofile(MP .. "/record.lua")
dofile(MP .. "/playback.lua")
dofile(MP .. "/registry.lua")
dofile(MP .. "/snap.lua")

View File

@ -32,7 +32,7 @@ minetest.register_tool("pick_and_place:place", {
if controls.aux1 then
-- removal
pick_and_place.remove_area(pos1, pos2)
pick_and_place.record_removal(pos1, pos2)
pick_and_place.record_removal(playername, pos1, pos2)
notify_change(pos1, pos2)
else
-- placement
@ -51,7 +51,7 @@ minetest.register_tool("pick_and_place:place", {
minetest.chat_send_player(playername, "Placement error: " .. msg)
else
if name ~= "" then
pick_and_place.record_placement(pos1, pos2, rotation, name, id)
pick_and_place.record_placement(playername, pos1, pos2, rotation, name, id)
end
notify_change(pos1, pos2)
end

View File

@ -9,16 +9,20 @@ local function playback(ctx)
ctx.i = ctx.i + 1
-- pick next entry
local entry = ctx.recording.entries[ctx.i]
local entry = ctx.composition.entries[ctx.i]
if not entry then
minetest.chat_send_player(ctx.playername, "pnp playback done with " .. (ctx.i-1) .. " entries")
minetest.chat_send_player(
ctx.playername, "composition playback done with " .. (ctx.i-1) .. " entries"
)
playback_active = false
return
end
if ctx.i % 10 == 0 then
-- status update
minetest.chat_send_player(ctx.playername, "pnp playback: entry " .. ctx.i .. "/" .. #ctx.recording.entries)
minetest.chat_send_player(
ctx.playername, "composition playback: entry " .. ctx.i .. "/" .. #ctx.composition.entries
)
end
if entry.type == "place" then
@ -38,7 +42,7 @@ local function playback(ctx)
local abs_pos1 = vector.add(ctx.origin, entry.pos1)
pick_and_place.deserialize(abs_pos1, schematic)
else
minetest.chat_send_player(ctx.playername, "pnp playback: template not found: '" .. entry.id .. "'")
minetest.chat_send_player(ctx.playername, "composition playback: template not found: '" .. entry.id .. "'")
end
elseif entry.type == "remove" then
local abs_pos1 = vector.add(ctx.origin, entry.pos1)
@ -51,7 +55,7 @@ local function playback(ctx)
minetest.after(0, playback, ctx)
end
function pick_and_place.start_playback(playername, origin, recording)
function pick_and_place.start_playback(playername, origin, composition)
if playback_active then
return false, "playback already running"
end
@ -59,7 +63,7 @@ function pick_and_place.start_playback(playername, origin, recording)
playback({
playername = playername,
origin = origin,
recording = recording,
composition = composition,
i = 0,
cache = {}
})

View File

@ -1,190 +0,0 @@
-- current state of recording
local state = false
-- current recording entry
local recording
-- current recording origin
local origin
local function reset_recording()
recording = {
entries = {}
}
end
reset_recording()
minetest.register_chatcommand("pnp_record_save", {
params = "[filename]",
description = "save the current recording to a file in the world-directory",
func = function(_, param)
local json = minetest.write_json(recording)
local filename = minetest.get_worldpath() .. "/" .. param .. ".json"
local f = io.open(filename, "w")
f:write(json)
f:close()
return true, "saved " .. #recording.entries .. " entries to '" .. filename .. "'"
end
})
minetest.register_chatcommand("pnp_record_load", {
params = "[filename]",
description = "loads a recording from a file in the world-directory",
func = function(_, param)
local filename = minetest.get_worldpath() .. "/" .. param .. ".json"
local f = io.open(filename, "r")
if not f then
return false, "file not found: '" .. filename .. "'"
end
recording = minetest.parse_json(f:read("*all"))
if not recording then
reset_recording()
return false, "could not parse file '" .. filename .. "'"
end
return true, "read " .. #recording.entries .. " entries from '" .. filename .. "'"
end
})
minetest.register_chatcommand("pnp_record", {
params = "[origin|start|info|pause|reset|play]",
description = "manages the recording state or plays the current recording",
func = function(name, param)
if param == "origin" then
if state then
return false, "origin can't be set while the recording is active"
end
-- set origin to current player pos
local player = minetest.get_player_by_name(name)
if not player then
return false, "player not found"
end
origin = vector.round(player:get_pos())
return true, "origin set to: " .. minetest.pos_to_string(origin)
end
if not origin then
return false, "origin not set, please use '/pnp_record origin' first"
end
if param == "start" then
state = true
return true, "recording started"
elseif param == "pause" then
state = false
return true, "recording paused"
elseif param == "reset" then
reset_recording()
return true, "recording reset"
elseif param == "play" then
return pick_and_place.start_playback(name, origin, recording)
else
local msg = "recording state: "
if state then
msg = msg .. "running"
else
msg = msg .. "stopped/paused"
end
msg = msg .. ", entries: " .. #recording.entries
msg = msg .. ", origin: "
if origin then
msg = msg .. minetest.pos_to_string(origin)
else
msg = msg .. "<not set>"
end
if recording.min_pos then
msg = msg .. ", min_pos: " .. minetest.pos_to_string(recording.min_pos)
end
if recording.max_pos then
msg = msg .. ", max_pos: " .. minetest.pos_to_string(recording.max_pos)
end
return true, msg
end
end
})
local function track_min_max_pos(pos)
if not recording.min_pos then
recording.min_pos = vector.copy(pos)
elseif pos.x < recording.min_pos.x then
recording.min_pos.x = pos.x
elseif pos.y < recording.min_pos.y then
recording.min_pos.y = pos.y
elseif pos.z < recording.min_pos.z then
recording.min_pos.z = pos.z
end
if not recording.max_pos then
recording.max_pos = vector.copy(pos)
elseif pos.x > recording.max_pos.x then
recording.max_pos.x = pos.x
elseif pos.y > recording.max_pos.y then
recording.max_pos.y = pos.y
elseif pos.z > recording.max_pos.z then
recording.max_pos.z = pos.z
end
end
function pick_and_place.record_removal(pos1, pos2)
if not state or not origin then
return
end
if #recording.entries == 0 then
return
end
local rel_pos1 = vector.subtract(pos1, origin)
local rel_pos2 = vector.subtract(pos2, origin)
track_min_max_pos(rel_pos1)
track_min_max_pos(rel_pos2)
-- search and remove exact pos1/2 matches
local entry_removed = false
for i, entry in ipairs(recording.entries) do
if vector.equals(entry.pos1, rel_pos1) and vector.equals(entry.pos2, rel_pos2) then
-- remove matching entry
table.remove(recording.entries, i)
entry_removed = true
break
end
end
if not entry_removed then
-- non-aligned removal, just record
table.insert(recording.entries, {
type = "remove",
pos1 = rel_pos1,
pos2 = rel_pos2
})
end
end
function pick_and_place.record_placement(pos1, pos2, rotation, name, id)
if not state or not origin then
return
end
local rel_pos1 = vector.subtract(pos1, origin)
local rel_pos2 = vector.subtract(pos2, origin)
track_min_max_pos(rel_pos1)
track_min_max_pos(rel_pos2)
table.insert(recording.entries, {
type = "place",
pos1 = rel_pos1,
pos2 = rel_pos2,
rotation = rotation,
name = name,
id = id
})
end

View File

@ -47,44 +47,3 @@ minetest.register_node("pick_and_place:replacement", {
end
end
})
function pick_and_place.get_replacement_nodeid(ctx, metadata)
local group = metadata.fields.group
local selected_name
if group and group ~= "" and ctx[group] then
-- group placement
selected_name = metadata.inventory.main[ctx[group]]
else
-- random placement
local replacement_names = {}
for _, name in ipairs(metadata.inventory.main) do
if name ~= "" then
table.insert(replacement_names, name)
end
end
if #replacement_names == 0 then
-- no replacement
return
end
local i = math.random(#replacement_names)
selected_name = replacement_names[i]
-- set group context
if group and group ~= "" then
ctx[group] = i
end
end
local stack = ItemStack(selected_name)
local nodename = stack:get_name()
if not minetest.registered_nodes[nodename] then
-- node not found
return
end
local nodeid = minetest.get_content_id(nodename)
return nodeid
end

View File

@ -0,0 +1,54 @@
local function update_formspec(meta)
local group = meta:get_string("group")
meta:set_string("formspec", [[
size[10,8.3]
real_coordinates[true]
field[0.1,0.4;8.8,0.8;group;Group;]] .. group .. [[]
button_exit[9,0.4;0.9,0.8;set;Set]
list[context;main;0.1,1.4;8,1;]
list[current_player;main;0.1,3;8,4;]
listring[]
]])
local txt = "Replacement node wallmounted"
if group and group ~= "" then
txt = txt .. " (group: '" .. group .. "')"
end
meta:set_string("infotext", txt)
end
minetest.register_node("pick_and_place:replacement_wallmounted", {
description = "Replacement node wallmounted",
tiles = {"pick_and_place.png^[colorize:#ff0000"},
drawtype = "signlike",
use_texture_alpha = "blend",
paramtype = "light",
paramtype2 = "wallmounted",
walkable = false,
climbable = true,
sunlight_propagates = true,
selection_box = {
type = "wallmounted"
},
groups = {
oddly_breakable_by_hand = 3
},
on_construct = function(pos)
local meta = minetest.get_meta(pos)
local inv = meta:get_inventory()
inv:set_size("main", 8)
update_formspec(meta)
end,
on_receive_fields = function(pos, _, fields)
if fields.set then
local meta = minetest.get_meta(pos)
meta:set_string("group", fields.group)
update_formspec(meta)
end
end
})

View File

@ -1,5 +1,8 @@
local air_cid = minetest.get_content_id("air")
local replacement_cid = minetest.get_content_id("pick_and_place:replacement")
local replacement_cids = {
[minetest.get_content_id("pick_and_place:replacement")] = true,
[minetest.get_content_id("pick_and_place:replacement_wallmounted")] = true
}
function pick_and_place.serialize(pos1, pos2)
local manip = minetest.get_voxel_manip()
@ -77,7 +80,7 @@ function pick_and_place.deserialize(pos1, schematic, disable_replacements)
local nodeid = schematic.node_id_data[j]
node_ids[nodeid] = true
if nodeid == replacement_cid and not disable_replacements then
if replacement_cids[nodeid] and not disable_replacements then
-- replacement placement
local abs_pos = {x=x, y=y, z=z}
local rel_pos = vector.subtract(abs_pos, pos1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB