Skip to content

Commit

Permalink
Add support for player skins via SkinsDB (#284)
Browse files Browse the repository at this point in the history
* Add support for player skins via SkinsDB

* Fix jshint complaints
  • Loading branch information
shrimpza committed Dec 16, 2022
1 parent 67114e1 commit 7e8dcdc
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 39 deletions.
6 changes: 6 additions & 0 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ func ParseConfig(filename string) (*Config, error) {
"mapserver_player",
}

skins := SkinsConfig{
EnableSkinsDB: false,
SkinsPath: "",
}

cfg := Config{
ConfigVersion: 1,
Port: 8080,
Expand All @@ -123,6 +128,7 @@ func ParseConfig(filename string) (*Config, error) {
MapObjects: &mapobjs,
MapBlockAccessorCfg: &mapblockaccessor,
DefaultOverlays: defaultoverlays,
Skins: &skins,
}

info, err := os.Stat(filename)
Expand Down
6 changes: 6 additions & 0 deletions app/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Config struct {
MapObjects *MapObjectConfig `json:"mapobjects"`
MapBlockAccessorCfg *MapBlockAccessorConfig `json:"mapblockaccessor"`
DefaultOverlays []string `json:"defaultoverlays"`
Skins *SkinsConfig `json:"skins"`
}

type MapBlockAccessorConfig struct {
Expand Down Expand Up @@ -71,3 +72,8 @@ type WebApiConfig struct {
//mod http bridge secret
SecretKey string `json:"secretkey"`
}

type SkinsConfig struct {
EnableSkinsDB bool `json:"enableskinsdb"`
SkinsPath string `json:"skinspath"`
}
16 changes: 14 additions & 2 deletions doc/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ scifi_nodes:slope_vent 120 120 120
scifi_nodes:white2 240 240 240
```

Default colors, see: [colors.txt](../server/static/colors.txt)
Default colors, see: [colors directory](../public/colors)

## Configuration json

Expand Down Expand Up @@ -63,7 +63,11 @@ The mapserver will generate a fresh `mapserver.json` if there is none at startup
"expiretime": "10s",
"purgetime": "15s",
"maxitems": 5000
}
},
"skins": {
"enableskinsdb": true,
"skinspath": "/path/to/minetest/mods/skinsdb/textures"
}
}
```

Expand Down Expand Up @@ -119,3 +123,11 @@ Enables the [Prometheus](./prometheus.md) metrics endpoint

#### mapblockaccessor.maxitems
Number of mapblocks to keep in memory, dial this down if you have memory issues

#### skins.enableskinsdb
Enables support for serving/displaying custom player skins provided by the SkinsDB mod.

#### skins.skinspath
The path to where SkinsDB textures are stored. This should be the SkinsDB textures directory.

Example: `/path/to/minetest/mods/skinsdb/textures`
18 changes: 18 additions & 0 deletions public/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,21 @@ body {
margin-left: -100px !important;
margin-top: -100px !important;
}

.player-popup {
display: grid;
grid-template-columns: 64px auto;
grid-template-areas: 'img info';
grid-column-gap: 10px;
}

.player-popup img.portrait {
grid-area: img;
height: 128px;
width: 64px;
image-rendering: pixelated;
}

.player-popup div.info {
grid-area: info;
}
111 changes: 77 additions & 34 deletions public/js/map/overlays/PlayerOverlay.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import wsChannel from '../../WebSocketChannel.js';
import layerMgr from '../../LayerManager.js';

const defaultSkin = "pics/sam.png";

let players = [];
let playerSkins = {};

//update players all the time
wsChannel.addListener("minetest-info", function(info){
players = info.players || [];
});

var PlayerIcon = L.icon({
iconUrl: 'pics/sam.png',

iconSize: [16, 32],
iconAnchor: [8, 16],
popupAnchor: [0, -16]
});

export default L.LayerGroup.extend({
initialize: function() {
L.LayerGroup.prototype.initialize.call(this);
Expand All @@ -26,55 +21,56 @@ export default L.LayerGroup.extend({
this.onMinetestUpdate = this.onMinetestUpdate.bind(this);
},

createPopup: function(player){
let html = "<b>" + player.name + "</b>";
html += "<hr>";
createPopup: function(player) {
// moderators get a small crown icon
let moderator = player.moderator ? `<img src="pics/crown.png" alt="moderator" title="moderator">` : "";

for (let i=0; i<Math.floor(player.hp / 2); i++)
html += "<img src='pics/heart.png'>";
let info = `<b>${moderator} ${player.name}</b>`;
info += "<hr>";

if (player.hp % 2 == 1)
html += "<img src='pics/heart_half.png'>";
for (let i = 0; i < Math.floor(player.hp / 2); i++)
info += "<img src='pics/heart.png' alt='health'>";

html += "<br>";
if (player.hp % 2 === 1)
info += "<img src='pics/heart_half.png' alt='health'>";

for (let i=0; i<Math.floor(player.breath / 2); i++)
html += "<img src='pics/bubble.png'>";
info += "<br>";

if (player.breath % 2 == 1)
html += "<img src='pics/bubble_half.png'>";
for (let i = 0; i < Math.floor(player.breath / 2); i++)
info += "<img src='pics/bubble.png' alt='breath'>";

html += `
if (player.breath % 2 === 1)
info += "<img src='pics/bubble_half.png' alt='breath'>";

info += `
<br>
<b>RTT:</b> ${Math.floor(player.rtt*1000)} ms
<br>
<b>Protocol version:</b> ${player.protocol_version}
`;

return html;
info = `<div class="info">${info}</div>`;

let portrait = `<img class="portrait" src="${this.getSkin(player)}" alt="${player.name}">`;

return `<div class="player-popup">${portrait}${info}</div>`;
},

createMarker: function(player){
createMarker: function(player) {
const marker = L.marker([player.pos.z, player.pos.x], {icon: this.getIcon(player)});

marker.bindPopup(this.createPopup(player));
marker.bindPopup(this.createPopup(player), {minWidth: 220});
return marker;
},

getIcon: function(player) {
/*
compatibility with mapserver_mod without `yaw` attribute - value will be 0.
if a player manages to look exactly north, the indicator will also disappear
but aligning view at 0.0 is difficult/unlikely during normal gameplay.
*/
if (player.yaw === 0) return PlayerIcon;

const icon = 'pics/sam.png';
const indicator = player.velocity.x !== 0 || player.velocity.z !== 0 ? 'pics/sam_dir_move.png' : 'pics/sam_dir.png';
const icon = this.getSkin(player);
// compatibility with mapserver_mod without `yaw` attribute - value will be 0.
const indicator = player.yaw === 0 ? false : player.velocity.x !== 0 || player.velocity.z !== 0 ? 'pics/sam_dir_move.png' : 'pics/sam_dir.png';
return L.divIcon({
html: `<div style="display:inline-block;width:48px;height:48px">
<img src="${icon}" style="position:absolute;top:8px;left:16px;width:16px;height:32px;" alt="${player.name}">
<img src="${indicator}" style="position:absolute;top:0;left:0;width:48px;height:48px;transform:rotate(${player.yaw*-1}rad)" alt="${player.name}">
${indicator ? `<img src="${indicator}" style="position:absolute;top:0;left:0;width:48px;height:48px;transform:rotate(${player.yaw*-1}rad)" alt="${player.name}">` : ''}
</div>`,
className: '', // don't use leaflet default of a white block
iconSize: [48, 48],
Expand All @@ -83,6 +79,53 @@ export default L.LayerGroup.extend({
});
},

getSkin: function(player) {
if (!player.skin || player.skin === "" || player.skin === "character.png") return defaultSkin;

let skin = `api/skins/${player.skin}`;

if (playerSkins[skin]) return playerSkins[skin];

// no cached skin, we need to build the image
let img = new Image();
img.onload = function() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = 16;
canvas.height = 32;

// head
ctx.drawImage(img, 8, 8, 8, 8, 4, 0, 8, 8);
// chest
ctx.drawImage(img, 20, 20, 8, 12, 4, 8, 8, 12);
// leg left
ctx.drawImage(img, 4, 20, 4, 12, 4, 20, 4, 12);
// leg right
if (img.height === 64) {
ctx.drawImage(img, 20, 52, 4, 12, 8, 20, 4, 12);
} else {
ctx.drawImage(img, 4, 20, 4, 12, 8, 20, 4, 12);
}
// arm left
ctx.drawImage(img, 44, 20, 4, 12, 0, 8, 4, 12);
// arm right
if (img.height === 64) {
ctx.drawImage(img, 36, 52, 4, 12, 12, 8, 4, 12);
} else {
ctx.drawImage(img, 44, 20, 4, 12, 12, 8, 4, 12);
}

// store the skin, so it gets used on next update
playerSkins[skin] = canvas.toDataURL("image/png");
};

// trigger source image load
img.src = skin;

// return the default skin while the replacement loads
return defaultSkin;
},

isPlayerInCurrentLayer: function(player){
const mapLayer = layerMgr.getCurrentLayer();

Expand Down
Binary file added public/pics/crown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ See: [Incremental rendering](doc/incrementalrendering.md)
* Initial and incremental map rendering
* Param2 coloring
* Realtime rendering and map-updating
* Realtime player and world stats
* Realtime player and world stats, with SkinsDB support
* [Search](doc/search.md) bar
* Configurable layers (default: "Base" from y -16 to 160)
* POI [markers](doc/mapobjects.md) / [mod](doc/mod.md) integration
Expand All @@ -69,7 +69,6 @@ See: [Incremental rendering](doc/incrementalrendering.md)
## Planned Features

* Isometric view
* Skin support
* Route planning (via travelnets / trains)

# Supported map-databases
Expand Down
3 changes: 2 additions & 1 deletion web/minetest.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ type Player struct {
RTT float64 `json:"rtt"`
ProtocolVersion float64 `json:"protocol_version"`
Yaw float64 `json:"yaw"`
//TODO: stamina, skin, etc
Skin string `json:"skin"`
//TODO: stamina, armor, etc
}

type AirUtilsPlane struct {
Expand Down
4 changes: 4 additions & 0 deletions web/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ func Serve(ctx *app.App) {
mux.Handle("/metrics", promhttp.Handler())
}

if ctx.Config.Skins.EnableSkinsDB && len(ctx.Config.Skins.SkinsPath) > 0 {
mux.HandleFunc("/api/skins/", api.GetSkin)
}

ws := NewWS(ctx)
mux.Handle("/api/ws", ws)

Expand Down
46 changes: 46 additions & 0 deletions web/skins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package web

import (
"errors"
"net/http"
"os"
"strings"
)

func (api *Api) GetSkin(resp http.ResponseWriter, req *http.Request) {
filename := strings.TrimPrefix(req.URL.Path, "/api/skins/")
// there should be no remaining path elements - abort if there are any - prevent escaping into FS
if strings.Contains(filename, "/") {
resp.WriteHeader(http.StatusNotFound)
return
}

// we should only be serving PNG images
if !strings.HasSuffix(filename, ".png") {
resp.WriteHeader(http.StatusNotFound)
return
}

filePath := api.Context.Config.Skins.SkinsPath + "/" + filename

content, err := os.ReadFile(filePath)
// make file not found more sensible
if errors.Is(err, os.ErrNotExist) {
resp.WriteHeader(http.StatusNotFound)
return
} else if err != nil {
resp.WriteHeader(http.StatusInternalServerError)
return
}

// return the file content when available
if content != nil {
resp.Write(content)
resp.Header().Add("content-type", "image/png")
return
}

// fallback
resp.WriteHeader(http.StatusNotFound)
resp.Write([]byte(filename))
}

0 comments on commit 7e8dcdc

Please sign in to comment.