local S = minetest.get_translator("rp_crafting") -- Crafting menu display modes local MODE_CRAFTABLE = 1 -- crafting guide mode, show all recipes (default) local MODE_GUIDE = 2 -- craftable mode, only show recipes craftable from input slots local mod_creative = minetest.get_modpath("rp_creative") ~= nil -- -- API -- crafting = {} -- Callbacks crafting.callbacks = {} -- Array of registered craft recipes crafting.registered_crafts = {} -- User table of last selected row etc. local userdata = {} local userdata_init = { mode = MODE_CRAFTABLE } -- Crafting can only take a limited number of itemstacks as -- input for sanity/interface reasons crafting.MAX_INPUTS = 4 -- Unique ID for crafting recipes local max_craft_id = 0 -- Default crafting definition values local default_craftdef = { output = nil, items = {}, } function crafting.register_craft(def) if def.output == nil or def.output == "" then minetest.log("error", "[rp_crafting] No output for craft recipe, ignoring") return end local itemstack = ItemStack(def.output) local output = itemstack:to_string() max_craft_id = max_craft_id + 1 local itemkey = max_craft_id if not minetest.registered_items[itemstack:get_name()] then minetest.log("warning", "[rp_crafting] Trying to register craft #"..itemkey.." ('" .. output .. "') that has an unknown output item, allowing") end local craftdef = { output = itemstack, output_str = output, items = def.items or default_craftdef.items, } if #craftdef.items > crafting.MAX_INPUTS then minetest.log("warning", "[rp_crafting] Attempting to register craft #" .. itemkey .." ("..output..") with more than " .. crafting.MAX_INPUTS .. " inputs, allowing") end for i = 1, crafting.MAX_INPUTS do if craftdef.items[i] ~= nil then craftdef.items[i] = ItemStack(craftdef.items[i]) end end crafting.registered_crafts[itemkey] = craftdef minetest.log("info", "[rp_crafting] Registered craft #" .. itemkey .." for " .. output) return itemkey end -- Invalidades the cache that contains the list of currently craftable -- items, used in crafting guide to reduce calculation times. -- This function needs to be called when the input items -- have changed. local invalidate_craftable_cache = function(player) local pname = player:get_player_name() userdata[pname].craftable_cache = nil end -- Checks if the given crafting recipe (given by its craft definition) -- can be crafted from the input items of the inventory list 'craft_in' local function is_craftable_from_inventory(craftdef, inventory) local input_list = inventory:get_list("craft_in") for c=1, #craftdef.items do local name = craftdef.items[c]:get_name() if string.sub(name, 1, 6) == "group:" then local group = string.sub(name, 7) local gcount = craftdef.items[c]:get_count() if input_list == nil then return false end local count = 0 for i=1, #input_list do if minetest.get_item_group(input_list[i]:get_name(), group) ~= 0 then count = count + input_list[i]:get_count() end end if count < gcount then return false end elseif not inventory:contains_item("craft_in", craftdef.items[c]) then return false end end return true end -- Cache the crafting list for the crafting guide for a much faster -- loading time. -- The crafting guide list only needs to be generated once because -- it will never change. local all_crafts_cached function crafting.get_crafts(player_inventory, player_name) local results = {} local function get_filtered() for craft_id, craftdef in pairs(crafting.registered_crafts) do if is_craftable_from_inventory(craftdef, player_inventory) then table.insert(results, craft_id) end end end local function get_all() for craft_id, craft in pairs(crafting.registered_crafts) do local output_stack = ItemStack(craft.output) local name = output_stack:get_name() -- Hide items with the 'not_in_craft_guide' group when the crafting guide is active; -- These items are only craftable with the craft guide disabled. -- This is useful for secret crafting recipes. if minetest.get_item_group(name, "not_in_craft_guide") == 0 then table.insert(results, craft_id) end end end local function sort_crafts() local function sort_function(a, b) local a_item = crafting.registered_crafts[a].output local b_item = crafting.registered_crafts[b].output local a_itemn = a_item:get_name() local b_itemn = b_item:get_name() if a_itemn == b_itemn then return a_item:get_count() < b_item:get_count() else return a_itemn < b_itemn end end table.sort(results, sort_function) end if player_inventory == nil then if all_crafts_cached then results = all_crafts_cached else get_all() sort_crafts() all_crafts_cached = table.copy(results) end else get_filtered() sort_crafts() end return results end function crafting.register_on_craft(func) if not crafting.callbacks.on_craft then crafting.callbacks.on_craft = {} end table.insert(crafting.callbacks.on_craft, func) end function crafting.craft(player, wanted, wanted_count, output, items, craft_id) -- `output` can be any ItemStack value -- Duplicate items in `items` should work correctly if wanted:is_empty() then return nil end local craftdef = crafting.registered_crafts[craft_id] if craftdef == nil then minetest.log("error", "[rp_crafting] Tried to craft unknown recipe #"..craft_id) return nil end -- Check for validity local craft_count = wanted_count for i = 1, crafting.MAX_INPUTS do local required_itemstack = ItemStack(craftdef.items[i]) local itemc = 0 local group = string.match(required_itemstack:get_name(), "group:(.*)") if required_itemstack ~= nil and required_itemstack:get_count() ~= 0 then for j = 1, crafting.MAX_INPUTS do local input_itemstack = ItemStack(items[j]) if (group ~= nil and minetest.get_item_group(input_itemstack:get_name(), group) ~= 0) or (input_itemstack ~= nil and input_itemstack:get_name() == required_itemstack:get_name()) then itemc = itemc + input_itemstack:get_count() end end craft_count = math.min(craft_count, math.floor(itemc / required_itemstack:get_count())) if craft_count < 1 then return nil -- Not enough items to craft end end end -- Put stuff in output stack local free_space = wanted:get_stack_max() - output:get_count() if free_space < (craft_count * craftdef.output:get_count()) then craft_count = math.floor(free_space / craftdef.output:get_count()) end if craft_count < 1 then return nil -- Can't hold any output end output:add_item( ItemStack({ name = craftdef.output:get_name(), count = craftdef.output:get_count() * craft_count })) -- Iterate through second time to take items used for crafting local function remove_used_item(itemn, count) local items_required = count local group = string.match(itemn, "group:(.*)") for i = 1, crafting.MAX_INPUTS do local input_itemstack = ItemStack(items[i]) if (group ~= nil and minetest.get_item_group(input_itemstack:get_name(), group) ~= 0) or (items[i] ~= nil and input_itemstack:get_name() == itemn) then local items_left = items_required - input_itemstack:get_count() input_itemstack:take_item(items_required) if items_left > 0 then items_required = items_required - (items_required - items_left) else items[i] = input_itemstack:to_table() break end items[i] = input_itemstack:to_table() end end end for i = 1, crafting.MAX_INPUTS do local required_itemstack = ItemStack(craftdef.items[i]) if craftdef.items[i] ~= nil then remove_used_item(required_itemstack:get_name(), required_itemstack:get_count() * craft_count) end end for _, func in ipairs(crafting.callbacks.on_craft) do for i = 1, (craftdef.output:get_count() * craft_count) do func(output, player) end end return {items = items, output = output} end local form = rp_formspec.get_page("rp_formspec:2part") form = form .. rp_formspec.default.player_inventory form = form .. "container["..rp_formspec.default.start_point.x..","..rp_formspec.default.start_point.y.."]" form = form .. rp_formspec.get_itemslot_bg(0, 0, 1, 4) form = form .. "list[current_player;craft_in;0,0;1,4;]" form = form .. "listring[current_player;main]" form = form .. "listring[current_player;craft_in]" form = form .. "listring[current_player;main]" form = form .. "container_end[]" form = form .. "tablecolumns[text,align=left,width=2;text,align=left,width=40]" function crafting.get_formspec(name) if not userdata[name] then userdata[name] = table.copy(userdata_init) end local selected_craft_id = (userdata[name] and userdata[name].craft_id) or 1 if userdata[name] and userdata[name].old_craft_id then selected_craft_id = userdata[name].old_craft_id userdata[name].old_craft_id = nil end local inv = minetest.get_player_by_name(name):get_inventory() local craft_list = "" local craftitems if userdata[name] and userdata[name].mode == MODE_GUIDE then craftitems = crafting.get_crafts(nil, name) else craftitems = crafting.get_crafts(inv, name) end local selected_craftdef = nil local BUTTONS_WIDTH = 5 local BUTTONS_HEIGHT = 4 local craft_count = 0 local crx, cry = 0, 0 local selected = false local selected_element local btn_styles = "" -- In the craft guide, the list of craftable items is cached for -- the player so it doesn't have to be recalculated that often. local craftable_cache = userdata[name] and userdata[name].craftable_cache local cache_update_required if userdata[name] and userdata[name].mode == MODE_GUIDE and craftable_cache == nil then cache_update_required = true craftable_cache = {} userdata[name].craftable_cache = craftable_cache end for element_id, craft_id in ipairs(craftitems) do local itemstack = crafting.registered_crafts[craft_id].output local itemstring = itemstack:to_string() local itemname = itemstack:get_name() local itemdef = minetest.registered_items[itemname] local craftdef = crafting.registered_crafts[craft_id] local this_selected = false -- Check if this button will be the selected one if selected_craft_id then if craft_id == selected_craft_id then selected_craftdef = craftdef selected = true selected_element = element_id this_selected = true if userdata[name] ~= nil then userdata[name].craft_id = selected_craft_id end end end -- Check if this recipe is craftable with the current input. -- In MODE_CRAFTABLE, everything in the list is craftable so no extra check is needed local craftable if userdata[name].mode == MODE_CRAFTABLE then craftable = true elseif craftable_cache and not cache_update_required then craftable = craftable_cache[craft_id] == true else craftable = is_craftable_from_inventory(craftdef, inv) if cache_update_required and craftable then craftable_cache[craft_id] = true end end -- Button styling for non-selected button if not this_selected then if craftable then if minetest.get_item_group(itemname, "not_in_craft_guide") ~= 0 then -- Hidden in craft guide btn_styles = btn_styles .. "style[craft_select_"..craft_id..";bgimg=ui_button_crafting_secret_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..craft_id..":pressed;bgimg=ui_button_crafting_secret_active.png]" elseif userdata[name].mode == MODE_GUIDE then -- Craftable button (style explicitly needed in craft guide but otherwise not) btn_styles = btn_styles .. "style[craft_select_"..craft_id..";bgimg=ui_button_crafting_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..craft_id..":pressed;bgimg=ui_button_crafting_active.png]" end elseif userdata[name].mode == MODE_CRAFTABLE then -- Gray out uncraftable recipes btn_styles = btn_styles .. "style[craft_select_"..craft_id..";bgimg=ui_button_crafting_uncraftable_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..craft_id..":pressed;bgimg=ui_button_crafting_uncraftable_active.png]" end end if itemdef ~= nil then -- Add the craft recipe button local iib_item = itemname .. " " .. itemstack:get_count() -- Note: The buttons MUST be vertically spaced apart power of two. Otherwise, there will be an awkward Y pixel offset when scrolling -- due to floating-point rounding error. In this case, we space apart the buttons by exactly 1 on the Y axis via 'cry'. craft_list = craft_list .. "item_image_button["..(crx*1.1)..","..(cry)..";0.9,0.9;"..iib_item..";".."craft_select_"..craft_id..";]" crx = crx + 1 if crx >= BUTTONS_WIDTH then crx = 0 cry = cry + 1 end craft_count = craft_count + 1 end end -- Select the first entry if there is no selection if not selected and #craftitems > 0 then selected_craft_id = craftitems[1] selected_craftdef = crafting.registered_crafts[selected_craft_id] selected_element = 1 userdata[name].craft_id = selected_craft_id local craftdef = crafting.registered_crafts[selected_craft_id] local craftable = userdata[name].mode == MODE_CRAFTABLE or is_craftable_from_inventory(craftdef, inv) local itemname = craftdef.output:get_name() end -- Button style for selected button if selected_craft_id then local craftdef = crafting.registered_crafts[selected_craft_id] local craftable = userdata[name].mode == MODE_CRAFTABLE or is_craftable_from_inventory(craftdef, inv) local itemname = craftdef.output:get_name() if craftable then -- Highlight selected button if minetest.get_item_group(itemname, "not_in_craft_guide") ~= 0 then -- Hidden in craft guide btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..";bgimg=ui_button_crafting_secret_selected_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..":pressed;bgimg=ui_button_crafting_secret_selected_active.png]" else -- Normal craft recipe btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..";bgimg=ui_button_crafting_selected_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..":pressed;bgimg=ui_button_crafting_selected_active.png]" end elseif not craftable then -- Gray out uncraftable selected recipe btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..";bgimg=ui_button_crafting_uncraftable_selected_inactive.png]" btn_styles = btn_styles .. "style[craft_select_"..selected_craft_id..":pressed;bgimg=ui_button_crafting_uncraftable_selected_active.png]" end end local form = rp_formspec.get_page("rp_crafting:crafting") form = form .. "container["..rp_formspec.default.start_point.x..","..rp_formspec.default.start_point.y.."]" -- Crafting list if craft_count > 0 then -- Recipe selector if craft_count > BUTTONS_WIDTH*BUTTONS_HEIGHT then -- Render scrollbar if scrolling is neccessary local scrollmax = math.max(1, cry - (BUTTONS_HEIGHT-1)) if craft_count % BUTTONS_WIDTH == 0 then scrollmax = scrollmax - 1 end local scrollpos = (userdata[name] and userdata[name].scrollpos) if not scrollpos and selected_element then scrollpos = math.floor((selected_element-1) / BUTTONS_WIDTH) userdata[name].scrollpos = scrollpos end if not scrollpos then scrollpos = 0 userdata[name].scrollpos = scrollpos end form = form .. "scrollbaroptions[min=0;max="..scrollmax..";smallstep=1;largestep="..BUTTONS_HEIGHT.."]" form = form .. "scrollbar[6.7,0.25;0.3,3.95;vertical;craft_scroller;"..scrollpos.."]" end form = form .. "scroll_container[1.25,0.25;5.35,3.9;craft_scroller;vertical;1]" -- Default craft recipe button style if userdata[name].mode == MODE_GUIDE then -- The 'uncraftable' button style is default in craft guide since this is the more common one; -- this will reduce the amount of style[] elements considerably form = form .. "style_type[item_image_button;bgimg=ui_button_crafting_uncraftable_inactive.png;border=false;padding=2]" form = form .. "style_type[item_image_button:pressed;bgimg=ui_button_crafting_uncraftable_active.png;border=false;padding=2]" else -- Normal buton style otherwise form = form .. "style_type[item_image_button;bgimg=ui_button_crafting_inactive.png;border=false;padding=2]" form = form .. "style_type[item_image_button:pressed;bgimg=ui_button_crafting_active.png;border=false;padding=2]" end form = form .. btn_styles -- Craft recipe buttons form = form .. craft_list form = form .. "scroll_container_end[]" end if selected_craftdef ~= nil then local input_items = 0 -- Crafting input slots for i=1, crafting.MAX_INPUTS do local y = (i-1) * (1 + rp_formspec.default.list_spacing.y) if selected_craftdef.items[i] ~= nil then input_items = input_items + 1 form = form .. rp_formspec.fake_itemstack_any( 7.25, y, selected_craftdef.items[i], "craftex_in_"..i) end end -- Crafting buttons and output preview if selected_craftdef.output ~= nil then form = form .. rp_formspec.fake_itemstack_any( 8.95, 0, selected_craftdef.output, "craftex_out") if input_items >= 1 and input_items <= crafting.MAX_INPUTS then -- Arrow(s) pointing from input to output (a visual helper) form = form .. "image[8.35,0;0.5,4.45;ui_crafting_arrow_"..input_items..".png]" end -- Show crafting buttons only if something is selected form = form .. rp_formspec.image_button(8.95, 1.15, 1, 1, "do_craft_1", "ui_button_crafting_1.png", S("Craft once")) form = form .. rp_formspec.image_button(8.95, 2.3, 1, 1, "do_craft_10", "ui_button_crafting_10.png", S("Craft 10 times")) end end form = form .. "container_end[]" -- Crafting guide button local guide_icon, guide_tip, guide_pushed if userdata[name] and userdata[name].mode == MODE_GUIDE then guide_icon = "ui_icon_craftingguide_active.png" guide_tip = S("Show only craftable recipes") guide_pushed = true else guide_icon = "ui_icon_craftingguide.png" guide_tip = S("Show all recipes") guide_pushed = false end form = form .. rp_formspec.tab(rp_formspec.default.size.x, 0.5, "toggle_filter", guide_icon, guide_tip, "right", guide_pushed) return form end rp_formspec.register_page("rp_crafting:crafting", form) rp_formspec.register_invpage("rp_crafting:crafting", { get_formspec = crafting.get_formspec, _is_startpage = function(pname) if mod_creative and minetest.is_creative_enabled(pname) then return false else return true end end, }) rp_formspec.register_invtab("rp_crafting:crafting", { icon = "ui_icon_crafting.png", icon_active = "ui_icon_crafting_active.png", tooltip = S("Crafting"), }) local function clear_craft_slots(player) local inv = player:get_inventory() -- Move items out of input and output slots local items_moved = false local pos = player:get_pos() local lists = { "craft_in" } for l = 1, #lists do local list = lists[l] for i = 1, inv:get_size(list) do local itemstack = inv:get_stack(list, i) if not itemstack:is_empty() then if inv:room_for_item("main", itemstack) then inv:add_item("main", itemstack) else item_drop.drop_item(pos, itemstack) end itemstack:clear() inv:set_stack(list, i, itemstack) items_moved = true end end end if items_moved then invalidate_craftable_cache(player) rp_formspec.refresh_invpage(player, "rp_crafting:crafting") end end local function on_player_receive_fields(player, form_name, fields) local inv = player:get_inventory() local invpage = rp_formspec.get_current_invpage(player) if not (form_name == "" and invpage == "rp_crafting:crafting") then return end if fields.quit then clear_craft_slots(player) return end local name = player:get_player_name() if not userdata[name].craft_id then local craftitems = crafting.get_crafts(nil, name) if #craftitems > 0 then userdata[name].craft_id = craftitems[1] else userdata[name].craft_id = 1 end end if fields.craft_scroller then local evnt = minetest.explode_scrollbar_event(fields.craft_scroller) if evnt.type == "CHG" then userdata[name].scrollpos = evnt.value return end end local do_craft_1, do_craft_10 = false, false do_craft_1 = fields.do_craft_1 ~= nil do_craft_10 = fields.do_craft_10 ~= nil if do_craft_1 or do_craft_10 then local old_craft_id = nil if userdata[name] then old_craft_id = userdata[name].craft_id end local wanted_id = userdata[name].craft_id if not wanted_id then return end local wanted_itemstack = crafting.registered_crafts[wanted_id].output local output_itemstack local count = 1 if do_craft_1 then count = 1 elseif do_craft_10 then count = 10 else return end -- Do the craft local has_crafted = false repeat -- Repeat the craft count times or until materials or space run out output_itemstack = ItemStack("") local crafted = crafting.craft(player, wanted_itemstack, 1, output_itemstack, inv:get_list("craft_in"), wanted_id) if crafted then if inv:room_for_item("main", crafted.output) then -- Move result directly into the player inventory inv:add_item("main", crafted.output) local new_list = {} for i=1, #crafted.items do new_list[i] = ItemStack(crafted.items[i]) end inv:set_list("craft_in", new_list) has_crafted = true end else break end count = count - 1 until count < 1 if has_crafted then invalidate_craftable_cache(player) crafting.update_crafting_formspec(player, old_craft_id) return end elseif fields.toggle_filter then if userdata[name] and userdata[name].craft_id then local craft_id = userdata[name].craft_id userdata[name].old_craft_id = craft_id end if userdata[name].mode == MODE_GUIDE then userdata[name].mode = MODE_CRAFTABLE else userdata[name].mode = MODE_GUIDE end -- Invalidate scrollpos on mode switch to force a rescroll -- to that element userdata[name].scrollpos = nil else for k,v in pairs(fields) do if string.sub(k, 1, 13) == "craft_select_" then local id = tonumber(string.sub(k, 14)) if id then userdata[name].craft_id = id crafting.update_crafting_formspec(player, id) return end end end end rp_formspec.refresh_invpage(player, "rp_crafting:crafting") end function crafting.update_crafting_formspec(player, craft_id) local name = player:get_player_name() if craft_id then userdata[name].old_craft_id = craft_id end rp_formspec.refresh_invpage(player, "rp_crafting:crafting") end minetest.register_on_player_inventory_action(function(player, action, inventory, inventory_info) if action == "move" then if inventory_info.from_list == "craft_in" or inventory_info.to_list == "craft_in" then invalidate_craftable_cache(player) crafting.update_crafting_formspec(player) end elseif action == "put" or action == "take" then if inventory_info.listname == "craft_in" then invalidate_craftable_cache(player) crafting.update_crafting_formspec(player) end end end) local function on_joinplayer(player) local name = player:get_player_name() local inv = player:get_inventory() if not userdata then userdata[name] = table.copy(userdata_init) end if inv:get_size("craft_in") ~= 4 then inv:set_size("craft_in", 4) end clear_craft_slots(player) end local function on_leaveplayer(player) local name = player:get_player_name() userdata[name] = nil end if minetest.get_modpath("rp_drop_items_on_die") ~= nil then drop_items_on_die.register_listname("craft_in") end if minetest.settings:get_bool("rp_testing_enable", false) == true then -- Check if all input items of crafting recipes are known minetest.register_on_mods_loaded(function() for id, craftdef in pairs(crafting.registered_crafts) do for i=1, #craftdef.items do local iname = craftdef.items[i]:get_name() if string.sub(iname, 1, 6) ~= "group:" and not minetest.registered_items[iname] then minetest.log("error", "[rp_crafting] Unknown input item in craft '"..id.."': "..tostring(iname)) end end end end) end minetest.register_on_joinplayer(on_joinplayer) minetest.register_on_leaveplayer(on_leaveplayer) minetest.register_on_player_receive_fields(on_player_receive_fields)