From 7e8dcdc77b502a1be4494a9debbf294a8be4b4bc Mon Sep 17 00:00:00 2001 From: Kenneth Watson Date: Fri, 16 Dec 2022 09:07:47 +0200 Subject: [PATCH] Add support for player skins via SkinsDB (#284) * Add support for player skins via SkinsDB * Fix jshint complaints --- app/config.go | 6 ++ app/types.go | 6 ++ doc/config.md | 16 +++- public/css/custom.css | 18 ++++ public/js/map/overlays/PlayerOverlay.js | 111 ++++++++++++++++-------- public/pics/crown.png | Bin 0 -> 336 bytes readme.md | 3 +- web/minetest.go | 3 +- web/serve.go | 4 + web/skins.go | 46 ++++++++++ 10 files changed, 174 insertions(+), 39 deletions(-) create mode 100644 public/pics/crown.png create mode 100644 web/skins.go 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 ? `moderator` : ""; - for (let i=0; i${moderator} ${player.name}`; + info += "
"; - if (player.hp % 2 == 1) - html += ""; + for (let i = 0; i < Math.floor(player.hp / 2); i++) + info += "health"; - html += "
"; + if (player.hp % 2 === 1) + info += "health"; - for (let i=0; i RTT: ${Math.floor(player.rtt*1000)} ms
Protocol version: ${player.protocol_version} `; - return html; + info = `
${info}
`; + + let portrait = `${player.name}`; + + return `
${portrait}${info}
`; }, - 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: `
${player.name} - ${player.name} + ${indicator ? `${player.name}` : ''}
`, 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 0000000000000000000000000000000000000000..0bf44bbb382b84cf819f6b1f316eadc030fa4ec6 GIT binary patch literal 336 zcmV-W0k8gvP)P5oISyeO=#XT>eo zA%V+WCR`Q}3Q|VK#+d*A85kHD!#?5*ctYkf6870wHDn*6JAi@V>QsnDtN0li3F||b zXTohUYS5An2 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)) +}