Add support for player skins via SkinsDB (#284)

* Add support for player skins via SkinsDB

* Fix jshint complaints
This commit is contained in:
Kenneth Watson 2022-12-16 09:07:47 +02:00 committed by GitHub
parent 67114e1ea3
commit 7e8dcdc77b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 174 additions and 39 deletions

View File

@ -104,6 +104,11 @@ func ParseConfig(filename string) (*Config, error) {
"mapserver_player", "mapserver_player",
} }
skins := SkinsConfig{
EnableSkinsDB: false,
SkinsPath: "",
}
cfg := Config{ cfg := Config{
ConfigVersion: 1, ConfigVersion: 1,
Port: 8080, Port: 8080,
@ -123,6 +128,7 @@ func ParseConfig(filename string) (*Config, error) {
MapObjects: &mapobjs, MapObjects: &mapobjs,
MapBlockAccessorCfg: &mapblockaccessor, MapBlockAccessorCfg: &mapblockaccessor,
DefaultOverlays: defaultoverlays, DefaultOverlays: defaultoverlays,
Skins: &skins,
} }
info, err := os.Stat(filename) info, err := os.Stat(filename)

View File

@ -23,6 +23,7 @@ type Config struct {
MapObjects *MapObjectConfig `json:"mapobjects"` MapObjects *MapObjectConfig `json:"mapobjects"`
MapBlockAccessorCfg *MapBlockAccessorConfig `json:"mapblockaccessor"` MapBlockAccessorCfg *MapBlockAccessorConfig `json:"mapblockaccessor"`
DefaultOverlays []string `json:"defaultoverlays"` DefaultOverlays []string `json:"defaultoverlays"`
Skins *SkinsConfig `json:"skins"`
} }
type MapBlockAccessorConfig struct { type MapBlockAccessorConfig struct {
@ -71,3 +72,8 @@ type WebApiConfig struct {
//mod http bridge secret //mod http bridge secret
SecretKey string `json:"secretkey"` SecretKey string `json:"secretkey"`
} }
type SkinsConfig struct {
EnableSkinsDB bool `json:"enableskinsdb"`
SkinsPath string `json:"skinspath"`
}

View File

@ -14,7 +14,7 @@ scifi_nodes:slope_vent 120 120 120
scifi_nodes:white2 240 240 240 scifi_nodes:white2 240 240 240
``` ```
Default colors, see: [colors.txt](../server/static/colors.txt) Default colors, see: [colors directory](../public/colors)
## Configuration json ## Configuration json
@ -63,6 +63,10 @@ The mapserver will generate a fresh `mapserver.json` if there is none at startup
"expiretime": "10s", "expiretime": "10s",
"purgetime": "15s", "purgetime": "15s",
"maxitems": 5000 "maxitems": 5000
},
"skins": {
"enableskinsdb": true,
"skinspath": "/path/to/minetest/mods/skinsdb/textures"
} }
} }
``` ```
@ -119,3 +123,11 @@ Enables the [Prometheus](./prometheus.md) metrics endpoint
#### mapblockaccessor.maxitems #### mapblockaccessor.maxitems
Number of mapblocks to keep in memory, dial this down if you have memory issues 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`

View File

@ -30,3 +30,21 @@ body {
margin-left: -100px !important; margin-left: -100px !important;
margin-top: -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;
}

View File

@ -1,21 +1,16 @@
import wsChannel from '../../WebSocketChannel.js'; import wsChannel from '../../WebSocketChannel.js';
import layerMgr from '../../LayerManager.js'; import layerMgr from '../../LayerManager.js';
const defaultSkin = "pics/sam.png";
let players = []; let players = [];
let playerSkins = {};
//update players all the time //update players all the time
wsChannel.addListener("minetest-info", function(info){ wsChannel.addListener("minetest-info", function(info){
players = info.players || []; 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({ export default L.LayerGroup.extend({
initialize: function() { initialize: function() {
L.LayerGroup.prototype.initialize.call(this); L.LayerGroup.prototype.initialize.call(this);
@ -27,54 +22,55 @@ export default L.LayerGroup.extend({
}, },
createPopup: function(player) { createPopup: function(player) {
let html = "<b>" + player.name + "</b>"; // moderators get a small crown icon
html += "<hr>"; let moderator = player.moderator ? `<img src="pics/crown.png" alt="moderator" title="moderator">` : "";
let info = `<b>${moderator} ${player.name}</b>`;
info += "<hr>";
for (let i = 0; i < Math.floor(player.hp / 2); i++) for (let i = 0; i < Math.floor(player.hp / 2); i++)
html += "<img src='pics/heart.png'>"; info += "<img src='pics/heart.png' alt='health'>";
if (player.hp % 2 == 1) if (player.hp % 2 === 1)
html += "<img src='pics/heart_half.png'>"; info += "<img src='pics/heart_half.png' alt='health'>";
html += "<br>"; info += "<br>";
for (let i = 0; i < Math.floor(player.breath / 2); i++) for (let i = 0; i < Math.floor(player.breath / 2); i++)
html += "<img src='pics/bubble.png'>"; info += "<img src='pics/bubble.png' alt='breath'>";
if (player.breath % 2 == 1) if (player.breath % 2 === 1)
html += "<img src='pics/bubble_half.png'>"; info += "<img src='pics/bubble_half.png' alt='breath'>";
html += ` info += `
<br> <br>
<b>RTT:</b> ${Math.floor(player.rtt*1000)} ms <b>RTT:</b> ${Math.floor(player.rtt*1000)} ms
<br> <br>
<b>Protocol version:</b> ${player.protocol_version} <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)}); 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; return marker;
}, },
getIcon: function(player) { getIcon: function(player) {
/* const icon = this.getSkin(player);
compatibility with mapserver_mod without `yaw` attribute - value will be 0. // compatibility with mapserver_mod without `yaw` attribute - value will be 0.
if a player manages to look exactly north, the indicator will also disappear const indicator = player.yaw === 0 ? false : player.velocity.x !== 0 || player.velocity.z !== 0 ? 'pics/sam_dir_move.png' : 'pics/sam_dir.png';
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';
return L.divIcon({ return L.divIcon({
html: `<div style="display:inline-block;width:48px;height:48px"> 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="${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>`, </div>`,
className: '', // don't use leaflet default of a white block className: '', // don't use leaflet default of a white block
iconSize: [48, 48], iconSize: [48, 48],
@ -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){ isPlayerInCurrentLayer: function(player){
const mapLayer = layerMgr.getCurrentLayer(); const mapLayer = layerMgr.getCurrentLayer();

BIN
public/pics/crown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

View File

@ -58,7 +58,7 @@ See: [Incremental rendering](doc/incrementalrendering.md)
* Initial and incremental map rendering * Initial and incremental map rendering
* Param2 coloring * Param2 coloring
* Realtime rendering and map-updating * Realtime rendering and map-updating
* Realtime player and world stats * Realtime player and world stats, with SkinsDB support
* [Search](doc/search.md) bar * [Search](doc/search.md) bar
* Configurable layers (default: "Base" from y -16 to 160) * Configurable layers (default: "Base" from y -16 to 160)
* POI [markers](doc/mapobjects.md) / [mod](doc/mod.md) integration * POI [markers](doc/mapobjects.md) / [mod](doc/mod.md) integration
@ -69,7 +69,6 @@ See: [Incremental rendering](doc/incrementalrendering.md)
## Planned Features ## Planned Features
* Isometric view * Isometric view
* Skin support
* Route planning (via travelnets / trains) * Route planning (via travelnets / trains)
# Supported map-databases # Supported map-databases

View File

@ -51,7 +51,8 @@ type Player struct {
RTT float64 `json:"rtt"` RTT float64 `json:"rtt"`
ProtocolVersion float64 `json:"protocol_version"` ProtocolVersion float64 `json:"protocol_version"`
Yaw float64 `json:"yaw"` Yaw float64 `json:"yaw"`
//TODO: stamina, skin, etc Skin string `json:"skin"`
//TODO: stamina, armor, etc
} }
type AirUtilsPlane struct { type AirUtilsPlane struct {

View File

@ -53,6 +53,10 @@ func Serve(ctx *app.App) {
mux.Handle("/metrics", promhttp.Handler()) 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) ws := NewWS(ctx)
mux.Handle("/api/ws", ws) mux.Handle("/api/ws", ws)

46
web/skins.go Normal file
View File

@ -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))
}