diff --git a/app/config.go b/app/config.go
index ee16d72..44bf05f 100644
--- a/app/config.go
+++ b/app/config.go
@@ -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)
diff --git a/app/types.go b/app/types.go
index 41c6c2b..e686159 100644
--- a/app/types.go
+++ b/app/types.go
@@ -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"`
+}
diff --git a/doc/config.md b/doc/config.md
index 9d97a9f..d96bc20 100644
--- a/doc/config.md
+++ b/doc/config.md
@@ -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`
diff --git a/public/css/custom.css b/public/css/custom.css
index 2cd8b1c..84029a1 100644
--- a/public/css/custom.css
+++ b/public/css/custom.css
@@ -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;
+}
diff --git a/public/js/map/overlays/PlayerOverlay.js b/public/js/map/overlays/PlayerOverlay.js
index eaf9c00..dd6e060 100644
--- a/public/js/map/overlays/PlayerOverlay.js
+++ b/public/js/map/overlays/PlayerOverlay.js
@@ -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 = "" + player.name + "";
- html += "
";
+ createPopup: function(player) {
+ // moderators get a small crown icon
+ let moderator = player.moderator ? `` : "";
- for (let i=0; i";
+ let info = `${moderator} ${player.name}`;
+ info += "
";
- if (player.hp % 2 == 1)
- html += "";
+ for (let i = 0; i < Math.floor(player.hp / 2); i++)
+ info += "";
- html += "
";
+ if (player.hp % 2 === 1)
+ info += "";
- for (let i=0; i";
+ info += "
";
- if (player.breath % 2 == 1)
- html += "";
+ for (let i = 0; i < Math.floor(player.breath / 2); i++)
+ info += "";
- html += `
+ if (player.breath % 2 === 1)
+ info += "";
+
+ info += `
RTT: ${Math.floor(player.rtt*1000)} ms
Protocol version: ${player.protocol_version}
`;
- return html;
+ info = `${info}
`;
+
+ let portrait = ``;
+
+ return ``;
},
- 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: `
-
+ ${indicator ? `
` : ''}
`,
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();
diff --git a/public/pics/crown.png b/public/pics/crown.png
new file mode 100644
index 0000000..0bf44bb
Binary files /dev/null and b/public/pics/crown.png differ
diff --git a/readme.md b/readme.md
index b2e66e1..26a5ff1 100644
--- a/readme.md
+++ b/readme.md
@@ -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
diff --git a/web/minetest.go b/web/minetest.go
index 32d91f8..f21dddb 100644
--- a/web/minetest.go
+++ b/web/minetest.go
@@ -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 {
diff --git a/web/serve.go b/web/serve.go
index 8dde681..46b8975 100644
--- a/web/serve.go
+++ b/web/serve.go
@@ -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)
diff --git a/web/skins.go b/web/skins.go
new file mode 100644
index 0000000..1bc639f
--- /dev/null
+++ b/web/skins.go
@@ -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))
+}