Add support for player skins via SkinsDB (#284)
* Add support for player skins via SkinsDB * Fix jshint complaints
This commit is contained in:
parent
67114e1ea3
commit
7e8dcdc77b
@ -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)
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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,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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -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`
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
BIN
public/pics/crown.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 336 B |
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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
46
web/skins.go
Normal 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))
|
||||
}
|
Loading…
Reference in New Issue
Block a user