Initial commit

master
Lars Mueller 2022-01-09 17:16:29 +01:00
commit 67961cad03
58 changed files with 2340 additions and 0 deletions

157
Readme.md Normal file
View File

@ -0,0 +1,157 @@
# Epidermis ![Logo](logo.png)
> the surface epithelium of the skin, overlying the dermis
The only ~~outer skin~~ epidermis mod you'll ever need.
## About
`epidermis` is a feature-fledged Minetest skin mod. Requires at least Minetest 5.4 for the server and at least 5.3 (dynamic media support) for the client. All code written by [appgurueu](github.com/appgurueu/) and licensed under the MIT license. Media by appgurueu and Dragoni as credited below, all licensed under CC BY-SA 3.0.
## Credits
The following tool textures (within in the `textures/tools` folder) have been created by Dragoni and are licensed under CC BY-SA 3.0:
* `epidermis_book.png`
* `epidermis_eraser.png`
* `epidermis_filling_bucket.png`
* `epidermis_filling_paint.png`
* `epidermis_palette.png`
* `epidermis_pen_handle.png`
* `epidermis_pen_tip.png`
* `epidermis_undo_redo.png`
`logo.png` in the root folder was also created by Dragoni and is licensed under CC BY-SA 3.0 as well. Everything else was created by appgurueu.
## Features
* Per-player skins
* Just drop them in `<worldpath>/data/epidermis/textures/players/epidermis_player_<playername>.png`
* 3D Epidermis painting
* Model- and texture-agnostic. Full B3D and PNG support.
* HSV & RGB colorpickers, named color support
* Arbitrary rotation & backface culling support
* [SkinDB](http://minetest.fensta.bplaced.net/) support
* Real-time syncing with SkinDB (uploaded textures immediately become usable without a restart); no external scripts required
* Picking SkinDB skins for yourself or as Epidermis base textures
* Upload to SkinDB
## Comparison
### 2D Texture Painting Mods
* [Painted 3D armor](https://content.minetest.net/packages/Beerholder/painted_3d_armor/): A mod supporting paintings on armor. Painting still happens in 2D space and is rather limited through the use of texture modifiers; a rather old mod.
* [skinmaker](https://github.com/GreenXenith/skinmaker), a well-done mod limited to the scope of 2-dimensional creation of skins in-game using only texture modifiers. Good support for older MT versions without dynamic media, not entirely texture- and model-agnostic. Experimental.
### Clothing Mods
* [Clothing 2](https://content.minetest.net/packages/SFENCE/clothing/): Adds wearable clothing items
### Skin Mods
* [NodeCore Skins](https://content.minetest.net/packages/Warr1024/nc_skins/): NodeCore-only, Multiplayer-focused mod providing a single fixed skin per player through the file system
* [Wardrobe](https://content.minetest.net/packages/AntumDeluge/wardrobe_ad/) and [Wardrobe Outfits](https://content.minetest.net/packages/AntumDeluge/wardrobe_outfits/): A few "selected" skins; the former provides an API for other mods to register more
* [Simple Skins](https://content.minetest.net/packages/TenPlus1/simple_skins/): A different set of available skins, excellent support for ancient MT versions
* [SkinsDB](https://content.minetest.net/packages/bell07/skinsdb/) and [SkinsDB for Hades Revisited](https://content.minetest.net/packages/SFENCE/hades_skinsdb/): Proper SkinDB support using an update command which shuts down the server, support for user-added skins, decent skin selection dialog including a search feature
Epidermis beats all currently available Skin Mods through better SkinDB support (including **uploading**) and is the first mod to provide 3-dimensional skin painting (which may however not be considered generally superior to 2-dimensional painting).
## Engine Limitations
### Memory Usage
You can expect each active entity to consume memory proportional to the texture pixel count. Skins sized 64x32 should stay in the kilobyte range. There is however a [clientside memory leak](https://github.com/minetest/minetest/issues/11531) which causes textures to not be dropped from texture cache. This means that every time the texture is changed, the client will store it in memory until the session ends. For 64x32, roughly 8 KB will be stored per update/action. That means a thousand actions will roughly take 8 MB; a million actions would take 8 GB. **Therefore, it is not recommended to try using higher resolution textures, even though they are perfectly supported by the mod.**
### Disk Usage
The dynamic media API allows marking media as `ephemeral`, which means it isn't cached clientside *and* not sent to new clients. Unfortunately this means that joining players don't receive the media, which would result in undefined behavior. Therefore, this fills up client & server disk space in it's current form. Server disk space is automatically cleared on startup; client cache must be cleared manually.
## Mod Limitations
### [`wield3d`](https://github.com/stujones11/wield3d)
Does not display the colors of wielded items.
## Hints
If you want to be able to accurately paint, don't use cinematic camera smoothing or view bobbing. Both will make your look direction inaccurate in certain cases. Alternatively to disabling view bobbing, rest while painting (and use the newest Minetest version).
As you might have noticed, there is no kind of palette. That is no issue however: Simply abuse a second entity (or a portion of the epidermis) as palette.
## Instructions
The in-game guide item contains these instructions as well.
### Tools
#### Guide
The in-game guide provides instructions for these tools.
#### Spawners
##### Paintable spawner
Spawns a paintable epidermis with your current texture.
##### HSV colorpicker spawner
Spawns a "wallmounted" HSV colorpicker.
#### Painting Tools
Tools which work much like those found in common painting programs.
Pen, line, rectangle and filling bucket all require a color. There are three ways to pick a color:
* You can pick a color from the paintable epidermis by right-clicking it.
* You can open a RGB color picker dialog by right clicking while pointing at nothing.
* You can spawn a HSV color picker in-world by placing it against a node. Right-click to pick a color, punch the hue to change the hue of the saturation & value field.
##### Pen
The pen is the most basic tool. It is used to place single pixels (left-click).
##### Line
The line tool draws, duh, a line. Use it by "dragging": keep the left mouse button down. You will be shown a preview. Dragging stops when you change your wield item or point at a different entity.
##### Rectangle
Works like the line tool but draws a filled rectangle.
##### Filling Bucket
Floodfills adjacent pixels of exactly the same color, swapping out their color for the color of the filling bucket.
##### Undo-redo
Left-click to undo, right-click to redo. Undo-redo log size is limited due to [Memory Usage] constraints.
## Configuration
<!--modlib:conf:2-->
### `skindb`
#### `autosync`
Automatically sync with SkinDB at startup, continue syncing during game
* Type: boolean
* Default: `true`
<!--modlib:conf-->
## Possible future features
- [ ] 3D armor support
- [ ] Restart server if a certain amount of dynamic texture data has been reached (100 MB?)
- [ ] Paintable transportability (as items?) & trashability
- [ ] Better icons (play button for animation?)
- [ ] Skinmaker support to add 2-dimensional texture painting
- [ ] Semi-transparency painting support
- Pointless as long as Minetest doesn't properly support semitransparency for CAOs
- [ ] Survival mode
- [ ] Obtaining paintable epidermi through skinning
- [ ] Dye rewrite with color mixing and limited color supply
- [ ] SkinDB replacement server

BIN
character_with_normals.b3d Normal file

Binary file not shown.

Binary file not shown.

163
colorpicker_hsv_ingame.lua Normal file
View File

@ -0,0 +1,163 @@
local bar_size = 1/16
local bar_width = bar_size * 256 --[[px]]
assert(bar_width % 1 == 0)
local c_comp = { "r", "g", "g", "b", "b", "r" }
local x_comp = { "g", "r", "b", "g", "r", "b" }
local function get_gradient_texture(hue)
hue = hue * 6
local idx = 1 + math.floor(hue)
local mul_color = { r = 0, g = 0, b = 0 }
mul_color[c_comp[idx]] = 255
mul_color[x_comp[idx]] = math.floor(255 * (1 - math.abs(hue % 2 - 1)) + 0.5)
return ("epidermis_gradient_field_chroma.png^[multiply:%s^epidermis_gradient_field_m.png"):format(
modlib.minetest.colorspec.new(mul_color):to_string()
)
end
local function get_texture(hue)
return ("[combine:%dx256:0,0="):format(256 + bar_width)
.. get_gradient_texture(hue):gsub("[\\^:]", function(char) return "\\" .. char end) -- escape
.. ([[:256,0=epidermis_gradient_hue.png\^[transformR270\^[resize\:%dx256]]):format(bar_width)
end
local function get_uv(self, user)
if not (user and user:is_player()) then
return
end
local direction = modlib.vector.from_minetest(user:get_look_dir())
local rotation = self.object:get_rotation()
local rotation_axis, rotation_angle = epidermis.vector_axis_angle(rotation)
local normal = modlib.vector.new({ 0, 0, -1 }):rotate3(rotation_axis, rotation_angle)
if vector.dot(direction, normal) < 0 then
return -- Backface
end
local pos = self.object:get_pos()
local visual_size = modlib.vector.from_minetest(self.object:get_properties().visual_size)
local parent, _, position, _ = self.object:get_attach()
if parent then
pos = modlib.vector.from_minetest(vector.add(parent:get_pos(), vector.divide(position, 10)))
visual_size = visual_size * modlib.vector.from_minetest(parent:get_properties().visual_size)
end
local relative = modlib.vector.from_minetest(vector.subtract(moblib.get_eye_pos(user), pos))
local function transform(vertex)
return modlib.vector.rotate3(modlib.vector.multiply(vertex, visual_size), rotation_axis, rotation_angle)
end
local pos_on_ray, u, v = modlib.vector.ray_parallelogram_intersection(relative, direction, {
transform{ -0.5, -0.5, 0.5 },
transform{ 0.5, -0.5, 0.5 },
transform{ -0.5, 0.5, 0.5 },
})
if pos_on_ray then
return u, v
end
end
local function vector_combine(v, w, func)
return vector.new(
func(v.x, w.x),
func(v.y, w.y),
func(v.z, w.z)
)
end
local function rotate_boxes(self)
local collisionbox = self.initial_properties.collisionbox
local rotation = self.object:get_rotation()
local min, max = vector.new(math.huge, math.huge, math.huge), vector.new(-math.huge, -math.huge, -math.huge)
for index_x = 1, 1 + 3, 3 do
for index_y = 2, 2 + 3, 3 do
for index_z = 3, 3 + 3, 3 do
local pos = vector.rotate(vector.new(collisionbox[index_x], collisionbox[index_y], collisionbox[index_z]), rotation)
min = vector_combine(min, pos, math.min)
max = vector_combine(max, pos, math.max)
end
end
end
local box = {min.x, min.y, min.z, max.x, max.y, max.z}
self.object:set_properties{
collisionbox = box,
selectionbox = box
}
end
local colorpicker = {}
local thickness = 0.01
colorpicker._thickness = thickness
local height = 1 / (1 + bar_size)
local box = { -0.5, -height/2, -thickness/2, 0.5, height/2, thickness/2 }
colorpicker.initial_properties = {
visual = "cube",
visual_size = vector.new(1, height, thickness),
selectionbox = box,
collisionbox = box,
static_save = true,
physical = true,
infotext = "HSV colorpicker",
}
colorpicker.lua_properties = {
staticdata = "lua"
}
function colorpicker:_set_hue(hue)
self._.hue = hue
self.object:set_properties({
textures = {
"epxb.png",
"epxb.png",
"epxb.png",
"epxb.png",
get_texture(hue),
"epxb.png",
},
})
end
function colorpicker:_set_rotation(rotation)
self.object:set_rotation(rotation)
rotate_boxes(self)
self._.rotation = rotation
end
function colorpicker:on_activate()
self:_set_hue(self._.hue or math.random())
self:_set_rotation(self._.rotation or vector.new(0, 0, 0))
local object = self.object
object:set_acceleration(vector.new(0, -0.981, 0))
object:set_armor_groups({ immortal = 1 })
end
function colorpicker:_get_color(user)
local u, v = get_uv(self, user)
if not u then
return
end
local hue = self._.hue
local saturation, value = 1 - v, (1 - u) * (1 + bar_size)
if value > 1 then -- hue bar
hue = saturation
saturation, value = 1, 1
end
return modlib.minetest.colorspec.from_hsv(hue, saturation, value)
end
function colorpicker:on_punch(puncher)
local u, v = get_uv(self, puncher)
if u then
local hue, value = 1 - v, (1 - u) * (1 + bar_size)
if value > 1 then -- hue bar
self:_set_hue(hue)
return
end
end
local inventory = puncher:get_inventory()
if not inventory:room_for_item("main", "epidermis:spawner_colorpicker") then
return
end
self.object:remove()
inventory:add_item("main", "epidermis:spawner_colorpicker")
end
moblib.register_entity("epidermis:colorpicker", colorpicker)

View File

@ -0,0 +1,85 @@
local function get_gradient_texture(component, color)
local old_value = color[component]
color[component] = 255
local texture = ("epxw.png^[multiply:%s^[resize:256x1^[mask:epidermis_gradient_%s.png"):format(color:to_string(), component)
color[component] = old_value
return texture
end
function epidermis.show_colorpicker_formspec(player, color, callback)
local function show_colorpicker_formspec()
local fs = {
"size[8.5,5.25,false]",
"real_coordinates[true]",
"scrollbaroptions[min=0;max=255;smallstep=1;largestep=25;thumbsize=1;arrows=show]",
"label[0.25,0.5;Pick a color:]",
("image[3,0.25;0.5,0.5;epxw.png^[multiply:%s]"):format(color:to_string()),
("field[3.5,0.25;2,0.5;color;;%s]"):format(color:to_string()),
"field_close_on_enter[color;false]",
("image_button[5,0.25;0.5,0.5;%s;random;]"):format(minetest.formspec_escape(epidermis.textures.dice)),
"tooltip[random;Random color]",
"image_button_exit[7.25,0.25;0.5,0.5;epidermis_check.png;set;]",
"tooltip[set;Set color]",
"image_button_exit[7.75,0.25;0.5,0.5;epidermis_cross.png;cancel;]",
"tooltip[cancel;Cancel]",
}
for index, component in ipairs{"Red", "Green", "Blue"} do
local component_short = component:sub(1, 1):lower()
local y = 0.25 + index * 1.25
table.insert(fs, ("scrollbar[0.25,%f;8,0.5;horizontal;%s;%d]"):format(y, component_short, color[component_short]))
table.insert(fs, ("label[0.25,%f;%s]"):format(y + 0.75, minetest.colorize(("#%06X"):format(0xFF * 0x100 ^ (3 - index)), component:sub(1, 1))))
table.insert(fs, ("image[0.75,%f;6.5,0.5;%s]"):format(y + 0.5, get_gradient_texture(component_short, color)))
table.insert(fs, ("field[7.25,%f;1,0.5;field_%s;;%s]"):format(y + 0.5, component_short, color[component_short]))
table.insert(fs, ("field_close_on_enter[field_%s;false]"):format(component_short))
end
epidermis.show_formspec(player, table.concat(fs), function(fields)
if fields.random then
color = modlib.minetest.colorspec.new{
r = math.random(0, 255),
g = math.random(0, 255),
b = math.random(0, 255)
}
show_colorpicker_formspec()
return
end
if fields.quit then
if fields.set or fields.key_enter then
callback(color)
return
end
callback()
return
end
local key_enter_field = fields.key_enter_field
local value = fields[key_enter_field]
if key_enter_field and value then
if key_enter_field == "color" then
local new_color = modlib.minetest.colorspec.from_string(value)
if not new_color then return end -- invalid colorstring
new_color = new_color or color
new_color.a = 255 -- HACK the colorpicker doesn't support alpha
color = new_color
show_colorpicker_formspec()
return
end
local short_component = ({field_r = "r", field_g = "g", field_b = "b"})[key_enter_field]
if not short_component then return end
if not value:match"^%d+$" then return end
color[short_component] = math.min(tonumber(value), 255)
show_colorpicker_formspec()
return
end
for _, short_component in pairs{"r", "g", "b"} do
if fields[short_component] then
local field = minetest.explode_scrollbar_event(fields[short_component])
if field.type == "CHG" then
color[short_component] = math.max(0, math.min(field.value, 255))
show_colorpicker_formspec()
return
end
end
end
end)
end
show_colorpicker_formspec()
end

48
dynamic_add_media.lua Normal file
View File

@ -0,0 +1,48 @@
local media_paths = epidermis.media_paths
-- TODO keep count of total added media, force-kick players after their RAM is too full, restart after server disk is too full
function epidermis.dynamic_add_media(path, on_all_received, ephemeral)
local filename = modlib.file.get_name(path)
local existing_path = media_paths[filename]
if existing_path == path then
-- May occur when players & epidermi share a texture or when an epidermis is activated multiple times
-- Also occurs when SkinDB deletions happen and is required for expected behavior
on_all_received()
return
end
assert(not existing_path)
assert(modlib.file.exists(path))
local to_receive = {}
for player in modlib.minetest.connected_players() do
local name = player:get_player_name()
if minetest.get_player_information(name).protocol_version < 39 then
minetest.kick_player(name, "Your Minetest client is outdated (< 5.3) and can't receive dynamic media. Rejoin to get the added media.")
else
to_receive[name] = true
end
end
local arg = path
if minetest.features.dynamic_add_media_table then
arg = {path = path}
if minetest.is_singleplayer() then
arg.ephemeral = true
else
arg.ephemeral = ephemeral
end
end
if not next(to_receive) then
minetest.dynamic_add_media(arg, error)
on_all_received()
return
end
minetest.dynamic_add_media(arg, function(name)
if name == nil then
on_all_received()
return
end
assert(to_receive[name])
to_receive[name] = nil
if not next(to_receive) then
on_all_received()
end
end)
end

39
formspec.lua Normal file
View File

@ -0,0 +1,39 @@
-- TODO FS building utils
local formspecs = {}
local id = 1
minetest.register_on_leaveplayer(function(player)
formspecs[player:get_player_name()] = nil
end)
minetest.register_on_player_receive_fields(function(player, formname, fields)
local player_name = player:get_player_name()
local formspec = formspecs[player_name]
if formname ~= (formspec or {}).name then return end
if fields.quit then
formspecs[player_name] = nil
end
formspec.handler(fields)
return true -- don't call remaining functions
end)
function epidermis.show_formspec(player, formspec, handler)
local player_name = player:get_player_name()
local formspec_name = "epidermis:" .. id
formspecs[player_name] = {
name = formspec_name,
handler = handler or modlib.func.no_op,
}
id = id + 1
if id > 2^50 then id = 1 end
-- See https://github.com/minetest/minetest/issues/11907: Formspecs must not use exit buttons if there are to be following stages
minetest.show_formspec(player_name, formspec_name, formspec)
end
function epidermis.close_formspec(player)
local player_name = player:get_player_name()
local formspec = assert(formspecs[player_name])
formspecs[player_name] = nil
minetest.close_formspec(player_name, formspec.name)
end

150
help.lua Normal file
View File

@ -0,0 +1,150 @@
local tags = setmetatable({}, {
__index = function(_, tag_name)
return function(table)
table[true] = tag_name
return table
end
end,
})
local function item_(name, title, ...)
return {
tags.itemtitle{
tags.item{
name = name,
float = "left",
width = 64,
height = 64,
},
title,
},
"\n",
table.concat({ ... }, "\n"), -- description
"\n",
}
end
local help = {
tags.tag{ name = "itemtitle", size = 18 },
tags.tag{ name = "code", font = "mono", color = "lightgreen" },
{
tags.itemtitle{
tags.item{
name = "epidermis:guide",
float = "left",
width = 64,
height = 64,
},
"Guide",
},
"\n",
"This guide. Can also be opened using ", -- description
tags.code{"/epidermis_guide"}, ".",
"\n",
},
item_(
"epidermis:spawner_paintable",
"Epidermis Spawner",
"Spawns a paintable epidermis that copies your skin. Use your bare hands on the paintable:",
"- Left-click (punch) to swap skins",
"- Right-click (interact) to open the control panel, which allows toggling backface culling, changing rotation, previewing the texture, playing the animation, picking a texture from and uploading to SkinDB"
),
item_(
"epidermis:spawner_colorpicker",
"HSV Colorpicker Spawner",
"Spawns a HSV color picker if a node is pointed. The colorpicker is oriented as if it were wallmounted.",
"Punch the colorpicker's hue bar to select a hue."
),
item_(
"epidermis:undo_redo",
"Undo / redo",
"Left-click to undo the last action, right-click to redo undone actions. Only a limited amount of actions can be undone / redone."
),
item_(
"epidermis:eraser",
"Eraser",
"Left-click to mark a pixel as transparent, right-click to restore opacity of the first transparent pixel above the pointed pixel."
),
tags.b({
"The painting tools below support right-clicking an epidermis or HSV color picker to choose a color. If nothing is pointed, you will be shown a RGB color picker.",
}),
"\n",
item_("epidermis:pen", "Pen", "Left-click to set a single pixel."),
item_("epidermis:filling_bucket", "Filling Bucket", "Left-click to fill pixels of (exactly) the same color on the texture."),
item_("epidermis:line", "Line", "Drag to draw a line. The line is drawn on the texture, not the model."),
item_(
"epidermis:rectangle",
"Rectangle",
"Drag to draw a rectangle. The rectangle is drawn on the texture, not the model."
),
}
local rope = {}
local function write(text)
return table.insert(rope, text)
end
local function write_element(element)
local tag_name = element[true]
if tag_name then
write("<")
write(tag_name)
for k, v in pairs(element) do
if type(k) == "string" then
write(" ")
write(k)
write("=")
write(v)
end
end
write(">")
end
if tag_name == "item" or tag_name == "img" or tag_name == "tag" then
assert(#element == 0)
-- Self-enclosing tags
return
end
for _, child in ipairs(element) do
if type(child) == "string" then
write(child:gsub(".", { ["\\"] = [[\\]], ["<"] = [[\<]] }))
else
write_element(child)
end
end
if tag_name then
write("</")
write(tag_name)
write(">")
end
end
write_element(help)
local text = minetest.formspec_escape(table.concat(rope))
local formspec = ([[
size[8.5,5.25,false]
real_coordinates[true]
image_button_exit[7.75,0.25;0.5,0.5;epidermis_cross.png;close;]
tooltip[close;Close]
hypertext[0.25,0.25;7.5,4.75;help;<big><b>Epidermis Guide</b></big>]
hypertext[0.25,0.75;8,4.25;help;%s]]):format(text)
function epidermis.show_guide_formspec(player)
minetest.show_formspec(player:get_player_name(), "epidermis:guide", formspec)
end
minetest.register_chatcommand("epidermis_guide", {
description = "Open the Epidermis Guide",
params = "",
func = function(name)
local player = minetest.get_player_by_name(name)
if not player then
return false, "Command only available to players"
end
epidermis.show_guide_formspec(player)
end
})
minetest.register_tool("epidermis:guide", {
description = "Epidermis Guide",
inventory_image = "epidermis_book.png",
on_use = function(_, user)
epidermis.show_guide_formspec(user)
end,
})

18
init.lua Normal file
View File

@ -0,0 +1,18 @@
epidermis = {}
epidermis.conf = modlib.mod.configuration()
local include = modlib.mod.include
include"misc.lua"
include"media_paths.lua"
include"dynamic_add_media.lua"
include"persistence.lua"
include"theme.lua"
include"send_notification.lua"
include"formspec.lua"
include"colorpicker_rgb_formspec.lua"
include"colorpicker_hsv_ingame.lua"
local http = assert(minetest.request_http_api(), "add epidermis to secure.http_trusted_mods")
assert(loadfile(modlib.mod.get_resource("skindb.lua")))(http)
include"skin.lua"
include"paintable.lua"
include"tools.lua"
include"help.lua"

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

17
media_paths.lua Normal file
View File

@ -0,0 +1,17 @@
local media_paths = modlib.minetest.media.paths
local dynamic_media_paths = {}
-- HACK override dynamic_add_media to capture the paths
-- Too invasive to be part of modlib
local dynamic_add_media = minetest.dynamic_add_media
function minetest.dynamic_add_media(options_or_filepath, callback)
local filepath = options_or_filepath
if type(filepath) ~= "string" then
filepath = assert(options_or_filepath.filepath)
end
dynamic_media_paths[assert(modlib.file.get_name(filepath))] = filepath
return dynamic_add_media(options_or_filepath, callback)
end
setmetatable(media_paths, {__index = dynamic_media_paths})
epidermis.media_paths = media_paths

9
misc.lua Normal file
View File

@ -0,0 +1,9 @@
function epidermis.vector_axis_angle(euler_rotation)
return modlib.quaternion.to_axis_angle(modlib.quaternion.from_euler_rotation(vector.multiply(euler_rotation, -1)))
end
function epidermis.on_cheat(player, cheat)
local name = player:get_player_name()
minetest.log("warning", "Kicked " .. name .. " for cheating: " .. modlib.json:write_string(cheat))
minetest.kick_player(name, "Kicked for cheating")
end

6
mod.conf Normal file
View File

@ -0,0 +1,6 @@
name = epidermis
description = Feature-fledged skin (painting) mod
depends = modlib, moblib, player_api
author = appguru(eu)
license = MIT
min_minetest_version = 5.4

57
models.lua Normal file
View File

@ -0,0 +1,57 @@
local mlvec = modlib.vector
local media_paths = epidermis.media_paths
local bad_character_b3d_path = modlib.mod.get_resource"character_without_normals.b3d"
local bad_character_b3d_hash = minetest.sha1(modlib.file.read(bad_character_b3d_path))
local fixed_character_b3d_path = modlib.mod.get_resource"character_with_normals.b3d"
return setmetatable({}, {__index = function(self, filename)
local _, ext = modlib.file.get_extension(filename)
if not ext or ext:lower() ~= "b3d" then
-- Only B3D support currently
return
end
local path = assert(media_paths[filename], filename)
-- HACK replace a "bad" character.b3d with a fixed version that includes normals
-- Susceptible to SHA1 collisions, but so is MT's media loading process
-- See https://github.com/minetest/minetest_game/pull/2902
if filename == "character.b3d" and minetest.sha1(modlib.file.read(path)) == bad_character_b3d_hash then
path = fixed_character_b3d_path
end
local model = io.open(path, "r")
local character = assert(modlib.b3d.read(model))
assert(not model:read(1))
model:close()
local mesh = assert(character.node.mesh)
local vertices = assert(mesh.vertices)
for _, vertex in ipairs(vertices) do
-- Minetest hardcodes a blocksize of 10 model units
vertex.pos = mlvec.divide_scalar(vertex.pos, 10)
end
-- Triangle sets by texture index
local tris_by_tex = {}
local func = modlib.func
for _, set in pairs(assert(mesh.triangle_sets)) do
local tris = set.vertex_ids
for _, tri in pairs(tris) do
modlib.table.map(tri, func.curry(func.index, vertices))
tri.poses = {tri[1].pos, tri[2].pos, tri[3].pos}
end
local brush_id = tris.brush_id or mesh.brush_id
local tex_id
if brush_id then
tex_id = assert(character.brushes[brush_id].texture_id[1])
else
-- No brush, default to first texture
tex_id = 1
end
tris_by_tex[tex_id] = tris_by_tex[tex_id] and modlib.table.append(tris_by_tex[tex_id], tris) or tris
end
self[filename] = {
vertices = vertices,
triangle_sets = tris_by_tex,
frames = (character.node.animation or {}).frames or 1
}
return self[filename]
end})

658
paintable.lua Normal file
View File

@ -0,0 +1,658 @@
local FSE = minetest.formspec_escape
local mlvec = modlib.vector
local function mlvec_interpolate_barycentric(u, v, p_1, p_2, p_3)
return mlvec.multiply_scalar(p_1, 1 - u - v)
+ mlvec.multiply_scalar(p_2, u)
+ mlvec.multiply_scalar(p_3, v)
end
local media_paths = epidermis.media_paths
local models = modlib.mod.include"models.lua"
local def = {
initial_properties = {
visual = "mesh",
mesh = "character.b3d",
textures = {"character.png"},
backface_culling = false,
collisionbox = {-0.5, 0, -0.5, 0.5, 2, 0.5},
physical = true
},
lua_properties = {
staticdata = "lua",
id = true
},
}
function def:_get_pixel_index(x, y)
return 1 + x + self._.width * y
end
function def:_get_xy(index)
index = index - 1
return index % self._.width, math.floor(index / self._.width)
end
function def:_get_color(index)
return self._.overlay_pixels[index] or self._pixels[index]
end
function def:_set_color(index, color, log)
if not self._paintable_pixels[index] then
return
end
if log then
self:_log_actions("undo", {[index] = self:_get_color(index)})
end
if self._pixels[index] == color then
self._.overlay_pixels[index] = nil
else
self._.overlay_pixels[index] = color
end
end
local logsize = 100
function def:_log_actions(logname, actions)
local logs = self._.logs
local log = assert(logs[logname])
local count = modlib.table.count(actions)
log.pixel_count = log.pixel_count + count
log:push_tail(actions)
while log:len() >= logsize or log.pixel_count > self._log_max_count do
local popped_actions = assert(log:pop_head())
log.pixel_count = log.pixel_count - modlib.table.count(popped_actions)
end
end
function def:_reverse_last_log_action(logname)
local action_log = self._.logs[logname]
local last_action = action_log:pop_tail()
if not last_action then
return
end
for index, color in pairs(last_action) do
last_action[index] = self:_get_color(index)
self:_set_color(index, color)
end
self:_log_actions(assert(({undo = "redo", redo = "undo"})[logname]), last_action)
return true
end
function def:_bulk_set_color(indices, color, log)
for index in pairs(indices) do
if self._paintable_pixels[index] then
indices[index] = self:_get_color(index)
self:_set_color(index, color)
else
indices[index] = nil
end
end
if log then
self:_log_actions(log, indices)
end
end
function def:_set_mesh(mesh)
self.object:set_properties{mesh = mesh}
self._.mesh = mesh
end
function def:_set_rotation(rotation)
self.object:set_rotation(rotation)
-- Update collision & selection box
local rotation_axis, rotation_angle = epidermis.vector_axis_angle(rotation)
local model = assert(models[self._.mesh])
local min, max = mlvec.new{math.huge, math.huge, math.huge}, mlvec.new{-math.huge, -math.huge, -math.huge}
for _, vertex in ipairs(model.vertices) do
local pos = mlvec.rotate3(vertex.pos, rotation_axis, rotation_angle)
min = mlvec.combine(min, pos, math.min)
max = mlvec.combine(max, pos, math.max)
end
local box = {min[1], min[2], min[3], max[1], max[2], max[3]}
self.object:set_properties{
collisionbox = box,
selectionbox = box,
}
self._.rotation = rotation
end
function def:_face(player)
-- Don't use the eye pos as the eye pos of the paintable is unknown
local rotation = moblib.get_rotation(vector.direction(player:get_pos(), self.object:get_pos()))
-- Tweak rotation to better align with character.b3d which faces -Z
rotation.x = -rotation.x
rotation.y = rotation.y - math.pi
self:_set_rotation(rotation)
end
function def:_set_backface_culling(backface_culling)
self.object:set_properties{backface_culling = backface_culling}
self._.backface_culling = backface_culling
end
function def:_encode_png()
modlib.table.add_all(self._pixels, self._.overlay_pixels)
return modlib.minetest.encode_png(self._.width, self._.height, self._pixels, 9)
end
function def:_write_texture(on_all_received)
self._.dynamic_texture_id = (self._.dynamic_texture_id or 0) + 1
-- It is assumed that the preview will always fit within the remaining space; only check the overlay pixels
local path, texture_name = epidermis.write_epidermis(self._.id, self._.dynamic_texture_id, self:_encode_png())
self._.base_texture = texture_name
self._.overlay_pixels = {}
self._status = "loading"
epidermis.dynamic_add_media(path, function()
self._status = "active"
on_all_received()
end, true)
end
local max_overlay_pixels = 1e3
function def:_update_texture(preview)
if modlib.table.count(self._.overlay_pixels) > max_overlay_pixels then
self:_write_texture(function()
self:_update_texture(preview)
end)
return
end
local preview_pixels = type(preview) == "table" -- preview is a table of pixels (line preview)
local dim = self._.width .. "x" .. self._.height
local overlays = {"0,0=" .. self._.base_texture}
local function pixels(func)
if preview_pixels then
for index, color in pairs(preview) do
func(index, color)
end
end
for index, color in pairs(self._.overlay_pixels) do
if not (preview_pixels and preview[index]) then
func(index, color)
end
end
end
pixels(function(index, color)
local x, y = self:_get_xy(index)
table.insert(overlays, ([[%d,%d=epxw.png\^[multiply\:#%06X]]):format(x, y, color % 0x1000000))
end)
local mask_overlays = {}
pixels(function(index, color)
local x, y = self:_get_xy(index)
local alpha = math.floor(color / 0x1000000)
if alpha < 255 then
table.insert(mask_overlays, ([[%d,%d=epxb.png\\^[opacity\\:%d]]):format(x, y, 255 - alpha))
end
end)
local nonalpha = "[combine:" .. dim .. ":" .. table.concat(overlays, ":")
local alpha = [[[combine\:]] .. dim .. [[\:]] .. table.concat(mask_overlays, [[\:]])
local tex = nonalpha .. [[^[mask:]] .. alpha .. [[\^[invert\:rgba]]
if type(preview) == "string" then -- preview is a texture modifier, just append
tex = tex .. preview
end
assert(#tex < 2^16)
local properties = self.object:get_properties()
self.object:set_properties{textures = {tex, unpack(properties.textures, 2)}}
end
function def:_set_texture(texture, reset)
self._.base_texture = texture
if reset then
self._.overlay_pixels = {}
self._.logs = {
undo = modlib.hashlist.new{pixel_count = 0},
redo = modlib.hashlist.new{pixel_count = 0},
}
end
self._paintable_pixels = {}
local file = io.open(assert(media_paths[self._.base_texture], self._.base_texture), "r")
local png = modlib.minetest.decode_png(file)
assert(not file:read(1), "EOF expected")
file:close()
assert(png.width <= 1024 and png.height <= 1024, "image too large (> 1024x1024)")
modlib.minetest.convert_png_to_argb8(png)
self._pixels = png.data
self._.width = png.width
self._.height = png.height
self._log_max_count = 10 * self._.width * self._.height
local dim = {self._.width, self._.height}
local model = assert(models[self._.mesh])
for texid, tris in pairs(model.triangle_sets) do
for _, triangle in pairs(tris) do
local base = triangle[1].tex_coords[texid]
local edge_1 = mlvec.subtract(triangle[2].tex_coords[texid], base)
local edge_2 = mlvec.subtract(triangle[3].tex_coords[texid], base)
for u = 0, 1, 1/math.ceil(edge_1:multiply(dim):length()+1) do
for v = 0, 1, 1/math.ceil(edge_2:multiply(dim):length()+1) do
local tc = mlvec.add(base, edge_1:multiply_scalar(u) + edge_2:multiply_scalar(v))
self._paintable_pixels[self:_get_pixel_index(math.floor(tc[1] * dim[1]), math.floor(tc[2] * dim[2]))] = true
end
end
end
end
self:_update_texture()
end
function def:_init()
modlib.table.deepcomplete(self._, {
mesh = def.initial_properties.mesh,
overlay_pixels = {},
logs = {
undo = {pixel_count = 0},
redo = {pixel_count = 0},
},
backface_culling = def.initial_properties.backface_culling,
rotation = vector.new(0, 0, 0)
})
-- Set metatables
modlib.hashlist.new(self._.logs.undo)
modlib.hashlist.new(self._.logs.redo)
self:_set_mesh(self._.mesh)
self:_set_texture(self._.base_texture)
self:_set_rotation(self._.rotation)
self:_set_backface_culling(self._.backface_culling)
self.object:set_acceleration{x = 0, y = -0.981, z = 0}
self.object:set_armor_groups{immortal = 1}
self._status = "active"
end
function def:_get_dir_path()
return modlib.file.concat_path{epidermis.paths.dynamic_textures.epidermi, ("epidermis_paintable_%d"):format(self._.id)}
end
function def:on_activate()
local dir_path = self:_get_dir_path()
minetest.mkdir(dir_path)
self._.base_texture = self._.base_texture or def.initial_properties.textures[1]
if media_paths[self._.base_texture] then
self:_init()
return
end
local path = epidermis.get_epidermis_path(self._.id, self._.dynamic_texture_id)
if not path then
minetest.log("warning", ("Base texture %s not found, defaulting to character.png."):format(self._.base_texture))
self:_set_texture("character.png", true)
self:_init()
return
end
if not modlib.file.exists(path) then
local texture
path, texture = epidermis.get_last_epidermis_path(self._.id)
if path then
minetest.log("warning", ("Force-upgrading paintable #%d to texture %s due to staticdata loss"):format(self._.id, texture))
self:_set_texture(texture, true) -- related staticdata must be overwritten, as it relates to the old texture
else
minetest.log("warning", ("No texture for paintable #%d available, defaulting to character.png."):format(self._.id))
self:_set_texture("character.png", true)
return self:_init()
end
end
epidermis.dynamic_add_media(path, function()
self:_init()
end, true)
end
-- TODO (engine change needed) remove directory using `minetest.rmdir(self:_get_dir_path())` on object removal
-- See https://github.com/minetest/minetest/pull/11931
function def:_get_intersection_infos(mt_pos, mt_direction)
local intersection_infos = {}
local pos = mlvec.from_minetest(mt_pos)
local direction = mlvec.from_minetest(mt_direction)
local properties = self.object:get_properties()
local scale = mlvec.from_minetest(properties.visual_size)
local rotation = self.object:get_rotation()
local rotation_axis, rotation_angle = epidermis.vector_axis_angle(rotation)
-- Instead of transforming all triangle vertices, we inversely transform the ray, which is a lot cheaper
local inv_trans_dir = mlvec.rotate3((direction / scale):normalize(), rotation_axis, -rotation_angle)
local inv_trans_rel_pos = mlvec.rotate3(pos - mlvec.from_minetest(self.object:get_pos()), rotation_axis, -rotation_angle)
for texid, tris in pairs(assert(models[properties.mesh]).triangle_sets) do
for _, triangle in pairs(tris) do
local pos_on_ray, u, v = mlvec.ray_triangle_intersection(inv_trans_rel_pos, inv_trans_dir, triangle.poses)
if pos_on_ray then
local normal
if triangle[1].normal then
normal = mlvec_interpolate_barycentric(u, v, triangle[1].normal, triangle[2].normal, triangle[3].normal)
else
normal = mlvec.triangle_normal(triangle.poses)
end
local frontface = mlvec.dot(inv_trans_dir, normal) < 0
local texcoord = mlvec_interpolate_barycentric(u, v,
triangle[1].tex_coords[texid],
triangle[2].tex_coords[texid],
triangle[3].tex_coords[texid])
local width, height = self._.width, self._.height
local pixelcoord = mlvec.apply(mlvec.multiply(texcoord, {width, height}), math.floor)
pixelcoord[1] = math.min(width - 1, pixelcoord[1])
pixelcoord[2] = math.min(height - 1, pixelcoord[2])
local index = self:_get_pixel_index(unpack(pixelcoord))
local paintable = self._paintable_pixels[index]
if paintable then
local color = modlib.minetest.colorspec.from_number(self:_get_color(index))
if frontface or not properties.backface_culling then
table.insert(intersection_infos, {
pos_on_ray = pos_on_ray,
frontface = frontface,
pixelcoord = pixelcoord,
color = color
})
end
end
end
end
end
table.sort(intersection_infos, function(a, b) return a.pos_on_ray < b.pos_on_ray end)
return intersection_infos
end
function def:_can_edit(user)
if self._status ~= "active" then
epidermis.send_notification(user, ("This paintable is %s!"):format(self._status), "warning")
return false
end
if self._.owner ~= user:get_player_name() then
epidermis.send_notification(user, ("This paintable belongs to %s!"):format(self._.owner or "no one"), "warning")
return false
end
return true
end
function def:on_rightclick(clicker)
if clicker:get_wielded_item():get_name() ~= "" or not self:_can_edit(clicker) then
return
end
self:_show_control_panel(clicker)
end
function def:on_punch(puncher)
if puncher:get_wielded_item():get_name() ~= "" or not self:_can_edit(puncher) then
return true
end
local player_name = puncher:get_player_name()
assert(player_name:match"^[A-Za-z_%-]+$")
self:_write_texture(function()
self:_update_texture()
epidermis.set_player_data(player_name, {epidermis = self._.base_texture})
-- Swap skins & meshes with owner
local puncher_model = player_api.get_animation(puncher).model
local puncher_skin = epidermis.get_skin(puncher)
player_api.set_model(puncher, self._.mesh)
epidermis.set_skin(puncher, self._.base_texture)
player_api.set_textures(puncher, {self._.base_texture})
if puncher_skin:match"^[^%[%^]+%.png$" then -- simple texture without modifiers
self:_set_mesh(puncher_model)
self:_set_texture(puncher_skin, true)
self:_write_texture(modlib.func.no_op) -- force-copy the player texture
else
epidermis.send_notification(puncher, "Invalid (combined?) texture! Defaulting to character.png.", "warning")
self:_set_mesh("character.b3d")
self:_set_texture("character.png", true)
end
end)
return true
end
function def:_show_control_panel(player)
local function image_button(exit, x, name, icon, tooltip)
return ("image_button%s[%f,0.25;0.5,0.5;%s;%s;]")
:format(exit and "_exit" or "", x, epidermis.textures[icon] or ("epidermis_" .. icon .. ".png"), name)
.. ("tooltip[%s;%s]"):format(name, FSE(tooltip))
end
local backface_culling = self._.backface_culling
epidermis.show_formspec(player, table.concat{
"size[5.5,1,false]",
"real_coordinates[true]",
image_button(true, 0.25, "backface_culling", (backface_culling and "backface_visible" or "backface_hidden"),
(backface_culling and "Show" or "Hide") .. " back faces"),
"image_button_exit[1,0.25;0.5,0.5;", FSE(epidermis.textures.dice), ";rotation_random;]";
"tooltip[rotation_random;Randomize paintable rotation]",
image_button(true, 1.5, "rotation_face_you", "eyes", "Rotation: Face you"),
image_button(true, 2.25, "preview_animation", "animation", "Play animation"),
image_button(false, 2.75, "preview_texture", "checker", "Open texture preview"),
image_button(false, 3.5, "upload", "upload", "Upload to SkinDB"),
image_button(false, 4, "download", "download", "Pick from SkinDB"),
image_button(true, 4.75, "close", "cross", "Close"),
}, function(fields)
if fields.backface_culling then
self:_set_backface_culling(not self._.backface_culling)
elseif fields.rotation_random then
self:_set_rotation(vector.multiply(vector.new(math.random(), math.random(), math.random()), 2 * math.pi))
elseif fields.rotation_face_you then
self:_face(player)
elseif fields.preview_animation then
local frames = models[self.object:get_properties().mesh].frames
local fps = 30
self.object:set_animation({x = 1, y = frames}, fps, 0, false)
modlib.minetest.after(frames / fps, function()
if self.object:get_pos() then -- check if object is still active
self.object:set_animation()
end
end)
elseif fields.preview_texture then
self:_show_texture_preview(player)
elseif fields.upload then
self:_show_upload_formspec(player)
elseif fields.download then
self:_show_picker_formspec(player)
end
end)
end
function def:_show_texture_preview(player)
local fs_content_width = 8
local image_height = fs_content_width * (self._.height / self._.width) --[fs units]
epidermis.show_formspec(player, table.concat{
("size[%f,%f,false]"):format(fs_content_width + 0.5, image_height + 1.25),
"real_coordinates[true]",
"label[0.25,0.5;Texture Preview:]",
("image[0.25,1;%f,%f;%s]"):format(fs_content_width, image_height, FSE(self.object:get_properties().textures[1])),
"image_button[7.25,0.25;0.5,0.5;", FSE(epidermis.textures.back), ";back;]";
"tooltip[back;Go back]",
"image_button_exit[7.75,0.25;0.5,0.5;epidermis_cross.png;close;]",
"tooltip[close;Close]",
}, function(fields)
if fields.back then
self:_show_control_panel(player)
end
end)
end
function def:_show_upload_formspec(player, message)
local context = {}
epidermis.show_formspec(player, table.concat{
"size[7.5,4.75,false]",
"real_coordinates[true]",
("label[0.25,0.5;%s]"):format(FSE("Upload to SkinDB: " .. (message or ""))),
"image_button[5.75,0.25;0.5,0.5;", FSE(epidermis.textures.back), ";back;]",
"tooltip[back;Go back]",
"image_button[6.25,0.25;0.5,0.5;", FSE(epidermis.textures.upload), ";upload;]",
"tooltip[upload;Upload]",
"image_button_exit[6.75,0.25;0.5,0.5;epidermis_cross.png;cancel;]",
"tooltip[cancel;Cancel]",
"field[0.25,1.25;7,0.5;name;Name:;]",
"field_close_on_enter[name;false]",
("field[0.25,2.25;7,0.5;author;Author:;%s]"):format(player:get_player_name()),
"field_close_on_enter[author;false]",
"label[0.25,3.125;License:]",
("dropdown[0.25,3.25;3,0.5;license;%s;1;true]"):format(table.concat(epidermis.upload_licenses, ",")),
("checkbox[3.5,3.5;credit;%s;false]")
:format(FSE"I have credited properly"),
("checkbox[0.25,4.25;completeness;%s;false]")
:format(FSE"My skin is complete and ready for upload")
}, function(fields)
if fields.quit then
return
end
if fields.back then
self:_show_control_panel(player)
return
end
if fields.credit ~= nil then
context.credit = fields.credit == "true"
return
end
if fields.completeness ~= nil then
context.completeness = fields.completeness == "true"
return
end
if not fields.upload then
return
end
local license = (fields.license or ""):match"^%d+$"
if not license then
epidermis.on_cheat(player, {type = "invalid_formspec_fields"})
return
end
license = tonumber(license)
if not epidermis.upload_licenses[license] then
epidermis.on_cheat(player, {type = "invalid_formspec_fields"})
return
end
local credit, completeness = context.credit, context.completeness
local name, author = modlib.text.trim_spacing(fields.name or ""), modlib.text.trim_spacing(fields.author or "")
if not (credit and completeness and name ~= "" and author ~= "") then
self:_show_upload_formspec(player, minetest.colorize(epidermis.colors.error:to_string(), "Please fill out the form!"))
return
end
epidermis.close_formspec(player)
local player_name = player:get_player_name()
if not minetest.get_player_privs(player_name).epidermis_upload then
epidermis.send_notification(player, 'Missing "epidermis_upload" privilege!', "error")
return
end
epidermis.send_notification(player, "Upload in progress...", "info")
epidermis.upload{
name = name,
author = author,
license = license,
raw_png_data = self:_encode_png(),
on_complete = function(error)
if not minetest.get_player_by_name(player_name) then
return
end
if error then
epidermis.send_notification(player, "Upload failed!", "error")
else
minetest.log("action", player_name .. " uploaded a skin: " .. modlib.json:write_string{
name = name,
author = author,
license = license
})
epidermis.send_notification(player, "Upload completed!", "success")
end
end
}
end)
end
function def:_show_picker_formspec(player)
if #epidermis.skins == 0 then
epidermis.send_notification(player, "SkinDB not loaded yet!", "error")
return
end
local context = {
query = "",
results = epidermis.skins,
index = #epidermis.skins
}
local function show_formspec()
local skin = assert(context.results[context.index])
epidermis.show_formspec(player, table.concat{
"size[8.5,5.25,false]",
"real_coordinates[true]",
"label[0.25,0.5;Pick a texture:]",
"field[3.5,0.25;2,0.5;query;;", FSE(context.query), "]";
"field_close_on_enter[query;false]",
"image_button[5,0.25;0.5,0.5;epidermis_magnifying_glass.png;search;]",
"tooltip[search;Search]",
"image_button[6.75,0.25;0.5,0.5;", FSE(epidermis.textures.back), ";back;]",
"tooltip[back;Go back]",
"image_button_exit[7.25,0.25;0.5,0.5;epidermis_check.png;set;]",
"tooltip[set;Set texture]",
"image_button_exit[7.75,0.25;0.5,0.5;epidermis_cross.png;cancel;]",
"tooltip[cancel;Cancel]",
"model[0.25,1;3,4;character;character.b3d;", skin.texture, ";-45,135]";
"tooltip[character;Drag to rotate]",
"label[3.5,1.25;Name: ", FSE(skin.name), "]";
"label[3.5,1.75;Author: ", FSE(skin.author), "]";
"label[3.5,2.25;License: ", FSE(skin.license), "]";
"label[3.5,2.75;Uploaded: ", FSE(skin.uploaded), "]";
"label[3.5,3.25;", FSE(context.message or (skin.deleted and minetest.colorize(epidermis.colors.error:to_string(), "This skin was deleted!")) or ""), "]";
("hypertext[4.75,4.45;2,0.7;_of;<global valign=middle halign=right>%d/%d]"):format(context.index, #context.results), -- HACK
"image_button[6.75,4.5;0.5,0.5;", FSE(epidermis.textures.dice), ";random;]";
"tooltip[random;Random]",
"image_button[7.25,4.5;0.5,0.5;", FSE(epidermis.textures.previous), ";previous;]";
"tooltip[previous;Previous]",
"image_button[7.75,4.5;0.5,0.5;", FSE(epidermis.textures.next), ";next;]";
"tooltip[next;Next]",
}, function(fields)
if fields.set then
local skin = context.results[context.index]
if skin.deleted then
epidermis.send_notification(player, "The selected skin was deleted!")
else
self:_set_texture(skin.texture, true)
end
return
end
if fields.back then
self:_show_control_panel(player)
return
end
if fields.next then
context.index = context.index + 1
if context.index > #context.results then
context.index = 1
end
elseif fields.previous then
context.index = context.index - 1
if context.index <= 0 then
context.index = #context.results
end
elseif fields.random then
context.index = math.random(1, #context.results)
elseif fields.key_enter or fields.search then
local query = {}
for keyword in (fields.query or ""):sub(1, 100):gmatch("%S+") do -- limit to 100 characters
if #query == 10 then break end -- limit to 10 components
table.insert(query, keyword:lower())
end
if query[1] == nil then
context.query = ""
context.results = epidermis.skins
context.index = #epidermis.skins
context.message = nil
return
end
context.query = table.concat(query, " ")
local results = {}
for _, skin in ipairs(epidermis.skins) do
for _, keyword in pairs(query) do
if skin.name:lower():find(keyword, 1, true)
or skin.author:lower():find(keyword, 1, true)
then
table.insert(results, skin)
break
end
end
end
if results[1] == nil then
context.message = minetest.colorize(epidermis.colors.error:to_string(), "No skins matching query found!")
else
context.results = results
context.index = #results
context.message = nil
end
end
show_formspec()
end)
end
show_formspec()
end
moblib.register_entity("epidermis:paintable", def)

139
persistence.lua Normal file
View File

@ -0,0 +1,139 @@
local concat_path = modlib.file.concat_path
local auth_handler = minetest.get_auth_handler()
epidermis.paths = {dynamic_textures = {}}
for _, folder in pairs{"skindb", "epidermi"} do
local path = modlib.file.concat_path({ minetest.get_worldpath(), "data", "epidermis", "textures", folder })
minetest.mkdir(path)
epidermis.paths.dynamic_textures[folder] = path
end
epidermis.paths.playerdata = modlib.file.concat_path({ minetest.get_worldpath(), "data", "epidermis", "players" })
minetest.mkdir(epidermis.paths.playerdata)
function epidermis.get_player_data(playername)
local filepath = concat_path{epidermis.paths.playerdata, playername .. ".lua"}
local content = modlib.file.read(filepath)
if not content then return end
local playerdata = assert(modlib.luon:read_string(content))
return playerdata
end
function epidermis.set_player_data(playername, data)
local filepath = concat_path{epidermis.paths.playerdata, playername .. ".lua"}
assert(modlib.file.write(filepath, modlib.luon:write_string(data)))
end
local function player_exists(name)
if name == "singleplayer" and minetest.is_singleplayer() then
return true
end
return auth_handler.get_auth(name) ~= nil
end
-- Remove unused player data & mark used textures
local used_textures = {}
for _, filename in ipairs(minetest.get_dir_list(epidermis.paths.playerdata, false)) do
local playername = filename:match"^(.-)%.lua$"
if playername then
local filepath = concat_path{epidermis.paths.playerdata, filename}
if player_exists(playername) then
local playerdata = epidermis.get_player_data(playername)
used_textures[playerdata.epidermis] = true
else
assert(os.remove(filepath))
end
end
end
-- Remove unused textures & store highest texture ID
local epidermi_texture_path = epidermis.paths.dynamic_textures.epidermi
for _, dirname in ipairs(minetest.get_dir_list(epidermi_texture_path, true)) do
local highest_number
local last_filename
local function remove_if_unused(filename)
if not used_textures[filename] then
assert(os.remove(concat_path{epidermi_texture_path, dirname, filename}))
end
end
for _, filename in ipairs(minetest.get_dir_list(concat_path{epidermi_texture_path, dirname}, false)) do
local number = filename:match("^" .. modlib.text.escape_magic_chars(dirname) .. "_(%d+)%.png$")
if number then
number = tonumber(number)
if last_filename then
if number > highest_number then
remove_if_unused(last_filename)
highest_number = number
last_filename = filename
else
remove_if_unused(filename)
end
else
highest_number = number
last_filename = filename
end
end
end
end
function epidermis.get_epidermis_path(paintable_id, texture_id)
local texture_name = ("epidermis_paintable_%d_%d.png"):format(paintable_id, texture_id)
local path = concat_path{
epidermi_texture_path,
("epidermis_paintable_%d"):format(paintable_id),
texture_name
}
return path, texture_name
end
function epidermis.get_epidermis_path_from_texture(dynamic_texture)
local tex_name, dir_name = dynamic_texture:match"^((epidermis_paintable_%d+)_%d+.png)$"
if not (tex_name and dir_name) then return end
return modlib.file.concat_path{epidermi_texture_path, dir_name, tex_name}
end
function epidermis.get_last_epidermis_path(paintable_id)
local dir_name = ("epidermis_paintable_%d"):format(paintable_id)
local max_tex_id = -math.huge
for _, filename in ipairs(minetest.get_dir_list(concat_path{epidermi_texture_path, dir_name}, false)) do
local number = filename:match("^" .. modlib.text.escape_magic_chars(dir_name) .. "_(%d+)%.png$")
if number then
max_tex_id = math.max(max_tex_id, tonumber(number))
end
end
if max_tex_id == -math.huge then return end
return epidermis.get_epidermis_path(paintable_id, max_tex_id)
end
function epidermis.write_epidermis(paintable_id, texture_id, raw_png_data)
local path, texture_name = epidermis.get_epidermis_path(paintable_id, texture_id)
assert(modlib.file.write_binary(path, raw_png_data))
return path, texture_name
end
-- SkinDB
function epidermis.write_skindb_skin(id, raw_png_data, meta_data)
local texture_name = ("epidermis_skindb_%d.png"):format(id)
local path = concat_path{ epidermis.paths.dynamic_textures.skindb, texture_name }
assert(modlib.file.write_binary(path, raw_png_data))
assert(modlib.file.write(concat_path{ epidermis.paths.dynamic_textures.skindb, texture_name .. ".json" },
modlib.json:write_string(meta_data)))
return path, texture_name
end
function epidermis.remove_skindb_skin(id)
local texture_name = ("epidermis_skindb_%d.png"):format(id)
local path = concat_path{ epidermis.paths.dynamic_textures.skindb, texture_name }
assert(os.remove(path))
assert(os.remove(path .. ".json"))
end
-- Player-set epidermis persistence
minetest.register_on_joinplayer(function(player)
local data = epidermis.get_player_data(player:get_player_name())
if data then
epidermis.dynamic_add_media(assert(epidermis.get_epidermis_path_from_texture(data.epidermis)), function()
epidermis.set_skin(player, data.epidermis)
end, true)
end
end)

15
schema.lua Normal file
View File

@ -0,0 +1,15 @@
return {
type = "table",
entries = {
skindb = {
type = "table",
entries = {
autosync = {
type = "boolean",
description = "Automatically sync with SkinDB at startup, continue syncing during game",
default = true
}
}
}
}
}

47
send_notification.lua Normal file
View File

@ -0,0 +1,47 @@
local max_count = 5
local show_duration = 10
local notifications = modlib.minetest.playerdata()
local function remove_last_notification(name)
local notifs = notifications[name]
minetest.get_player_by_name(name):hud_remove(notifs[#notifs].hud_id)
notifs[#notifs] = nil
end
function epidermis.send_notification(player, message, color)
local name = player:get_player_name()
local notifs = notifications[name]
if epidermis.colors[color] then
color = epidermis.colors[color]:to_number_rgb()
end
if notifs[1] and notifs[1].message == message and notifs[1].color == color then
notifs[1].job:cancel()
notifs[1].job = modlib.minetest.after(show_duration, remove_last_notification, name)
notifs[1].count = notifs[1].count + 1
player:hud_change(notifs[1].hud_id, "text", ("(%d) %s"):format(notifs[1].count, message))
return
end
if #notifs == max_count then
notifs[#notifs].job:cancel()
remove_last_notification(name)
end
for i, notification in ipairs(notifs) do
player:hud_change(notification.hud_id, "offset", { x = 0, y = i * -20 })
end
table.insert(notifs, 1, {
hud_id = player:hud_add({
hud_elem_type = "text",
position = { x = 0.6, y = 0.5 },
text = message,
number = color,
direction = 0,
alignment = { x = 1, y = 0 },
offset = { x = 0, y = 0 },
z_index = 0,
}),
color = color,
message = message,
count = 1,
job = modlib.minetest.after(show_duration, remove_last_notification, name),
})
end

3
settingtypes.txt Normal file
View File

@ -0,0 +1,3 @@
[*epidermis.skindb]
# Automatically sync with SkinDB at startup, continue syncing during game
epidermis.skindb.autosync (Epidermis Skindb Autosync) bool true

14
skin.lua Normal file
View File

@ -0,0 +1,14 @@
local function get_textures(player)
local anim = player_api.get_animation(player)
return anim.textures or player_api.registered_models[anim.model].textures
end
function epidermis.get_skin(player)
return get_textures(player)[1]
end
function epidermis.set_skin(player, skin)
local textures = modlib.table.copy(get_textures(player))
textures[1] = skin
player_api.set_textures(player, textures)
end

223
skindb.lua Normal file
View File

@ -0,0 +1,223 @@
-- SkinDB (https://bitbucket.org/kingarthursteam/mt-skin-db/src/master/) support
--[[
Assumptions:
- Skins are usually added
- Skins are rarely removed by the admin / hoster
- Skins are never changed
- `GROUP BY` works like `ORDER BY` (otherwise no ordering is guaranteed)
]]
local http = assert(...)
local base_url = "http://minetest.fensta.bplaced.net"
-- Uploading
epidermis.upload_licenses = {
"CC BY-SA 3.0",
"CC BY-NC-SA 3.0",
"CC BY 3.0",
"CC BY 4.0",
"CC BY-SA 4.0",
"CC BY-NC-SA 4.0",
"CC 0 (1.0)"
}
function epidermis.upload(params)
http.fetch({
url = base_url .. "/api/v2/upload.php",
timeout = 10,
method = "POST",
data = {
name = assert(params.name),
author = assert(params.author),
license = assert(params.license),
img = "data:image/png;base64," .. minetest.encode_base64(params.raw_png_data)
},
extra_headers = { "Accept: application/json", "Accept-Charset: utf-8" },
}, function(res)
if res.timeout then
params.on_complete"Timeout"
return
end
if not res.succeeded then
params.on_complete("HTTP status code: " .. res.code)
return
end
local status, data_or_err = pcall(modlib.json.read_string, modlib.json, res.data)
if not status then
params.on_complete("JSON error: " .. data_or_err)
return
end
if not data.success then
local message = data.status_msg
if #message > 100 then -- trim to 100 characters
message = message:sub(1, 100) .. "..."
end
params.on_complete(("SkinDB error message: %q"):format(message))
end
params.on_complete() -- success
end)
end
minetest.register_privilege("epidermis_upload", {
description = "Can upload skins",
give_to_singleplayer = false,
give_to_admin = false,
})
-- "Downloading"
local texture_path = epidermis.paths.dynamic_textures.skindb
epidermis.skins = {}
local function on_local_copy_loaded() end
local function load_local_copy()
local ids = {}
for _, filename in ipairs(minetest.get_dir_list(texture_path, false)) do
local id = filename:match"^epidermis_skindb_(%d+)%.png$"
if id then
table.insert(ids, tonumber(id))
end
end
table.sort(ids)
for index, id in ipairs(ids) do
local filename = ("epidermis_skindb_%d.png"):format(id)
local path = modlib.file.concat_path{texture_path, filename}
local metafile = assert(io.open(modlib.file.concat_path{texture_path, filename .. ".json"}))
local meta = modlib.json:read_file(metafile)
metafile:close()
meta.texture = "blank.png" -- dynamic media isn't available yet
epidermis.skins[index] = meta
epidermis.dynamic_add_media(path, function()
meta.texture = filename
end, false) -- Enable caching for SkinDB skins
end
on_local_copy_loaded()
end
minetest.after(0, load_local_copy)
local timeout = 10
local html_unescape = modlib.web.html.unescape
local function fetch_page(num, per_page, func, retry_time)
local function on_fail()
if retry_time then
modlib.minetest.after(retry_time, fetch_page, num, per_page, func, retry_time)
return
end
func()
end
http.fetch({
url = ("%s/api/v2/get.json.php?getlist&outformat=base64&page=%d&per_page=%d"):format(base_url, num, per_page),
timeout = timeout,
method = "GET",
extra_headers = { "Accept-Charset: utf-8" },
}, function(res)
if not res.succeeded then
return on_fail()
end
local status, data = pcall(modlib.json.read_string, modlib.json, res.data)
if not status then
return on_fail()
end
local skins = data.skins
-- Check sortedness of skins
for i = 2, #skins do
assert(skins[i - 1].id < skins[i].id)
end
func(data.pages, skins)
end)
end
local function add_skin(skin, index)
assert(skin.type == "image/png")
assert(type(skin.id) == "number" and skin.id % 1 == 0)
assert(type(skin.uploaded) == "string" and #skin.uploaded < 100)
-- These fields may have been incorrectly & automatically casted to numbers by SkinDB (PHP)
local name = html_unescape(tostring(skin.name))
local author = html_unescape(tostring(skin.author))
local license = tostring(skin.license)
local uploaded = skin.uploaded == "0000-00-00 00:00:00" and "Before 2013-08-11" or skin.uploaded
local data = assert(minetest.decode_base64(skin.img))
local meta = {
id = skin.id,
name = name,
author = author,
license = license,
uploaded = uploaded
}
local path, texture = epidermis.write_skindb_skin(skin.id, data, meta)
meta.texture = "blank.png"
if index then -- replace at index
epidermis.skins[index] = meta
else
table.insert(epidermis.skins, meta)
end
epidermis.dynamic_add_media(path, function()
meta.texture = texture
end, false)
end
local function page(pagenum, per_page, on_complete)
fetch_page(pagenum, per_page, function(pages, skins)
local start = math.min(1 + #epidermis.skins - (pagenum - 1) * per_page, per_page + 1)
for i = start - 1, 1, -1 do
local index = i + (pagenum - 1) * per_page
if skins[i].id > epidermis.skins[index].id then -- Deletion
epidermis.remove_skindb_skin(epidermis.skins[index])
add_skin(skins[i], index)
end
end
for i = start, #skins do
add_skin(skins[i])
end
if pagenum < pages then
return page(pagenum + 1, per_page, on_complete)
end
-- Last page reached, delete leftover skins
for i = (pagenum - 1) * per_page + #skins + 1, #epidermis.skins do
epidermis.skins[i] = nil
end
(on_complete or modlib.func.no_op)()
end, timeout)
end
minetest.register_chatcommand("epidermis_fetch_skindb", {
params = "<per_page>",
privs = {server = true},
description = "Start fully fetching SkinDB",
func = function(name, per_page)
per_page = modlib.text.trim_spacing(per_page)
if per_page == "" then
per_page = "50"
end
if not per_page:match"^%d+$" then
return false, "per_page must be an integer"
end
per_page = tonumber(per_page)
if per_page < 10 or per_page > 100 then
return false, "per_page must be between 10 and 100, both inclusive."
end
page(1, per_page, function()
minetest.chat_send_player(name, minetest.colorize("yellow", "[epidermis]") .. " SkinDB fetching complete.")
end)
return true, minetest.colorize("yellow", "[epidermis]") .. " SkinDB fetching started..."
end
})
if not epidermis.conf.skindb.autosync then return end
local function last_page(per_page, on_complete)
page(1 + math.floor(#epidermis.skins / per_page), per_page, on_complete)
end
function on_local_copy_loaded()
last_page(50, function()
-- Fetch 10 skins every 10s
modlib.minetest.register_globalstep(10, function()
last_page(10)
end)
end)
end

11
textures.lua Normal file
View File

@ -0,0 +1,11 @@
local media_paths = modlib.minetest.media.paths
return setmetatable({}, {__index = function(self, texture_name)
local file = io.open(media_paths[texture_name], "r")
local png = modlib.minetest.decode_png(file)
assert(not file:read(1), "EOF expected")
file:close()
modlib.minetest.convert_png_to_argb8(png)
self[texture_name] = png
return self[texture_name]
end})

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

View File

@ -0,0 +1,38 @@
# Script that generates the gradients in this folder. Requires Pillow and Python 3.
from PIL import Image
import math
def generate_rgb_gradient(c, name):
gradient = Image.new("RGB", (256, 1))
for x in range(0, 256):
color = [255, 255, 255]
color[c] = x
gradient.putpixel((x, 0), tuple(color))
gradient.save("epidermis_gradient_" + name + ".png")
generate_rgb_gradient(0, "r")
generate_rgb_gradient(1, "g")
generate_rgb_gradient(2, "b")
gradient = Image.new("HSV", (256, 1))
for x in range(0, 256):
gradient.putpixel((x, 0), (x, 255, 255))
gradient.convert(mode="RGB").save("epidermis_gradient_hue.png")
m_field = Image.new("RGBA", (256, 256))
C_field = Image.new("RGB", (256, 256))
for x in range(0, 256):
for y in range(0, 256):
S = y / 255
V = x / 255
C = S * V
m = V - C
m = math.floor(255 * m + 0.5)
m_field.putpixel((x, y), (255, 255, 255, m))
C = math.floor(255 * C + 0.5)
C_field.putpixel((x, y), (C, C, C))
m_field.save("epidermis_gradient_field_m.png")
C_field.save("epidermis_gradient_field_chroma.png")

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

BIN
textures/pixels/epxb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

BIN
textures/pixels/epxw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

16
theme.lua Normal file
View File

@ -0,0 +1,16 @@
-- Texture-modifier created textures
epidermis.textures = {
dice = "[inventorycube{epidermis_dice_1.png{epidermis_dice_2.png{epidermis_dice_3.png",
upload = "epidermis_arrow_up.png^[multiply:#00C6FF",
download = "epidermis_arrow_up.png^[multiply:#10C14E^[transformFY",
back = "epidermis_arrow_up.png^[multiply:#FFC14E^[transformR90",
previous = "epidermis_arrow_up.png^[multiply:green^[transformR90",
next = "epidermis_arrow_up.png^[multiply:green^[transformR270",
}
epidermis.colors = modlib.table.map({
error = 0xDC3545,
warning = 0xF76300,
info = 0x0DCAF0,
success = 0x198754,
}, modlib.minetest.colorspec.from_number_rgb)

427
tools.lua Normal file
View File

@ -0,0 +1,427 @@
local media_paths = epidermis.media_paths
local send_notification = epidermis.send_notification
minetest.register_craftitem("epidermis:spawner_paintable", {
description = "Paintable Spawner",
inventory_image = "epidermis_paintable_spawner.png",
on_place = function(itemstack, user, pointed_thing)
if not pointed_thing.above then return end
local base_texture = epidermis.get_skin(user)
if not media_paths[base_texture] then
send_notification(user, "Invalid (combined?) texture! Defaulting to character.png.", "warning")
base_texture = "character.png"
end
local object = minetest.add_entity(
vector.divide(vector.add(pointed_thing.under, pointed_thing.above), 2),
"epidermis:paintable",
minetest.serialize{
owner = user:get_player_name(),
base_texture = base_texture,
mesh = assert(player_api.get_animation(user).model)
}
)
if not object then
send_notification(user, "Can't spawn paintable!", "error")
return
end
local entity = object:get_luaentity()
entity:_face(user)
if epidermis.get_epidermis_path_from_texture(base_texture) then
-- Force-copy the texture if it belongs to another epidermis,
-- as the texture will be dropped when the player & the other epidermis stop using it
entity:_write_texture(modlib.func.no_op)
end
itemstack:take_item()
return itemstack
end,
})
local colorpicker_name = "epidermis:colorpicker"
local colorpicker_thickness = minetest.registered_entities[colorpicker_name]._thickness
minetest.register_craftitem("epidermis:spawner_colorpicker", {
description = "HSV colorpicker spawner",
inventory_image = "epidermis_palette.png",
on_place = function(itemstack, _user, pointed_thing)
if pointed_thing.type ~= "node" then
return
end
-- HACK assuming a node size of exactly one
local face_pos = vector.divide(vector.add(pointed_thing.above, pointed_thing.under), 2)
local direction = vector.direction(pointed_thing.under, pointed_thing.above)
local object = minetest.add_entity(vector.add(face_pos, vector.multiply(direction, colorpicker_thickness/2)), colorpicker_name)
if not object then
send_notification(user, "Can't spawn colorpicker!", "error")
return
end
local normal = vector.subtract(pointed_thing.above, pointed_thing.under)
object:get_luaentity():_set_rotation(moblib.get_rotation(normal))
itemstack:take_item()
return itemstack
end,
})
local function set_item_color(itemstack, colorspec)
local colorstring = colorspec:to_string()
itemstack:get_meta():set_string("color", colorstring)
local foreground = "#FFFFFF"
if (colorspec.r + colorspec.g + colorspec.b) > 3 * 127 then
-- Bright background: Choose a dark foreground color
foreground = "#000000"
end
itemstack:get_meta():set_string("description", minetest.get_background_escape_sequence(colorstring) .. minetest.colorize(foreground, itemstack:get_definition().description))
end
local function get_entity(user, pointed_thing, allow_any)
if not (user and user:is_player()) then
return
end
if not pointed_thing or pointed_thing.type ~= "object" then
return
end
local object = pointed_thing.ref
local entity = object:get_luaentity()
if not entity then
return
end
if entity.name == "epidermis:paintable" then
if not entity:_can_edit(user) then
return
end
if object:get_animation().y > 1 then
send_notification(user, "Playing animation!", "warning")
return
end
return entity
elseif allow_any then
return entity
end
end
local function get_paintable_intersection(user, entity)
local intersection_infos = entity:_get_intersection_infos(moblib.get_eye_pos(user), user:get_look_dir())
for _, intersection_info in ipairs(intersection_infos) do
if intersection_info.color.a > 0 then
return intersection_info
end
end
end
local color_tools = {}
local default_color = "#FFFFFF"
local function on_secondary_use(itemstack, user, pointed_thing)
local entity = get_entity(user, pointed_thing, true)
if entity then
if entity.name == "epidermis:colorpicker" then
local color = entity:_get_color(user)
if color then
set_item_color(itemstack, color)
return itemstack
end
return
end
if entity.name == "epidermis:paintable" then
local intersection_info = get_paintable_intersection(user, entity)
if intersection_info then
set_item_color(itemstack, intersection_info.color)
return itemstack
end
return
end
end
local colorstring = itemstack:get_meta():get"color" or default_color
local colorspec = assert(modlib.minetest.colorspec.from_string(colorstring))
epidermis.show_colorpicker_formspec(user, colorspec, function(color)
if not color then return end
local wstack = user:get_wielded_item()
if not color_tools[wstack:get_name()] then return end
set_item_color(wstack, color)
user:set_wielded_item(wstack)
end)
end
local function register_color_tool(name, def, on_paint)
color_tools[name] = true
def.color = default_color
def.on_secondary_use = on_secondary_use
def.on_place = on_secondary_use
function def.on_use(itemstack, user, pointed_thing)
local entity = get_entity(user, pointed_thing, true)
if not entity then
return
end
if entity.name == "epidermis:colorpicker" then
-- The other params aren't used by the on_punch handler
entity:on_punch(user)
return
end
if entity.name ~= "epidermis:paintable" then
return
end
local colorstring = itemstack:get_meta():get"color" or default_color
local color = assert(modlib.minetest.colorspec.from_string(colorstring), colorstring)
local intersection_info = get_paintable_intersection(user, entity)
if intersection_info then
return on_paint(entity, intersection_info.pixelcoord, color, user)
end
end
minetest.register_tool(name, def)
end
register_color_tool("epidermis:pen", {
description = "Pen",
inventory_image = "epidermis_pen_tip.png",
inventory_overlay = "epidermis_pen_handle.png",
}, function(entity, pixelcoord, color)
entity:_set_color(entity:_get_pixel_index(unpack(pixelcoord)), color:to_number(), "undo")
entity:_update_texture()
end)
-- TODO allow holding these items using a globalstep
minetest.register_tool("epidermis:eraser", {
description = "Eraser",
inventory_image = "epidermis_eraser.png",
on_secondary_use = function(_itemstack, user, pointed_thing)
local paintable = get_entity(user, pointed_thing)
if not paintable then
return
end
local last_transparent_frontface
local intersection_infos = paintable:_get_intersection_infos(moblib.get_eye_pos(user), user:get_look_dir())
for _, intersection_info in ipairs(intersection_infos) do
if intersection_info.color.a < 255 then
last_transparent_frontface = intersection_info
else
break
end
end
if last_transparent_frontface then
local idx = paintable:_get_pixel_index(unpack(last_transparent_frontface.pixelcoord))
paintable:_set_color(idx, paintable:_get_color(idx) % 0x1000000 + 0xFF * 0x1000000, true)
paintable:_update_texture()
end
end,
on_use = function(_itemstack, user, pointed_thing)
local paintable = get_entity(user, pointed_thing)
if not paintable then
return
end
local intersection_infos = paintable:_get_intersection_infos(moblib.get_eye_pos(user), user:get_look_dir())
for _, intersection_info in ipairs(intersection_infos) do
if intersection_info.color.a > 0 then
local idx = paintable:_get_pixel_index(unpack(intersection_info.pixelcoord))
paintable:_set_color(idx, paintable:_get_color(idx) % 0x1000000, true)
paintable:_update_texture()
return
end
end
end
})
local function undo_redo_use_func(logname)
return function(_itemstack, user, pointed_thing)
local paintable = get_entity(user, pointed_thing)
if not paintable then
return
end
if not paintable:_reverse_last_log_action(logname) then
send_notification(user, "Nothing to " .. logname .. "!", "warning")
else
paintable:_update_texture()
end
end
end
minetest.register_tool("epidermis:undo_redo", {
description = "Undo / Redo",
inventory_image = "epidermis_undo_redo.png",
on_secondary_use = undo_redo_use_func"redo",
on_use = undo_redo_use_func"undo"
})
register_color_tool("epidermis:filling_bucket", {
description = "Filling Bucket",
inventory_image = "epidermis_filling_paint.png",
inventory_overlay = "epidermis_filling_bucket.png",
}, function(entity, pixelcoord, color)
local index = entity:_get_pixel_index(unpack(pixelcoord))
local replace_color = entity:_get_color(index)
local to_fill = {[index] = replace_color}
local additions
local width, height = entity._.width, entity._.height
local function fill(index)
if to_fill[index] or not entity._paintable_pixels[index] then
return
end
local actual_color = entity:_get_color(index)
-- Doesn't need to handle transparent pixels, as those can't be pointed anyways
if actual_color ~= replace_color then
return
end
additions[index] = actual_color
end
repeat
additions = {}
for index in pairs(to_fill) do
local x, y = entity:_get_xy(index)
if x > 0 then
fill(index - 1)
end
if x < width - 1 then
fill(index + 1)
end
if y > 0 then
fill(index - width)
end
if y < height - 1 then
fill(index + width)
end
end
modlib.table.add_all(to_fill, additions)
until not next(additions)
local color_argb = color:to_number()
for index in pairs(to_fill) do
entity:_set_color(index, color_argb)
end
entity:_log_actions("undo", to_fill)
entity:_update_texture()
end)
-- Dragging tools (line & rectangle)
local dragging = {}
modlib.minetest.register_on_wielditem_change(function(player)
local name = player:get_player_name()
if dragging[name] then
-- Clear preview
dragging[name].entity:_update_texture()
send_notification(player, "Dragging stopped (wielded item changed)", "warning")
dragging[name] = nil
end
end)
minetest.register_globalstep(function()
for player in modlib.minetest.connected_players() do
local name = player:get_player_name()
local LMB = player:get_player_control().LMB
if dragging[name] then
local wielded_item = player:get_wielded_item()
local def = wielded_item:get_definition()
local range = def.range or 4
local eye_pos = moblib.get_eye_pos(player)
local raycast = minetest.raycast(eye_pos, vector.add(eye_pos, vector.multiply(player:get_look_dir(), range)), true, def.liquids_pointable)
local pointed_thing = raycast()
if pointed_thing.type == "object" and pointed_thing.ref:is_player() and pointed_thing.ref:get_player_name() == name then
-- Skip player
pointed_thing = raycast(pointed_thing)
end
local entity = pointed_thing and get_entity(player, pointed_thing)
if not (entity and entity == dragging[name].entity) then
send_notification(player, "Dragging stopped (pointed thing changed)", "warning")
-- Clear preview
dragging[name].entity:_update_texture()
dragging[name] = nil
else
local intersection_info = get_paintable_intersection(player, entity)
if intersection_info then
if LMB then -- still dragging
if dragging[name].preview then
dragging[name].preview(intersection_info.pixelcoord)
else
local action_preview, color = dragging[name].pixels(intersection_info.pixelcoord)
for k in pairs(action_preview) do
action_preview[k] = color
end
entity:_update_texture(action_preview)
end
else -- dragging stopped, finish the action
local actions, color = dragging[name].pixels(intersection_info.pixelcoord)
for idx in pairs(actions) do
entity:_set_color(idx, color)
end
entity:_log_actions("undo", actions)
entity:_update_texture()
dragging[name] = nil
end
end
end
end
end
end)
register_color_tool("epidermis:rectangle", {
description = "Rectangle",
inventory_image = "epidermis_rectangle_background.png",
inventory_overlay = "epidermis_rectangle_border.png",
}, function(entity, pixelcoord_start, color, user)
local color_argb = color:to_number()
dragging[user:get_player_name()] = {
entity = entity,
-- Texture modifier based preview as up to width * height pixels might be needed
preview = function(pixelcoord_end)
local min = modlib.vector.combine(pixelcoord_start, pixelcoord_end, math.min)
local max = modlib.vector.combine(pixelcoord_start, pixelcoord_end, math.max)
local dim = max - min
local preview = "^[combine:" .. entity._.width .. "x" .. entity._.height
.. ":" .. min[1] .."," .. min[2] .. "=epxw.png\\^[multiply\\:" .. color:to_string() .. "\\^[resize\\:" .. (dim[1] + 1) .. "x" .. (dim[2] + 1)
entity:_update_texture(preview)
end,
pixels = function(pixelcoord_end)
local actions = {}
local min = modlib.vector.combine(pixelcoord_start, pixelcoord_end, math.min)
local max = modlib.vector.combine(pixelcoord_start, pixelcoord_end, math.max)
for x = min[1], max[1] do
for y = min[2], max[2] do
local idx = entity:_get_pixel_index(x, y)
actions[idx] = entity:_get_color(idx)
end
end
return actions, color_argb
end
}
end)
register_color_tool("epidermis:line", {
description = "Line",
inventory_image = "epidermis_line_background.png",
inventory_overlay = "epidermis_line_border.png",
}, function(entity, pixelcoord_start, color, user)
local color_argb = color:to_number()
dragging[user:get_player_name()] = {
entity = entity,
-- A pixel preview is sufficient here as the line may at most have max(width, height) pixels
pixels = function(pixelcoord_end)
local pixelcoord_start = pixelcoord_start
-- Uses Bresenham's line algorithm
local diff = modlib.vector.subtract(pixelcoord_end, pixelcoord_start)
if diff:norm() == 0 then
-- Early return: We would divide by zero when obtaining the slope otherwise
local idx = entity:_get_pixel_index(unpack(pixelcoord_start))
return {[idx] = entity:_get_color(idx)}, color_argb
end
local swapped
if math.abs(diff[2]) > math.abs(diff[1]) then
swapped = true
pixelcoord_start = {pixelcoord_start[2], pixelcoord_start[1]}
pixelcoord_end = {pixelcoord_end[2], pixelcoord_end[1]}
end
local actions = {}
local min = pixelcoord_start
local max = pixelcoord_end
if min[1] > max[1] then
min, max = max, min
end
local slope = (max[2] - min[2]) / (max[1] - min[1])
for x = min[1], max[1] do
local y = math.floor(0.5 + slope * (x - min[1])) + min[2]
if swapped then
x, y = y, x
end
local idx = entity:_get_pixel_index(x, y)
actions[idx] = entity:_get_color(idx)
end
return actions, color_argb
end
}
end)