From 67961cad0318d4eb9db0a4029a10722b1d81afe9 Mon Sep 17 00:00:00 2001 From: Lars Mueller Date: Sun, 9 Jan 2022 17:16:29 +0100 Subject: [PATCH] Initial commit --- Readme.md | 157 +++++ character_with_normals.b3d | Bin 0 -> 73433 bytes character_without_normals.b3d | Bin 0 -> 71467 bytes colorpicker_hsv_ingame.lua | 163 +++++ colorpicker_rgb_formspec.lua | 85 +++ dynamic_add_media.lua | 48 ++ formspec.lua | 39 ++ help.lua | 150 ++++ init.lua | 18 + logo.png | Bin 0 -> 187 bytes media_paths.lua | 17 + misc.lua | 9 + mod.conf | 6 + models.lua | 57 ++ paintable.lua | 658 ++++++++++++++++++ persistence.lua | 139 ++++ schema.lua | 15 + send_notification.lua | 47 ++ settingtypes.txt | 3 + skin.lua | 14 + skindb.lua | 223 ++++++ textures.lua | 11 + textures/gradients/epidermis_gradient_b.png | Bin 0 -> 77 bytes .../epidermis_gradient_field_chroma.png | Bin 0 -> 5681 bytes .../gradients/epidermis_gradient_field_m.png | Bin 0 -> 5778 bytes textures/gradients/epidermis_gradient_g.png | Bin 0 -> 77 bytes textures/gradients/epidermis_gradient_hue.png | Bin 0 -> 94 bytes textures/gradients/epidermis_gradient_r.png | Bin 0 -> 77 bytes textures/gradients/generate_gradients.py | 38 + textures/icons/epidermis_animation.png | Bin 0 -> 179 bytes textures/icons/epidermis_arrow_up.png | Bin 0 -> 153 bytes textures/icons/epidermis_backface_hidden.png | Bin 0 -> 148 bytes textures/icons/epidermis_backface_visible.png | Bin 0 -> 130 bytes textures/icons/epidermis_check.png | Bin 0 -> 168 bytes textures/icons/epidermis_checker.png | Bin 0 -> 113 bytes textures/icons/epidermis_cross.png | Bin 0 -> 180 bytes textures/icons/epidermis_dice_1.png | Bin 0 -> 120 bytes textures/icons/epidermis_dice_2.png | Bin 0 -> 126 bytes textures/icons/epidermis_dice_3.png | Bin 0 -> 133 bytes textures/icons/epidermis_eyes.png | Bin 0 -> 136 bytes textures/icons/epidermis_magnifying_glass.png | Bin 0 -> 117 bytes textures/pixels/epxb.png | Bin 0 -> 91 bytes textures/pixels/epxw.png | Bin 0 -> 89 bytes textures/tools/epidermis_book.png | Bin 0 -> 384 bytes textures/tools/epidermis_eraser.png | Bin 0 -> 215 bytes textures/tools/epidermis_filling_bucket.png | Bin 0 -> 284 bytes textures/tools/epidermis_filling_paint.png | Bin 0 -> 154 bytes textures/tools/epidermis_line_background.png | Bin 0 -> 103 bytes textures/tools/epidermis_line_border.png | Bin 0 -> 104 bytes .../tools/epidermis_paintable_spawner.png | Bin 0 -> 137 bytes textures/tools/epidermis_palette.png | Bin 0 -> 322 bytes textures/tools/epidermis_pen_handle.png | Bin 0 -> 222 bytes textures/tools/epidermis_pen_tip.png | Bin 0 -> 109 bytes .../tools/epidermis_rectangle_background.png | Bin 0 -> 120 bytes textures/tools/epidermis_rectangle_border.png | Bin 0 -> 123 bytes textures/tools/epidermis_undo_redo.png | Bin 0 -> 335 bytes theme.lua | 16 + tools.lua | 427 ++++++++++++ 58 files changed, 2340 insertions(+) create mode 100644 Readme.md create mode 100644 character_with_normals.b3d create mode 100644 character_without_normals.b3d create mode 100644 colorpicker_hsv_ingame.lua create mode 100644 colorpicker_rgb_formspec.lua create mode 100644 dynamic_add_media.lua create mode 100644 formspec.lua create mode 100644 help.lua create mode 100644 init.lua create mode 100644 logo.png create mode 100644 media_paths.lua create mode 100644 misc.lua create mode 100644 mod.conf create mode 100644 models.lua create mode 100644 paintable.lua create mode 100644 persistence.lua create mode 100644 schema.lua create mode 100644 send_notification.lua create mode 100644 settingtypes.txt create mode 100644 skin.lua create mode 100644 skindb.lua create mode 100644 textures.lua create mode 100644 textures/gradients/epidermis_gradient_b.png create mode 100644 textures/gradients/epidermis_gradient_field_chroma.png create mode 100644 textures/gradients/epidermis_gradient_field_m.png create mode 100644 textures/gradients/epidermis_gradient_g.png create mode 100644 textures/gradients/epidermis_gradient_hue.png create mode 100644 textures/gradients/epidermis_gradient_r.png create mode 100644 textures/gradients/generate_gradients.py create mode 100644 textures/icons/epidermis_animation.png create mode 100644 textures/icons/epidermis_arrow_up.png create mode 100644 textures/icons/epidermis_backface_hidden.png create mode 100644 textures/icons/epidermis_backface_visible.png create mode 100644 textures/icons/epidermis_check.png create mode 100644 textures/icons/epidermis_checker.png create mode 100644 textures/icons/epidermis_cross.png create mode 100644 textures/icons/epidermis_dice_1.png create mode 100644 textures/icons/epidermis_dice_2.png create mode 100644 textures/icons/epidermis_dice_3.png create mode 100644 textures/icons/epidermis_eyes.png create mode 100644 textures/icons/epidermis_magnifying_glass.png create mode 100644 textures/pixels/epxb.png create mode 100644 textures/pixels/epxw.png create mode 100644 textures/tools/epidermis_book.png create mode 100644 textures/tools/epidermis_eraser.png create mode 100644 textures/tools/epidermis_filling_bucket.png create mode 100644 textures/tools/epidermis_filling_paint.png create mode 100644 textures/tools/epidermis_line_background.png create mode 100644 textures/tools/epidermis_line_border.png create mode 100644 textures/tools/epidermis_paintable_spawner.png create mode 100644 textures/tools/epidermis_palette.png create mode 100644 textures/tools/epidermis_pen_handle.png create mode 100644 textures/tools/epidermis_pen_tip.png create mode 100644 textures/tools/epidermis_rectangle_background.png create mode 100644 textures/tools/epidermis_rectangle_border.png create mode 100644 textures/tools/epidermis_undo_redo.png create mode 100644 theme.lua create mode 100644 tools.lua 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 0000000000000000000000000000000000000000..3e0827e40b5608d36019d0cfef98994d2fb9584c GIT binary patch literal 73433 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 literal 0 HcmV?d00001 diff --git a/character_without_normals.b3d b/character_without_normals.b3d new file mode 100644 index 0000000000000000000000000000000000000000..764197d95ae32fa9614f1a17a7a553fe3dc54948 GIT binary patch literal 71467 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 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 0000000000000000000000000000000000000000..02606f7ab3f418399732f5877b421c43307e3956 GIT binary patch literal 187 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAP%zii z#WBR9H#uQ}fWf1Qzx{in)M}QQTNoHjiJJIAB}M$t{c9ioAKi56e?(04^+*fR zx&Kdm(H`*4d5zY`c1A;n)r|kQn7Qylwx@k g4(j`sR7~V&kbfz)tLlZ+QJ}>Pp00i_>zopr0Fh!teE= 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 0000000000000000000000000000000000000000..4d4a3011c2dd6d827dfe4cff3ec03e8123761e47 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K57&(}LmdKI;Vst0H4PdmH+?% literal 0 HcmV?d00001 diff --git a/textures/gradients/epidermis_gradient_field_chroma.png b/textures/gradients/epidermis_gradient_field_chroma.png new file mode 100644 index 0000000000000000000000000000000000000000..2758743f51fb0aa362052caff2ec9b5ad1c6f7c3 GIT binary patch literal 5681 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 X;`swNgMzfj00000NkvXXu0mjfSX`s^ literal 0 HcmV?d00001 diff --git a/textures/gradients/epidermis_gradient_field_m.png b/textures/gradients/epidermis_gradient_field_m.png new file mode 100644 index 0000000000000000000000000000000000000000..2620d7058b37c9677520ae2ef122ac865ef3a228 GIT binary patch literal 5778 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 literal 0 HcmV?d00001 diff --git a/textures/gradients/epidermis_gradient_hue.png b/textures/gradients/epidermis_gradient_hue.png new file mode 100644 index 0000000000000000000000000000000000000000..1620e2bf6f53288ad3f5a9a13607c37e035700f7 GIT binary patch literal 94 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K57&(}Lkyt literal 0 HcmV?d00001 diff --git a/textures/gradients/epidermis_gradient_r.png b/textures/gradients/epidermis_gradient_r.png new file mode 100644 index 0000000000000000000000000000000000000000..b124e3eac6629a5c537149aeefc8f9b9c45bff60 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K57&(}LLO8w%47zCNxM5_`cxS-k4LhpHazB+ zjCf^cFl7;6+*J8nOIwzSFx*yem^Q_f;i5^Lkk#)ixr?4XPkSZT({qtEVwcB%!x^^A c9zM2XWd2klxI^)iG0+tZp00i_>zopr0HfJIxBvhE literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_arrow_up.png b/textures/icons/epidermis_arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8710376e7e8ce10fe35f8cf79070f873882097 GIT binary patch literal 153 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`37#&FAr}5`CwuZSC~z=8{NI0U z2iwF`jQv$xjb~~3oIJ{?pDh0@k4f;QO>y_mNR@~WdlV;CG%@e|tU BG}ZtB literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_backface_hidden.png b/textures/icons/epidermis_backface_hidden.png new file mode 100644 index 0000000000000000000000000000000000000000..a28a2395c61b14b3c7b737f7c2c9d24e77f0e2b8 GIT binary patch literal 148 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(Vi}jAr}5uCmrNtP+)QR`M>{K zM()WIk0yCXKdt0(GQ4n7o#Dau7f(XIFi7e*-sf4jz>N9Y$rjBWdL0^R58jlquaMls xaO8(S^VXQDVyBEV*iyE=xWSw{;mr2?Y$4owf@+>@{y=LOJYD@<);T3K0RR(WHBA5j literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_backface_visible.png b/textures/icons/epidermis_backface_visible.png new file mode 100644 index 0000000000000000000000000000000000000000..564bfb4456c451c8fc79f3546567057902e631de GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`o}Mm_Ar}70DGLM)9$ol9-=kQv zQ0l{f`#NI@#VGcPj4nSVZ$`g}`{Ca+-<|istds{Y8&){5sLFg1;S!c$VH22LSy0*N d@pr~4hMimN1XXL^tO6R!;OXk;vd$@?2>_QXD*ONd literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_check.png b/textures/icons/epidermis_check.png new file mode 100644 index 0000000000000000000000000000000000000000..35aff58a58d23ce2dcdcacdf93666a947f9a134f GIT binary patch literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%``JOJ0Ar}5eCkOH!P~dQW_^-X} z9nD9xT`UisTKVaD3_)qHKbf6}y>gzK^=$+4PMU+{bQLg8jLi%JjE zs+Sza>=w#1EuX2qXIQwwK+LMF)&I7}l5;D>X8Fu1THv>A literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_checker.png b/textures/icons/epidermis_checker.png new file mode 100644 index 0000000000000000000000000000000000000000..ffa1a8f8e9182e59202604cd9e09cc413d4472eb GIT binary patch literal 113 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`7M?DSAr}70DGLM)oWA~7zbxa{ z#P;WZ|J@w7j%$I;E2P+1oTS5bl#>##Ftq&_X=hwIv(x7<4-Z4TgOOn1*}w@vGZ;Kw L{an^LB{Ts5jYJ`p literal 0 HcmV?d00001 diff --git a/textures/icons/epidermis_cross.png b/textures/icons/epidermis_cross.png new file mode 100644 index 0000000000000000000000000000000000000000..8582d85e1bf043c9310c2d3787aab32e24fa8fd6 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`)t)YnAr}5?gKrBq7;t!t7yjQX zv*g~*qT|{TYyWIz;b4?9Tf6fI-+})>h1eD!jIE5A)FAlat-$R0jj?TFid_ci3z8={2=i` zzCAoa?}#RU_=o@YtEAlAHi$S@HgPS```v$<>6H{isTuPLvz)jCYW>oBq3bY1x{is3!GuJPEGvc60zA&{ya%N&Tw!FWS~}y6bv1t| P&>RL&S3j3^P6+j&mg zBAu;Wq@Fot?u!&))5)!HTw#`e>8y;2qR|%FqPICxhqrFdlz7y?{i%V?y=l(&?H8CP zOpOTlZC1OqL1jgU05cFAj%k-;V`Fm>^yiWQ>U$+nVvr%@6c~H>fI)vF5IBa0mL|wv zdccr;ci9a^XZ@^uwab?pca(TM?D{PdxoE`-j|~(3E+}q&{m!_cxOnng4nsr3MO9ob z?!4;ioM$VH7#y#=y)yVSzwnDPv#{{bm$Rl%pT5A>g;m;gm+0yB0_oQ^Bv^MaFqBp4 V?kQ0>GXw@HgQu&X%Q~loCIFYwh;aY_ literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_eraser.png b/textures/tools/epidermis_eraser.png new file mode 100644 index 0000000000000000000000000000000000000000..988a9c6e927a9203851bca7b036d260bc82e43b1 GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAP_Wa} z#WBR9_iVo*-w_2Kmg_dGyPo{_KUw-=>RzRXDKa@hF%zdwF!p1cX1YGP z#?QFntVUsMvGS7O-Q!BHW!Y=UCc+Nlf^isjB6Q&=WT6;Wy zkh|-t%kBc%7cav&QdoX{7YO82R9O0C*MuKioESJ1r^I-1ezw`|x9a5WZBt+SUA|(P zeW6e$;n~8f#d-VQ?`DqB4t3wNZnajFz)Zc1viuCwQZ;v$P5o@c_=hk5U)*|ScF~1m gyBWBzaH;P5ej)!`&w^-UpwAdQUHx3vIVCg!04C{gH~;_u literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_filling_paint.png b/textures/tools/epidermis_filling_paint.png new file mode 100644 index 0000000000000000000000000000000000000000..5cdc390aa2f71a99ba2c249196d655593abe85d0 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAP|(NI z#WBR9_w2--ybKCF%(+MYX*)CXx+wAs#mL$hHRNYDUyhyHGH<~o!F)Dtl}xjaC98RL xUNSgn-qm88reDz_TQpSD%%417^;gUpZAsj`3qFV;OXk;vd$@?2>@&H kSkmnHgvrZTI8aiSVM(Topd*jjaiG}@p00i_>zopr02z%fX#fBK literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_palette.png b/textures/tools/epidermis_palette.png new file mode 100644 index 0000000000000000000000000000000000000000..cc7bd1accf6249e46f6a9f3dffe6e58f600c62d1 GIT binary patch literal 322 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAQ1FYV zi(`mI@6jMbzC#8)HsS%HT*7xN*o>vQZ$?z`@i(x4U>DnF*EoNJg4+V8wMlI!u4FQC zGn>1fyla^*{lk1kb)lWW@s7);>@nvZT3#x|X?n|=nqM=&NBgIJox>Yg^Kk z4hwJ2qfHafuU+J8bMaix2J>i!1ABcW7=AT=U13|M-T!5=%LRJ@o?mZw<}I?#Rd$IN z-4e-@-+cQ_(vB^TSN-jG$lbj%>E*1Kk?k$J68+~$dsnqYdhL+wyzl#FE(ibB_EQl( zj3!UNPr0Y`GA-=b(UW!(A^vU~lRjCg?EK=ALrJp-sAe z+6?=8JI_vDRvC+e2mdFZIPu@y+3DZpCrlCr1qBHrDapwX(o$7#3u-Vh+`SR&-a2pf RJ)qkdJYD@<);T3K0RTeRPD}s* literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_pen_tip.png b/textures/tools/epidermis_pen_tip.png new file mode 100644 index 0000000000000000000000000000000000000000..0931a8ad08da1c3c2dc62632261aa1d99beb10f2 GIT binary patch literal 109 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fANY2y6 zF~p)bIbi|Y4}R7sFH)V%JCwpFtgRI3X$m~Rz#!<(|JgdyJsGH!!PC{xWt~$(69DMu B8!`X@ literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_rectangle_background.png b/textures/tools/epidermis_rectangle_background.png new file mode 100644 index 0000000000000000000000000000000000000000..88687cda2b2ac67536375939c5705f99150eb077 GIT binary patch literal 120 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAP*BU$ z#WBR9H#tEf;{ZqUpa1{=+j~k$0)d2t#0-tngDV&pv+?jTv&k_qI3=)1{ES)uAE=AL M)78&qol`;+01O5ln*aa+ literal 0 HcmV?d00001 diff --git a/textures/tools/epidermis_rectangle_border.png b/textures/tools/epidermis_rectangle_border.png new file mode 100644 index 0000000000000000000000000000000000000000..78f9c27e42390556b3a08b853c1afcac2147a1bd GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAP*B&? z#WBR9H#uQ}fPvG?|01u{PdEJM0Ro?tTRiNG*?Oc5EO-(elP1qzsKLzOvW-PDne(PO PP$PqLlRIw1Fc3ta1OX8Y!YKsX3=EaNLvEGE$4HY~dAi#;T@cgSgqW7_{xb=(<&af z%!t?6VmRV(&F*zX>HbI@BIQz=n?B>ZC9WD!C6u?t3sP>jgTKN#7!BMe znCfLk!mZ%`&I;_zmQ$qMD9TGOF~`Z0C_$0`L2O&oAwdffb&`Z>1w6N~cG>6B!?{O- h6gqrpb 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