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

View File

@ -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 {
@ -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"`
}

View File

@ -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
@ -63,6 +63,10 @@ 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"
}
}
```
@ -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`

View File

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

View File

@ -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);
@ -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],
@ -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();

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
* 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
@ -69,7 +69,6 @@ See: [Incremental rendering](doc/incrementalrendering.md)
## Planned Features
* Isometric view
* Skin support
* Route planning (via travelnets / trains)
# Supported map-databases

View File

@ -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 {

View File

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

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