commit 67961cad0318d4eb9db0a4029a10722b1d81afe9 Author: Lars Mueller Date: Sun Jan 9 17:16:29 2022 +0100 Initial commit diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..2da90b1 --- /dev/null +++ b/Readme.md @@ -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 `/data/epidermis/textures/players/epidermis_player_.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 + + +### `skindb` + +#### `autosync` + +Automatically sync with SkinDB at startup, continue syncing during game + +* Type: boolean +* Default: `true` + + + +## 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 \ No newline at end of file diff --git a/character_with_normals.b3d b/character_with_normals.b3d new file mode 100644 index 0000000..3e0827e Binary files /dev/null and b/character_with_normals.b3d differ diff --git a/character_without_normals.b3d b/character_without_normals.b3d new file mode 100644 index 0000000..764197d Binary files /dev/null and b/character_without_normals.b3d differ diff --git a/colorpicker_hsv_ingame.lua b/colorpicker_hsv_ingame.lua new file mode 100644 index 0000000..7ead2ab --- /dev/null +++ b/colorpicker_hsv_ingame.lua @@ -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) \ No newline at end of file diff --git a/colorpicker_rgb_formspec.lua b/colorpicker_rgb_formspec.lua new file mode 100644 index 0000000..5656b52 --- /dev/null +++ b/colorpicker_rgb_formspec.lua @@ -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 \ No newline at end of file diff --git a/dynamic_add_media.lua b/dynamic_add_media.lua new file mode 100644 index 0000000..1e9ae0f --- /dev/null +++ b/dynamic_add_media.lua @@ -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 \ No newline at end of file diff --git a/formspec.lua b/formspec.lua new file mode 100644 index 0000000..c553d2e --- /dev/null +++ b/formspec.lua @@ -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 \ No newline at end of file diff --git a/help.lua b/help.lua new file mode 100644 index 0000000..acee05f --- /dev/null +++ b/help.lua @@ -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("") + 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;Epidermis Guide] +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, +}) + + diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..89816c7 --- /dev/null +++ b/init.lua @@ -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" diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..02606f7 Binary files /dev/null and b/logo.png differ diff --git a/media_paths.lua b/media_paths.lua new file mode 100644 index 0000000..d5849b4 --- /dev/null +++ b/media_paths.lua @@ -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 \ No newline at end of file diff --git a/misc.lua b/misc.lua new file mode 100644 index 0000000..53b38f9 --- /dev/null +++ b/misc.lua @@ -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 diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..86e4dfa --- /dev/null +++ b/mod.conf @@ -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 \ No newline at end of file diff --git a/models.lua b/models.lua new file mode 100644 index 0000000..a68a628 --- /dev/null +++ b/models.lua @@ -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}) \ No newline at end of file diff --git a/paintable.lua b/paintable.lua new file mode 100644 index 0000000..fb1dd57 --- /dev/null +++ b/paintable.lua @@ -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;%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) diff --git a/persistence.lua b/persistence.lua new file mode 100644 index 0000000..53b0335 --- /dev/null +++ b/persistence.lua @@ -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) \ No newline at end of file diff --git a/schema.lua b/schema.lua new file mode 100644 index 0000000..3bb0708 --- /dev/null +++ b/schema.lua @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/send_notification.lua b/send_notification.lua new file mode 100644 index 0000000..2ed14a6 --- /dev/null +++ b/send_notification.lua @@ -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 diff --git a/settingtypes.txt b/settingtypes.txt new file mode 100644 index 0000000..03273d3 --- /dev/null +++ b/settingtypes.txt @@ -0,0 +1,3 @@ +[*epidermis.skindb] +# Automatically sync with SkinDB at startup, continue syncing during game +epidermis.skindb.autosync (Epidermis Skindb Autosync) bool true \ No newline at end of file diff --git a/skin.lua b/skin.lua new file mode 100644 index 0000000..7e7f9a3 --- /dev/null +++ b/skin.lua @@ -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 \ No newline at end of file diff --git a/skindb.lua b/skindb.lua new file mode 100644 index 0000000..7de75ad --- /dev/null +++ b/skindb.lua @@ -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 = "", + 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 diff --git a/textures.lua b/textures.lua new file mode 100644 index 0000000..393314b --- /dev/null +++ b/textures.lua @@ -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}) \ No newline at end of file diff --git a/textures/gradients/epidermis_gradient_b.png b/textures/gradients/epidermis_gradient_b.png new file mode 100644 index 0000000..4d4a301 Binary files /dev/null and b/textures/gradients/epidermis_gradient_b.png differ diff --git a/textures/gradients/epidermis_gradient_field_chroma.png b/textures/gradients/epidermis_gradient_field_chroma.png new file mode 100644 index 0000000..2758743 Binary files /dev/null and b/textures/gradients/epidermis_gradient_field_chroma.png differ diff --git a/textures/gradients/epidermis_gradient_field_m.png b/textures/gradients/epidermis_gradient_field_m.png new file mode 100644 index 0000000..2620d70 Binary files /dev/null and b/textures/gradients/epidermis_gradient_field_m.png differ diff --git a/textures/gradients/epidermis_gradient_g.png b/textures/gradients/epidermis_gradient_g.png new file mode 100644 index 0000000..bd1ea47 Binary files /dev/null and b/textures/gradients/epidermis_gradient_g.png differ diff --git a/textures/gradients/epidermis_gradient_hue.png b/textures/gradients/epidermis_gradient_hue.png new file mode 100644 index 0000000..1620e2b Binary files /dev/null and b/textures/gradients/epidermis_gradient_hue.png differ diff --git a/textures/gradients/epidermis_gradient_r.png b/textures/gradients/epidermis_gradient_r.png new file mode 100644 index 0000000..b124e3e Binary files /dev/null and b/textures/gradients/epidermis_gradient_r.png differ diff --git a/textures/gradients/generate_gradients.py b/textures/gradients/generate_gradients.py new file mode 100644 index 0000000..379ef1e --- /dev/null +++ b/textures/gradients/generate_gradients.py @@ -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") diff --git a/textures/icons/epidermis_animation.png b/textures/icons/epidermis_animation.png new file mode 100644 index 0000000..9f9d789 Binary files /dev/null and b/textures/icons/epidermis_animation.png differ diff --git a/textures/icons/epidermis_arrow_up.png b/textures/icons/epidermis_arrow_up.png new file mode 100644 index 0000000..ab87103 Binary files /dev/null and b/textures/icons/epidermis_arrow_up.png differ diff --git a/textures/icons/epidermis_backface_hidden.png b/textures/icons/epidermis_backface_hidden.png new file mode 100644 index 0000000..a28a239 Binary files /dev/null and b/textures/icons/epidermis_backface_hidden.png differ diff --git a/textures/icons/epidermis_backface_visible.png b/textures/icons/epidermis_backface_visible.png new file mode 100644 index 0000000..564bfb4 Binary files /dev/null and b/textures/icons/epidermis_backface_visible.png differ diff --git a/textures/icons/epidermis_check.png b/textures/icons/epidermis_check.png new file mode 100644 index 0000000..35aff58 Binary files /dev/null and b/textures/icons/epidermis_check.png differ diff --git a/textures/icons/epidermis_checker.png b/textures/icons/epidermis_checker.png new file mode 100644 index 0000000..ffa1a8f Binary files /dev/null and b/textures/icons/epidermis_checker.png differ diff --git a/textures/icons/epidermis_cross.png b/textures/icons/epidermis_cross.png new file mode 100644 index 0000000..8582d85 Binary files /dev/null and b/textures/icons/epidermis_cross.png differ diff --git a/textures/icons/epidermis_dice_1.png b/textures/icons/epidermis_dice_1.png new file mode 100644 index 0000000..a099ed4 Binary files /dev/null and b/textures/icons/epidermis_dice_1.png differ diff --git a/textures/icons/epidermis_dice_2.png b/textures/icons/epidermis_dice_2.png new file mode 100644 index 0000000..d797320 Binary files /dev/null and b/textures/icons/epidermis_dice_2.png differ diff --git a/textures/icons/epidermis_dice_3.png b/textures/icons/epidermis_dice_3.png new file mode 100644 index 0000000..b94c2c7 Binary files /dev/null and b/textures/icons/epidermis_dice_3.png differ diff --git a/textures/icons/epidermis_eyes.png b/textures/icons/epidermis_eyes.png new file mode 100644 index 0000000..66ba94c Binary files /dev/null and b/textures/icons/epidermis_eyes.png differ diff --git a/textures/icons/epidermis_magnifying_glass.png b/textures/icons/epidermis_magnifying_glass.png new file mode 100644 index 0000000..4d889b4 Binary files /dev/null and b/textures/icons/epidermis_magnifying_glass.png differ diff --git a/textures/pixels/epxb.png b/textures/pixels/epxb.png new file mode 100644 index 0000000..4d2f54d Binary files /dev/null and b/textures/pixels/epxb.png differ diff --git a/textures/pixels/epxw.png b/textures/pixels/epxw.png new file mode 100644 index 0000000..264325b Binary files /dev/null and b/textures/pixels/epxw.png differ diff --git a/textures/tools/epidermis_book.png b/textures/tools/epidermis_book.png new file mode 100644 index 0000000..9850f1f Binary files /dev/null and b/textures/tools/epidermis_book.png differ diff --git a/textures/tools/epidermis_eraser.png b/textures/tools/epidermis_eraser.png new file mode 100644 index 0000000..988a9c6 Binary files /dev/null and b/textures/tools/epidermis_eraser.png differ diff --git a/textures/tools/epidermis_filling_bucket.png b/textures/tools/epidermis_filling_bucket.png new file mode 100644 index 0000000..5b01500 Binary files /dev/null and b/textures/tools/epidermis_filling_bucket.png differ diff --git a/textures/tools/epidermis_filling_paint.png b/textures/tools/epidermis_filling_paint.png new file mode 100644 index 0000000..5cdc390 Binary files /dev/null and b/textures/tools/epidermis_filling_paint.png differ diff --git a/textures/tools/epidermis_line_background.png b/textures/tools/epidermis_line_background.png new file mode 100644 index 0000000..a8b04b1 Binary files /dev/null and b/textures/tools/epidermis_line_background.png differ diff --git a/textures/tools/epidermis_line_border.png b/textures/tools/epidermis_line_border.png new file mode 100644 index 0000000..7b2861b Binary files /dev/null and b/textures/tools/epidermis_line_border.png differ diff --git a/textures/tools/epidermis_paintable_spawner.png b/textures/tools/epidermis_paintable_spawner.png new file mode 100644 index 0000000..217c1cf Binary files /dev/null and b/textures/tools/epidermis_paintable_spawner.png differ diff --git a/textures/tools/epidermis_palette.png b/textures/tools/epidermis_palette.png new file mode 100644 index 0000000..cc7bd1a Binary files /dev/null and b/textures/tools/epidermis_palette.png differ diff --git a/textures/tools/epidermis_pen_handle.png b/textures/tools/epidermis_pen_handle.png new file mode 100644 index 0000000..3cf1dfe Binary files /dev/null and b/textures/tools/epidermis_pen_handle.png differ diff --git a/textures/tools/epidermis_pen_tip.png b/textures/tools/epidermis_pen_tip.png new file mode 100644 index 0000000..0931a8a Binary files /dev/null and b/textures/tools/epidermis_pen_tip.png differ diff --git a/textures/tools/epidermis_rectangle_background.png b/textures/tools/epidermis_rectangle_background.png new file mode 100644 index 0000000..88687cd Binary files /dev/null and b/textures/tools/epidermis_rectangle_background.png differ diff --git a/textures/tools/epidermis_rectangle_border.png b/textures/tools/epidermis_rectangle_border.png new file mode 100644 index 0000000..78f9c27 Binary files /dev/null and b/textures/tools/epidermis_rectangle_border.png differ diff --git a/textures/tools/epidermis_undo_redo.png b/textures/tools/epidermis_undo_redo.png new file mode 100644 index 0000000..b773e2c Binary files /dev/null and b/textures/tools/epidermis_undo_redo.png differ diff --git a/theme.lua b/theme.lua new file mode 100644 index 0000000..fbd047b --- /dev/null +++ b/theme.lua @@ -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) \ No newline at end of file diff --git a/tools.lua b/tools.lua new file mode 100644 index 0000000..82aaea7 --- /dev/null +++ b/tools.lua @@ -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) \ No newline at end of file