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",
|
"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)
|
||||||
|
@ -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"`
|
||||||
|
}
|
||||||
|
@ -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`
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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);
|
||||||
@ -26,55 +21,56 @@ export default L.LayerGroup.extend({
|
|||||||
this.onMinetestUpdate = this.onMinetestUpdate.bind(this);
|
this.onMinetestUpdate = this.onMinetestUpdate.bind(this);
|
||||||
},
|
},
|
||||||
|
|
||||||
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">` : "";
|
||||||
|
|
||||||
for (let i=0; i<Math.floor(player.hp / 2); i++)
|
let info = `<b>${moderator} ${player.name}</b>`;
|
||||||
html += "<img src='pics/heart.png'>";
|
info += "<hr>";
|
||||||
|
|
||||||
if (player.hp % 2 == 1)
|
for (let i = 0; i < Math.floor(player.hp / 2); i++)
|
||||||
html += "<img src='pics/heart_half.png'>";
|
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++)
|
info += "<br>";
|
||||||
html += "<img src='pics/bubble.png'>";
|
|
||||||
|
|
||||||
if (player.breath % 2 == 1)
|
for (let i = 0; i < Math.floor(player.breath / 2); i++)
|
||||||
html += "<img src='pics/bubble_half.png'>";
|
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>
|
<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
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
|
* 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
|
||||||
|
@ -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 {
|
||||||
|
@ -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
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