mirror of
https://git.minetest.land/VoxeLibre/VoxeLibre.git
synced 2025-04-17 21:25:16 +02:00
563 lines
15 KiB
Lua
563 lines
15 KiB
Lua
local S, charmap, utf8 = ...
|
||
|
||
local function table_merge(t, ...)
|
||
local t2 = table.copy(t)
|
||
return table.update(t2, ...)
|
||
end
|
||
|
||
local SIGN_WIDTH = 115
|
||
|
||
local LINE_LENGTH = 15
|
||
local NUMBER_OF_LINES = 4
|
||
|
||
local LINE_HEIGHT = 14
|
||
local CHAR_WIDTH = 5
|
||
|
||
local SIGN_GLOW_INTENSITY = 14
|
||
|
||
local NEWLINE = {
|
||
[0x000A] = true,
|
||
[0x000B] = true,
|
||
[0x000C] = true,
|
||
-- U+000D (CR) is dropped on U-string conversion
|
||
[0x0085] = true,
|
||
[0x2028] = true,
|
||
[0x2029] = true,
|
||
}
|
||
|
||
local WHITESPACE = {
|
||
[0x0009] = true,
|
||
[0x0020] = true,
|
||
-- U+00A0 is a whitespace, but a non-breaking one
|
||
[0x1680] = true,
|
||
[0x2000] = true,
|
||
[0x2001] = true,
|
||
[0x2002] = true,
|
||
[0x2003] = true,
|
||
[0x2004] = true,
|
||
[0x2005] = true,
|
||
[0x2006] = true,
|
||
-- U+2007 is a whitespace, but a non-breaking one
|
||
[0x2008] = true,
|
||
[0x2009] = true,
|
||
[0x200A] = true,
|
||
-- U+202F is a whitespace, but a non-breaking one
|
||
[0x205F] = true,
|
||
[0x3000] = true,
|
||
}
|
||
|
||
local HYPHEN = {
|
||
[0x002D] = true,
|
||
[0x00AD] = true,
|
||
[0x058A] = true,
|
||
[0x05BE] = true,
|
||
[0x1806] = true,
|
||
[0x2010] = true,
|
||
-- U+2011 is a hyphen, but a non-breaking one
|
||
[0x2E17] = true,
|
||
[0x2E5D] = true,
|
||
[0x30FB] = true,
|
||
[0xFE63] = true,
|
||
[0xFF0D] = true,
|
||
[0xFF65] = true,
|
||
}
|
||
|
||
local CR_CODEPOINT = utf8.codepoint("\r") -- ignored
|
||
local WRAP_CODEPOINT = utf8.codepoint("‐") -- default, ellipsis for "truncate"
|
||
|
||
local DEFAULT_COLOR = "#000000"
|
||
local DYE_TO_COLOR = {
|
||
["white"] = "#d0d6d7",
|
||
["grey"] = "#818177",
|
||
["dark_grey"] = "#383c40",
|
||
["black"] = "#080a10",
|
||
["violet"] = "#6821a0",
|
||
["blue"] = "#2e3094",
|
||
["lightblue"] = "#258ec9",
|
||
["cyan"] = "#167b8c",
|
||
["dark_green"] = "#4b5e25",
|
||
["green"] = "#60ac19",
|
||
["yellow"] = "#f1b216",
|
||
["brown"] = "#633d20",
|
||
["orange"] = "#e26501",
|
||
["red"] = "#912222",
|
||
["magenta"] = "#ab31a2",
|
||
["pink"] = "#d56791",
|
||
}
|
||
|
||
local F = core.formspec_escape
|
||
|
||
-- Template definition
|
||
local sign_tpl = {
|
||
_tt_help = S("Can be written"),
|
||
_doc_items_longdesc = S("Signs can be written and come in two variants: Wall sign and sign on a sign post. Signs can be placed on the top and the sides of other blocks, but not below them."),
|
||
_doc_items_usagehelp = S("After placing the sign, you can write something on it. You have @1 lines of text with up to @2 characters for each line; anything beyond these limits is lost. Not all characters are supported. The text can be changed after it's written by rightclicking the sign. Can be colored and made to glow. Use bone meal to remove color and glow.", NUMBER_OF_LINES, LINE_LENGTH),
|
||
use_texture_alpha = "opaque",
|
||
sunlight_propagates = true,
|
||
walkable = false,
|
||
is_ground_content = false,
|
||
paramtype2 = "degrotate",
|
||
drawtype = "mesh",
|
||
mesh = "mcl_signs_sign.obj",
|
||
paramtype = "light",
|
||
selection_box = {
|
||
type = "fixed",
|
||
fixed = {-0.2, -0.5, -0.2, 0.2, 0.5, 0.2}
|
||
},
|
||
groups = {axey = 1, handy = 2, sign = 1, supported_node = 1, not_in_creative_inventory = 1},
|
||
stack_max = 16,
|
||
sounds = mcl_sounds.node_sound_wood_defaults(),
|
||
node_placement_prediction = "",
|
||
_mcl_sign_type = "standing"
|
||
}
|
||
|
||
-- Signs data / meta
|
||
local function normalize_rotation(rot)
|
||
return math.floor(0.5 + rot / 15) * 15
|
||
end
|
||
|
||
local function get_signdata(pos)
|
||
local node = core.get_node(pos)
|
||
local def = core.registered_nodes[node.name]
|
||
if not def or core.get_item_group(node.name, "sign") < 1 then return end
|
||
|
||
local meta = core.get_meta(pos)
|
||
local text = core.deserialize(meta:get_string("utext"), true) or {}
|
||
local color = meta:get_string("color")
|
||
if color == "" then
|
||
color = DEFAULT_COLOR
|
||
end
|
||
local glow = core.is_yes(meta:get_string("glow"))
|
||
|
||
local yaw, spos
|
||
local typ = "standing"
|
||
if def.paramtype2 == "wallmounted" then
|
||
typ = "wall"
|
||
local dir = core.wallmounted_to_dir(node.param2)
|
||
spos = vector.add(vector.offset(pos, 0, -0.25, 0), dir * 0.41)
|
||
yaw = core.dir_to_yaw(dir)
|
||
else
|
||
yaw = math.rad(((node.param2 * 1.5) + 1) % 360)
|
||
local dir = core.yaw_to_dir(yaw)
|
||
spos = vector.add(vector.offset(pos, 0, 0.08, 0), dir * -0.05)
|
||
end
|
||
|
||
return {
|
||
text = text,
|
||
color = color,
|
||
yaw = yaw,
|
||
node = node,
|
||
typ = typ,
|
||
glow = glow,
|
||
text_pos = spos,
|
||
}
|
||
end
|
||
|
||
local function set_signmeta(pos, tbl)
|
||
local meta = core.get_meta(pos)
|
||
if tbl.text then meta:set_string("utext", core.serialize(tbl.text)) end
|
||
if tbl.color then meta:set_string("color", tbl.color) end
|
||
if tbl.glow then meta:set_string("glow", tbl.glow) end
|
||
end
|
||
|
||
-- Text processing
|
||
local function string_to_ustring(str, max_characters)
|
||
-- limit saved text to 256 characters by default
|
||
-- (4 lines x 15 chars = 60 so this should be more than is ever needed)
|
||
max_characters = max_characters or 256
|
||
|
||
local ustr = {}
|
||
|
||
-- pcall wrapping to protect against invalid UTF-8
|
||
local iter = utf8.codes(str)
|
||
while true do
|
||
local success, i, code = pcall(iter)
|
||
if not success or not i or i >= max_characters
|
||
or code == CR_CODEPOINT then
|
||
break
|
||
end
|
||
table.insert(ustr, code)
|
||
end
|
||
|
||
return ustr
|
||
end
|
||
mcl_signs.string_to_ustring = string_to_ustring
|
||
|
||
local function ustring_to_string(ustr)
|
||
local str = ""
|
||
for _, code in ipairs(ustr) do
|
||
str = str .. utf8.char(code)
|
||
end
|
||
return str
|
||
end
|
||
mcl_signs.ustring_to_string = ustring_to_string
|
||
|
||
-- TODO: make shared code as table.slice()?
|
||
local function subseq(ustr, s, e)
|
||
local line = {}
|
||
for i = s, e do
|
||
line[#line+1] = ustr[i]
|
||
end
|
||
return line
|
||
end
|
||
|
||
local ustring_to_line_array
|
||
local wrap_mode = core.settings:get("mcl_signs_wrap_mode") or "word_wrap"
|
||
if wrap_mode == "word_break" then
|
||
function ustring_to_line_array(ustr)
|
||
local lines = {}
|
||
local line = {}
|
||
|
||
for _, code in ipairs(ustr) do
|
||
if #lines >= NUMBER_OF_LINES then break end
|
||
|
||
if NEWLINE[code]
|
||
or WHITESPACE[code] and #line >= (LINE_LENGTH - 1) then
|
||
table.insert(lines, line)
|
||
line = {}
|
||
elseif #line >= LINE_LENGTH then
|
||
table.insert(line, WRAP_CODEPOINT)
|
||
table.insert(lines, line)
|
||
line = {code}
|
||
else
|
||
table.insert(line, code)
|
||
end
|
||
end
|
||
if #line > 0 and #lines < NUMBER_OF_LINES then table.insert(lines, line) end
|
||
|
||
return lines
|
||
end
|
||
elseif wrap_mode == "word_wrap" then
|
||
function ustring_to_line_array(ustr)
|
||
local lines = {}
|
||
local start, stop = 1, 1
|
||
|
||
for cursor, code in ipairs(ustr) do
|
||
if #lines >= NUMBER_OF_LINES then break end
|
||
|
||
if WHITESPACE[code] or HYPHEN[code] then
|
||
stop = cursor
|
||
elseif NEWLINE[code] then
|
||
table.insert(lines, subseq(ustr, start, cursor - 1))
|
||
start, stop = cursor + 1, cursor + 1
|
||
elseif cursor - start + 1 >= LINE_LENGTH then
|
||
if stop <= start then -- forced break, no space in word
|
||
local line = subseq(ustr, start, cursor)
|
||
table.insert(line, WRAP_CODEPOINT)
|
||
table.insert(lines, line)
|
||
start, stop = cursor + 1, cursor + 1
|
||
else
|
||
table.insert(lines, subseq(ustr, start, stop + (HYPHEN[ustr[stop]] and 0 or -1)))
|
||
start, stop = stop + 1, stop + 1
|
||
end
|
||
end
|
||
end
|
||
if #lines < NUMBER_OF_LINES and start < #ustr then
|
||
table.insert(lines, subseq(ustr, start, #ustr))
|
||
end
|
||
|
||
return lines
|
||
end
|
||
elseif wrap_mode == "truncate" then
|
||
WRAP_CODEPOINT = utf8.codepoint("…")
|
||
function ustring_to_line_array(ustr)
|
||
local lines = {}
|
||
local line = {}
|
||
|
||
for _, code in ipairs(ustr) do
|
||
if #lines >= NUMBER_OF_LINES then break end
|
||
|
||
if NEWLINE[code] then
|
||
table.insert(lines, line)
|
||
line = {}
|
||
elseif #line == LINE_LENGTH then
|
||
table.insert(line, WRAP_CODEPOINT)
|
||
elseif #line < LINE_LENGTH then
|
||
table.insert(line, code)
|
||
end
|
||
end
|
||
if #line > 0 and #lines < NUMBER_OF_LINES then table.insert(lines, line) end
|
||
|
||
return lines
|
||
end
|
||
end
|
||
mcl_signs.ustring_to_line_array = ustring_to_line_array
|
||
|
||
local function generate_line(ustr, ypos)
|
||
local parsed = {}
|
||
local width = 0
|
||
local printed_char_width = CHAR_WIDTH + 1
|
||
|
||
for _, code in ipairs(ustr) do
|
||
local file = "_rc"
|
||
if charmap[code] then file = charmap[code] end
|
||
|
||
width = width + printed_char_width
|
||
table.insert(parsed, file)
|
||
end
|
||
|
||
width = width - 1
|
||
local texture = ""
|
||
local xpos = math.floor((SIGN_WIDTH - width) / 2) -- center with X offset
|
||
|
||
for _, file in ipairs(parsed) do
|
||
texture = texture .. ":" .. xpos .. "," .. ypos .. "=" .. file.. ".png"
|
||
xpos = xpos + printed_char_width
|
||
end
|
||
return texture
|
||
end
|
||
mcl_signs.generate_line = generate_line
|
||
|
||
local function generate_texture(data)
|
||
local lines = ustring_to_line_array(data.text)
|
||
local texture = "[combine:" .. SIGN_WIDTH .. "x" .. SIGN_WIDTH
|
||
local ypos = 0
|
||
local letter_color = data.color or DEFAULT_COLOR
|
||
|
||
for _, line in ipairs(lines) do
|
||
texture = texture .. generate_line(line, ypos)
|
||
ypos = ypos + LINE_HEIGHT
|
||
end
|
||
|
||
texture = "(" .. texture .. "^[multiply:" .. letter_color .. ")"
|
||
return texture
|
||
end
|
||
mcl_signs.generate_texture = generate_texture
|
||
|
||
-- Text entity handling
|
||
local function get_text_entity(pos, force_remove)
|
||
local objects = core.get_objects_inside_radius(pos, 0.5)
|
||
local text_entity
|
||
local i = 0
|
||
for _, v in pairs(objects) do
|
||
local ent = v:get_luaentity()
|
||
if ent and ent.name == "mcl_signs:text" then
|
||
i = i + 1
|
||
if i > 1 or force_remove == true then
|
||
v:remove()
|
||
else
|
||
text_entity = v
|
||
end
|
||
end
|
||
end
|
||
return text_entity
|
||
end
|
||
mcl_signs.get_text_entity = get_text_entity
|
||
|
||
-- Update the sign text entity (create if doesn't exist)
|
||
local function update_sign(pos)
|
||
local data = get_signdata(pos)
|
||
|
||
local text_entity = get_text_entity(pos)
|
||
if text_entity and not data then
|
||
text_entity:remove()
|
||
return false
|
||
elseif not data then
|
||
return false
|
||
elseif not text_entity then
|
||
text_entity = core.add_entity(data.text_pos, "mcl_signs:text")
|
||
if not text_entity or not text_entity:get_pos() then return end
|
||
end
|
||
|
||
text_entity:set_properties({
|
||
textures = {generate_texture(data)},
|
||
glow = data.glow and SIGN_GLOW_INTENSITY or 0,
|
||
})
|
||
text_entity:set_yaw(data.yaw)
|
||
text_entity:set_armor_groups({immortal = 1})
|
||
return true
|
||
end
|
||
mcl_signs.update_sign = update_sign
|
||
|
||
core.register_lbm({
|
||
name = "mcl_signs:restore_entities",
|
||
nodenames = {"group:sign"},
|
||
label = "Restore sign text",
|
||
run_at_every_load = true,
|
||
action = update_sign,
|
||
})
|
||
|
||
-- Text entity definition
|
||
core.register_entity("mcl_signs:text", {
|
||
initial_properties = {
|
||
pointable = false,
|
||
visual = "upright_sprite",
|
||
physical = false,
|
||
collide_with_objects = false,
|
||
},
|
||
on_activate = function(self)
|
||
local pos = self.object:get_pos()
|
||
update_sign(pos)
|
||
local props = self.object:get_properties()
|
||
local t = props and props.textures
|
||
if type(t) ~= "table" or #t == 0 then self.object:remove() end
|
||
end,
|
||
})
|
||
|
||
-- Formspec
|
||
local function show_formspec(player, pos, guest)
|
||
if not pos then return end
|
||
local meta = core.get_meta(pos)
|
||
local old_text = ustring_to_string(core.deserialize(meta:get_string("utext"), true) or {})
|
||
|
||
local fs
|
||
if guest then
|
||
fs = {
|
||
"size[6,2.3]textarea[0.25,0.25;6,1.5;;",
|
||
F(S("Sign text:")), ";", F(old_text), "]",
|
||
"button_exit[0,1.7;6,1;submit;", F(S("Close")), "]"
|
||
}
|
||
else
|
||
fs = {
|
||
"size[6,3]textarea[0.25,0.25;6,1.5;text;",
|
||
F(S("Enter sign text:")), ";", F(old_text), "]",
|
||
"label[0,1.5;",
|
||
F(S("Maximum line length: @1", LINE_LENGTH)), "\n",
|
||
F(S("Maximum lines: @1", NUMBER_OF_LINES)),
|
||
"]",
|
||
"button_exit[0,2.4;6,1;submit;", F(S("Done")), "]"
|
||
}
|
||
end
|
||
|
||
core.show_formspec(player:get_player_name(), "mcl_signs:set_text_"..pos.x.."_"..pos.y.."_"..pos.z, table.concat(fs))
|
||
end
|
||
mcl_signs.show_formspec = show_formspec
|
||
|
||
core.register_on_player_receive_fields(function(player, formname, fields)
|
||
if formname:find("mcl_signs:set_text_") == 1 then
|
||
local x, y, z = formname:match("mcl_signs:set_text_(.-)_(.-)_(.*)")
|
||
local pos = vector.new(tonumber(x), tonumber(y), tonumber(z))
|
||
if not fields or not fields.text then return end
|
||
if not mcl_util.check_position_protection(pos, player) then
|
||
local utext = string_to_ustring(fields.text)
|
||
set_signmeta(pos, {text = utext})
|
||
update_sign(pos)
|
||
end
|
||
end
|
||
end)
|
||
|
||
-- Node definition callbacks
|
||
function sign_tpl.on_place(itemstack, placer, pointed_thing)
|
||
local under = pointed_thing.under
|
||
do -- ensure the node we attach to can actually be attached to
|
||
local node = core.get_node(under)
|
||
local def = core.registered_nodes[node.name]
|
||
if def and def.buildable_to then return itemstack end
|
||
end
|
||
|
||
local new_stack = mcl_util.call_on_rightclick(itemstack, placer, pointed_thing)
|
||
if new_stack then
|
||
return new_stack
|
||
end
|
||
|
||
local above = pointed_thing.above
|
||
local dir = vector.subtract(under, above)
|
||
local wdir = core.dir_to_wallmounted(dir)
|
||
|
||
local itemstring = itemstack:get_name()
|
||
local def = itemstack:get_definition()
|
||
|
||
local pos
|
||
local placestack = ItemStack(itemstack)
|
||
if wdir < 1 then
|
||
-- no placement on ceilings allowed yet
|
||
return itemstack
|
||
elseif wdir == 1 then
|
||
placestack:set_name("mcl_signs:standing_sign_"..def._mcl_sign_wood)
|
||
-- param2 value is degrees / 1.5
|
||
local rot = normalize_rotation(placer:get_look_horizontal() * 180 / math.pi / 1.5)
|
||
itemstack, pos = core.item_place_node(placestack, placer, pointed_thing, rot)
|
||
else
|
||
placestack:set_name("mcl_signs:wall_sign_"..def._mcl_sign_wood)
|
||
itemstack, pos = core.item_place_node(placestack, placer, pointed_thing, wdir)
|
||
end
|
||
|
||
show_formspec(placer, pos)
|
||
-- restore canonical name as core.item_place_node might have changed it
|
||
itemstack:set_name(itemstring)
|
||
return itemstack
|
||
end
|
||
|
||
function sign_tpl.on_rightclick(pos, _, clicker, itemstack)
|
||
if core.is_protected(pos, clicker:get_player_name()) then
|
||
show_formspec(clicker, pos, true)
|
||
return itemstack
|
||
end
|
||
|
||
local iname = itemstack:get_name()
|
||
if iname == "mcl_mobitems:glow_ink_sac" then
|
||
local data = get_signdata(pos)
|
||
if data then
|
||
if data.color == "#000000" then
|
||
data.color = "#7e7e7e" -- black doesn't glow in the dark
|
||
end
|
||
set_signmeta(pos, {glow = "true", color = data.color})
|
||
update_sign(pos)
|
||
if not core.is_creative_enabled(clicker:get_player_name()) then
|
||
itemstack:take_item()
|
||
end
|
||
end
|
||
elseif iname == "mcl_bone_meal:bone_meal" then
|
||
set_signmeta(pos, {
|
||
glow = "false",
|
||
color = DEFAULT_COLOR,
|
||
})
|
||
update_sign(pos)
|
||
if not core.is_creative_enabled(clicker:get_player_name()) then
|
||
itemstack:take_item()
|
||
end
|
||
elseif iname:sub(1, 8) == "mcl_dye:" then
|
||
local dye = iname:sub(9)
|
||
set_signmeta(pos, {color = DYE_TO_COLOR[dye]})
|
||
update_sign(pos)
|
||
if not core.is_creative_enabled(clicker:get_player_name()) then
|
||
itemstack:take_item()
|
||
end
|
||
else
|
||
show_formspec(clicker, pos)
|
||
end
|
||
|
||
return itemstack
|
||
end
|
||
|
||
function sign_tpl.on_destruct(pos)
|
||
get_text_entity(pos, true)
|
||
end
|
||
|
||
-- TODO: reactivate when a good dyes API is finished
|
||
--function sign_tpl._on_dye_place(pos, color)
|
||
-- set_signmeta(pos, {
|
||
-- color = mcl_dyes.colors[color].rgb
|
||
-- })
|
||
-- mcl_signs.update_sign(pos)
|
||
--end
|
||
|
||
-- Wall sign definition
|
||
local sign_wall = table_merge(sign_tpl, {
|
||
mesh = "mcl_signs_signonwallmount.obj",
|
||
paramtype2 = "wallmounted",
|
||
selection_box = {
|
||
type = "wallmounted",
|
||
wall_side = {-0.5, -7/28, -0.5, -23/56, 7/28, 0.5}
|
||
},
|
||
groups = {axey = 1, handy = 2, sign = 1, supported_node_wallmounted = 1, deco_block = 1},
|
||
_mcl_sign_type = "wall",
|
||
})
|
||
|
||
local function colored_texture(texture, color)
|
||
return texture.."^[multiply:"..color
|
||
end
|
||
|
||
function mcl_signs.register_sign(name, color, def)
|
||
local newfields = {
|
||
tiles = {colored_texture("mcl_signs_sign_greyscale.png", color)},
|
||
inventory_image = colored_texture("mcl_signs_default_sign_greyscale.png", color),
|
||
wield_image = colored_texture("mcl_signs_default_sign_greyscale.png", color),
|
||
drop = "mcl_signs:wall_sign_"..name,
|
||
_mcl_sign_wood = name,
|
||
}
|
||
|
||
def = def or {}
|
||
core.register_node(":mcl_signs:standing_sign_"..name, table_merge(sign_tpl, newfields, def))
|
||
core.register_node(":mcl_signs:wall_sign_"..name, table_merge(sign_wall, newfields, def))
|
||
end
|