First release

This commit is contained in:
eater 2020-03-03 17:37:35 +01:00
parent 948d6886a0
commit a2a4f5e891
Signed by: eater
GPG key ID: AD2560A0F84F0759
42 changed files with 5262 additions and 27873 deletions

4
.gitignore vendored
View file

@ -1,2 +1,6 @@
/.idea/
/public/tileset/mwmap/
/public/blob/*
!/public/blob/.gitkeep
/node_modules

View file

@ -1,2 +1,31 @@
# nwahmap
You N'wah really need a map to get around?
---
Nwahmap is a companion site for NwaHTTP the tes3mp plugin
## Setup
Please make sure [`git-lfs`](https://git-lfs.github.com/) is installed. this is used to store the tiles.
```bash
git clone git@git.cijber.net:teamnwah/nwahmap.git nwahmap
cd $_
# Extract tiles from storage/mwmap-tiles.txz
bin/extract-tiles.sh
# Extract 3d files and textures head renders (Optional)
script/export_nif.py <morrowind>/Data\ Files/Meshes/b <morrowind>/Data\ Files/Textures public/blob
# Install dependencies
yarn
webpack
```
Then place the files under `public` at `$TES3MP_HOME/www` and nwahttp will serve them for you if installed
---
Code licensed under AGPL-3.0-or-later
Tiles for morrowind licensed under cc-by-sa, source: <a href="https://mwmap.uesp.net/">uesp.net</a>
Dependencies may be licensed seperately.

7
bin/extract-tiles.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$(realpath "$0")")/..";
mkdir -p public/tileset/mwmap;
cd $_;
tar -xvf ../../../storage/mwmap-tiles.txz;

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"dependencies": {
"@babel/core": "^7.8.6",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-transform-react-jsx": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.8.3",
"@babel/preset-env": "^7.8.6",
"@babel/runtime": "^7.8.4",
"babel-loader": "^8.0.6",
"leaflet": "1.4.0",
"preact": "^10.3.3",
"three": "^0.114.0",
"webpack": "^4.41.6"
},
"devDependencies": {
"css-loader": "^2.1.1",
"file-loader": "^3.0.1",
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.1",
"webpack-cli": "^3.3.11"
}
}

0
public/blob/.gitkeep Normal file
View file

View file

@ -1,18 +1,142 @@
@font-face {
font-family: 'Pelagiad';
src: url("../blob/font/Pelagiad.ttf") format('truetype');
}
html, body {
margin: 0;
height: 100%;
}
.main {
main {
height: 100%;
width: 100%;
display: grid;
grid-template-columns: min-content auto;
grid-template-rows: min-content auto;
background-color: #222222;
color: rgb(223, 201, 159);
font-family: sans-serif;
}
.nwah-entry-marker {
width: 10px !important;
height: 10px !important;
}
.nwah-entry-marker.mark {
border: 5px solid black;
}
.nwah-player-marker {
border-bottom: 20px solid;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
background: none !important;
}
.head {
height: 20px;
grid-row: 1;
grid-column: 1;
grid-column-end: 3;
}
.head > h2 {
font-family: 'Pelagiad', sans-serif;
margin: 0;
padding: 0.3em;
font-size: 3em;
display: inline-block;
}
.player-list {
padding: 0 1em 1em;
}
.player {
background-color: #111111;
border: 1px solid rgb(223, 201, 159);
padding: 0.2em 1em;
margin-bottom: 1em;
}
.player > .name {
font-family: 'Pelagiad', sans-serif;
font-size: 2em;
padding: 0.1em;
text-align: center;
}
.player > .name > .dot {
display: inline-block;
border-radius: 100%;
width: 0.3em;
height: 0.3em;
margin-right: 0.3em;
vertical-align: middle;
}
.player > .name > .level {
font-size: 0.5em;
vertical-align: bottom;
}
.map {
height: calc(100% - 20px);
width: 100%;
grid-row: 2;
grid-column: 2;
}
.overlay {
position: absolute;
grid-row: 2;
grid-column: 1;
}
.leaflet-fade-anim .leaflet-tile, .leaflet-zoom-anim .leaflet-zoom-animated {
will-change: auto !important;
}
.leaflet-container {
background: transparent;
}
.player .location {
text-align: center;
font-size: 0.7em;
min-height: 2.2em;
}
.player .location > * {
vertical-align: middle;
}
.bar {
margin: 0.7em 0;
position: relative;
border: 1px solid rgb(223, 201, 159);
}
.bar .inner-bar {
overflow: visible;
height: 1.4em;
}
.bar .inner-text {
display: block;
width: 100%;
position: absolute;
padding: 0.2em;
text-align: center;
}
.bar.health .inner-bar {
background-color: darkred;
}
.bar.magicka .inner-bar {
background-color: darkblue;
}
.bar.fatigue .inner-bar {
background-color: darkgreen;
}

BIN
public/img/arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

BIN
public/img/house.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

View file

@ -3,19 +3,11 @@
<head>
<meta charset="UTF-8">
<title>N'wah map</title>
<link rel="stylesheet" href="third-party/leaflet/leaflet.css">
<link rel="stylesheet" href="css/leaflet.css">
<link rel="stylesheet" href="css/main.css">
<script src="third-party/leaflet/leaflet.js"></script>
<script defer src="js/main.js"></script>
<script defer src="js/dist/bundle.js"></script>
</head>
<body>
<div class="main">
<div class="head">
<span>N'wah map</span>
</div>
<div class="map">
</div>
</div>
</body>
</html>

10
public/js/dist/bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,81 +0,0 @@
const SETTINGS = {
cellSize: 2048,
cellOffsetY: 20,
cellOffsetX: 8,
};
const GameCRS = Object.assign({}, L.CRS, {
scale(zoom) {
return Math.pow(2, zoom);
},
zoom(scale) {
return Math.log(scale) / Math.LN2;
},
distance(latlng1, latlng2) {
let dx = latlng2.lng - latlng1.lng,
dy = latlng2.lat - latlng1.lat;
return Math.sqrt(dx * dx + dy * dy);
},
projection: L.Projection.LonLat,
wrapLng: [-128, 128],
wrapLat: [-128, 128],
transformation: L.transformation(1, 128, 1, 128),
infinite: false
});
const map = document.querySelector('.map');
const lMap = L.map(map, {
zoom: 0,
center: [0, 0],
crs: GameCRS
});
L.tileLayer('/tileset/mwmap/zoom{z}/vvardenfell-{x}-{y}-{z}.jpg', {
attribution: 'uesp.net',
minZoom: 0,
maxZoom: 7,
zoomOffset: 10,
}).addTo(lMap);
document.addEventListener('DOMContentLoaded', () => lMap.invalidateSize());
document.addEventListener('resize', () => lMap.invalidateSize());
setMarkers();
async function setMarkers() {
let markers = await fetch('http://yoko/tes3mp/assets/json/LiveMap.json');
for (let [name, details] of Object.entries(await markers.json())) {
L.marker(translateLocationGameToMap(details.x, details.y)).addTo(lMap)
}
}
function translateLocationGameToMap(x, y) {
const trans = [
(-y / SETTINGS.cellSize) + SETTINGS.cellOffsetY,
(x / SETTINGS.cellSize) + SETTINGS.cellOffsetX,
];
console.log(trans);
return trans;
}
class NwahMap {
constructor(settings) {
this.markers = {};
this.settings = settings;
}
init(map) {
}
translateGameLocToLatLng(x, y) {
return L.LatLng
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

225
script/export_nif.py Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
import os
import sys
from os import DirEntry
from typing import List, Tuple, Optional, Dict
from pyffi.formats.nif import NifFormat
from PIL import Image
import json
class Texture:
file: str
wrap_s: bool
wrap_t: bool
uv_set: int
def __init__(self, file: str, wrap_s: bool = True, wrap_t: bool = True, uv_set: int = 0):
self.file = file
self.wrap_s = wrap_s
self.wrap_t = wrap_t
self.uv_set = uv_set
def __dict__(self):
return {
'file': self.file,
'wrapS': self.wrap_s,
'wrapT': self.wrap_t,
'uvSet': self.uv_set,
}
class Shape:
name: str
vertices: List[Tuple[float, float, float]]
normals: List[Tuple[float, float, float]]
faces: List[Tuple[float, float, float]]
uv_sets: List[List[float]]
translation: Tuple[float, float, float]
rotation: List[Tuple[float, float, float]]
texture: Optional[Texture]
def __init__(self, name: str = ""):
self.name = name
self.texture = None
def __dict__(self):
return {
'name': self.name,
'vertices': self.vertices,
'normals': self.normals,
'faces': self.faces,
'uvSets': self.uv_sets,
'translation': self.translation,
'rotation': self.rotation,
'texture': self.texture.__dict__(),
}
class ShapeCollection:
name: str
shapes: List[Shape]
def __init__(self, name: str = ""):
self.name = name
self.shapes = []
def __dict__(self):
return {
'name': self.name,
'shapes': [s.__dict__() for s in self.shapes],
}
def export(path) -> List[ShapeCollection]:
data = NifFormat.Data()
with open(path, 'rb') as f:
data.inspect(f)
data.read(f)
objects = []
for root in data.roots:
name = root.name.decode('utf-8').lower()
# For this project, only hair and head matters
if 'hair' in name or 'head' in name:
sys.stderr.write("> " + name + "\n")
else:
sys.stderr.write(" " + name + "\n")
continue
if isinstance(root, NifFormat.NiNode):
objects.append(export_object(root))
# Some NiTriShape's are a root object
if isinstance(root, NifFormat.NiTriShape):
coll = ShapeCollection(name)
coll.shapes = [export_shape(root)]
objects.append(coll)
return objects
def export_object(root: NifFormat.NiNode) -> List[ShapeCollection]:
obj = ShapeCollection(root.name.decode('utf-8'))
for shape in root.tree():
if not isinstance(shape, NifFormat.NiTriShape):
continue
obj.shapes.append(export_shape(shape))
return obj
def export_shape(shape: NifFormat.NiTriShape) -> Shape:
vertices = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.vertices)]
faces = shape.data.get_triangles()
uv_sets = [[(coord.u, coord.v) for coord in uv_set] for uv_set in shape.data.uv_sets]
normals = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.normals)]
res_shape = Shape()
for property in shape.get_properties():
if isinstance(property, NifFormat.NiTexturingProperty):
res_shape.texture = export_texture(property.base_texture)
res_shape.name = shape.name.decode('utf-8')
res_shape.vertices = vertices
res_shape.faces = faces
res_shape.uv_sets = uv_sets
res_shape.normals = normals
res_shape.translation = (shape.translation.x, shape.translation.y, shape.translation.z)
res_shape.rotation = shape.rotation.as_list()
return res_shape
def export_texture(texture) -> Texture:
wrap_s = texture.clamp_mode in [2, 3]
wrap_t = texture.clamp_mode in [1, 3]
source = texture.source
return Texture(source.file_name.decode('utf-8'), wrap_s, wrap_t, texture.uv_set)
# Dumb file map allowing case-insensitive matching
# And Morrowind's fucky file resolving
class TextureMap:
path: str
files: Dict[str, DirEntry]
dds_used: int
original_dds: int
original_used: int
def __init__(self, path):
self.path = path
self.files = {}
self.dds_used = 0
self.original_dds = 0
self.original_used = 0
def build(self):
for item in os.scandir(self.path):
self.files[item.name.lower()] = item
def get(self, name):
lower_name = name.lower()
dds_instead = lower_name.rsplit('.', maxsplit=1)[0] + '.dds'
if dds_instead in self.files:
if dds_instead == lower_name:
self.original_dds += 1
else:
self.dds_used += 1
return self.files[dds_instead].path
self.original_used += 1
return self.files[lower_name].path
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <nif dir> <texture dir> <output dir>")
exit(1)
nif_dir = sys.argv[1]
texture_dir = sys.argv[2]
texture_dir_map = TextureMap(texture_dir)
texture_dir_map.build()
output_dir = sys.argv[3]
output_texture_dir = os.path.join(output_dir, 'texture')
output_shape_dir = os.path.join(output_dir, 'shape')
os.makedirs(output_shape_dir, exist_ok=True)
os.makedirs(output_texture_dir, exist_ok=True)
for item in os.scandir(nif_dir):
if not item.is_file():
continue
file_objects = export(item.path)
for file_object in file_objects:
name = file_object.name = file_object.name.lower()
for shape in file_object.shapes:
if shape.texture is not None:
new_texture_name = shape.texture.file.rsplit('.', maxsplit=1)[0] + '.png'
new_texture_name = new_texture_name.lower()
new_file_name = os.path.join(output_texture_dir, new_texture_name)
texture_image = Image.open(texture_dir_map.get(shape.texture.file))
texture_image.save(new_file_name, 'png')
shape.texture.file = new_texture_name
shape_file_name = os.path.join(output_shape_dir, name + '.json')
with open(shape_file_name, 'w') as sf:
json.dump(file_object.__dict__(), sf)
print(f"Textures: {texture_dir_map.original_dds} Orig. DDS, {texture_dir_map.dds_used} New DDS, {texture_dir_map.original_used} Orig. Non-DDS")
if __name__ == '__main__':
main()

24
src/GameCRS.js Normal file
View file

@ -0,0 +1,24 @@
import {CRS, Projection, transformation} from 'leaflet'
export const GameCRS = Object.assign({}, CRS, {
scale(zoom) {
return Math.pow(2, zoom);
},
zoom(scale) {
return Math.log(scale) / Math.LN2;
},
distance(latlng1, latlng2) {
let dx = latlng2.lng - latlng1.lng,
dy = latlng2.lat - latlng1.lat;
return Math.sqrt(dx * dx + dy * dy);
},
projection: Projection.LonLat,
// wrapLng: [-128, 128],
// wrapLat: [-128, 128],
transformation: transformation(1, 128, -1, 128),
infinite: false
});

26
src/NwahEntryMarker.js Normal file
View file

@ -0,0 +1,26 @@
import {Marker} from "leaflet";
import {divIcon} from "leaflet/dist/leaflet-src.esm";
export const NwahEntryMarker = Marker.extend({
options: {
color: '#fff',
icon: divIcon({
className: 'nwah-entry-marker',
iconSize: ['', '']
})
},
_initIcon() {
Marker.prototype._initIcon.call(this);
this._icon.src = "";
this._icon.style.backgroundColor = this.options.color;
},
mark() {
this._icon.classList.add('mark');
},
unmark() {
this._icon.classList.remove('mark');
},
});

10
src/NwahIcons.js Normal file
View file

@ -0,0 +1,10 @@
import {icon} from "leaflet";
export const NwahIcons = {
playerIcon: icon({
iconUrl: '/img/arrow.png',
iconSize: [15, 25],
iconAnchor: [7, 13],
className: 'nwah-player'
})
};

135
src/NwahMap.js Normal file
View file

@ -0,0 +1,135 @@
import {CRS, LatLng, LatLngBounds, map, tileLayer} from 'leaflet'
import {NwahIcons} from "./NwahIcons";
import {NwahPlayer} from "./NwahPlayer";
import {Component} from "preact";
import PlayerList from "./components/PlayerList";
export class NwahMap {
constructor({mapUrl, settings, icons, websocketUrl, playerList} = {}) {
this.players = {};
this.mapUrl = mapUrl;
this.playerList = playerList;
this.settings = settings || {
cellSize: 2048,
cellOffsetY: -20,
cellOffsetX: 8,
};
this.map = null;
this.follow = false;
this.icons = icons || NwahIcons;
this.websocketUrl = websocketUrl;
}
setFollow(follow) {
this.follow = follow;
}
init(element, component) {
this.map = map(element, {
zoom: 3,
center: this.gameCoordsToLatLng(0, 0),
crs: CRS.Simple
});
window.nwahMap = this;
this.map.setMaxBounds(new LatLngBounds(new LatLng(-255, 255), new LatLng(0, 0)));
this.map.invalidateSize();
this.tileLayer = tileLayer('/tileset/mwmap/zoom{z}/vvardenfell-{x}-{y}-{z}.jpg', {
attribution: 'uesp.net',
minZoom: 0,
maxZoom: 7,
zoomOffset: 10,
});
this.tileLayer.addTo(this.map);
if (this.websocketUrl) {
this.websocket = new WebSocket(this.websocketUrl);
this.websocket.onmessage = (message) => {
let event = JSON.parse(message.data);
if (event.type === "playerPosition") {
this.updatePositions(event.positions);
}
if (event.type === "fullPlayer") {
this.updatePlayers(event.players);
component.setState({players: event.players});
window.players = event.players;
}
if (this.follow) {
this.zoomOnPlayers();
}
}
}
}
zoomOnPlayers() {
let locations = Object.values(this.players)
.filter(x => x.isOutside)
.map(x => this.gameCoordsToLatLng(x.x, x.y));
if (locations.length === 0) {
return;
}
let bounds = new LatLngBounds(locations);
this.map.fitBounds(bounds);
}
updatePositions(positions) {
let knownPlayers = new Set(Object.keys(this.players));
for (let pos of positions) {
const name = pos.name;
knownPlayers.delete(name);
if (!(name in this.players)) {
this.players[name] = new NwahPlayer(name);
this.players[name].addTo(this);
}
let [x, y] = pos.position;
this.players[name].update(x, y, pos.rotation, false, pos.isOutside, pos.cell);
}
for (let name of knownPlayers) {
this.players[name].remove();
delete this.players[name];
}
}
updatePlayers(players) {
let knownPlayers = new Set(Object.keys(this.players));
for (let player of players) {
const name = player.name;
knownPlayers.delete(name);
if (!(name in this.players)) {
this.players[name] = new NwahPlayer(name);
this.players[name].addTo(this);
}
let {x, y} = player.position;
this.players[name].update(x, y, player.rotation.z, true, player.isOutside, player.cell);
}
for (let name of knownPlayers) {
this.players[name].remove();
delete this.players[name];
}
}
gameCoordsToLatLng(x, y) {
return new LatLng(
(y / this.settings.cellSize) - 128 + this.settings.cellOffsetY,
128 + ((x / this.settings.cellSize) + this.settings.cellOffsetX),
);
}
show({x, y}) {
this.map.panTo(this.gameCoordsToLatLng(x, y));
}
}

34
src/NwahMarker.js Normal file
View file

@ -0,0 +1,34 @@
import {Marker} from "leaflet";
import {divIcon} from "leaflet/dist/leaflet-src.esm";
export const NwahMarker = Marker.extend({
options: {
rotation: '0deg',
color: '#fff',
icon: divIcon({
className: 'nwah-player-marker',
iconSize: [0, 0],
iconAnchor: [5, 10],
})
},
setRotation(deg) {
this.options.rotation = deg;
},
_initIcon() {
Marker.prototype._initIcon.call(this);
this._icon.style.borderBottomColor = this.options.color || '#fff';
},
_setPos(pos) {
Marker.prototype._setPos.call(this, pos);
if (this.options.icon && this.options.icon.options && this.options.icon.options.iconAnchor) {
let iconAnchor = this.options.icon.options.iconAnchor;
this._icon.style.transformOrigin = `${iconAnchor[0]}px ${iconAnchor[1]}px`;
} else {
this._icon.style.transformOrigin = 'center bottom';
}
this._icon.style.transform += ` rotateZ(${this.options.rotation})`
}
});

64
src/NwahPlayer.js Normal file
View file

@ -0,0 +1,64 @@
import {NwahMarker} from "./NwahMarker";
import {colorFromName, normalizeRadians} from "./utils";
import {NwahPlayerTrail} from "./NwahPlayerTrail";
export class NwahPlayer {
constructor(name) {
this.x = 0;
this.y = 0;
this.isOutside = false;
this.color = colorFromName(name);
this.arrow = new NwahMarker([0, 0], {
color: this.color,
className: 'nwah-player'
});
this.name = name;
this.trail = new NwahPlayerTrail({color: this.color});
}
addTo(nwahMap) {
this.nwahMap = nwahMap;
this.map = nwahMap.map;
if (this.map) {
this.trail.addTo(nwahMap);
}
}
remove() {
if (!this.map) {
return;
}
this.map.removeLayer(this.arrow);
this.trail.remove();
}
update(x, y, rot, full = false, isOutside = true, cell = null) {
if (isOutside !== this.isOutside) {
if (isOutside) {
this.map.addLayer(this.arrow);
} else {
this.map.removeLayer(this.arrow);
}
this.isOutside = isOutside;
}
this.trail.push(x, y, isOutside, cell);
if (!isOutside) {
return;
}
this.x = x;
this.y = y;
this.rot = rot;
if (this.map) {
this.arrow.setRotation(normalizeRadians(this.rot) + 'rad');
this.arrow.setLatLng(this.nwahMap.gameCoordsToLatLng(this.x, this.y));
}
}
}

136
src/NwahPlayerTrail.js Normal file
View file

@ -0,0 +1,136 @@
import {polyline, marker} from "leaflet";
import {NwahEntryMarker} from "./NwahEntryMarker";
export class NwahPlayerTrail {
constructor({color = '#fff'} = {color: '#fff'}) {
this.nwahMap = null;
this.color = color;
this.currentLine = null;
this.items = [];
this.lastEntry = null;
this.isOutside = false;
this.lastPosition = null;
this.map = null;
this.expirationTime = 10 /* min */ * 60 * 1000;
}
addTo(nwahMap) {
this.nwahMap = nwahMap;
this.map = nwahMap.map;
this.items.forEach(line => line.item.addTo(map));
}
push(x, y, isOutside, cell) {
if (this.isOutside !== isOutside) {
if (this.isOutside) {
this.lastEntry = new NwahEntryMarker(this.nwahMap.gameCoordsToLatLng(this.lastPosition.x, this.lastPosition.y), {color: this.color});
if (this.map) {
this.lastEntry.addTo(this.map);
}
this.lastEntry.mark();
this.items.push({
type: 'entry',
item: this.lastEntry,
locations: [{time: performance.now()}],
marked: true,
});
} else {
if (this.lastEntry !== null) {
this.lastEntry.marked = false;
this.lastEntry.unmark();
}
}
}
this.isOutside = isOutside;
if (!isOutside) {
this.currentLine = null;
this.cull();
return;
}
if (this.lastPosition !== null) {
let xDist = (this.lastPosition.x - x);
let yDist = (this.lastPosition.y - y);
if (Math.sqrt((xDist * xDist) + (yDist * yDist)) > this.nwahMap.settings.cellSize) {
let teleportLine = polyline([this.nwahMap.gameCoordsToLatLng(this.lastPosition.x, this.lastPosition.y), this.nwahMap.gameCoordsToLatLng(x, y)], {
color: this.color,
opacity: 0.2,
});
teleportLine.addTo(this.map);
this.items.push({
type: 'teleport',
item: teleportLine,
locations: [{time: performance.now()}],
});
if (this.currentLine) {
this.currentLine = null;
}
}
}
if (this.currentLine === null) {
this.currentLine = polyline([], {
dashArray: '4',
color: this.color,
});
this.items.push(this.lastItem = {type: 'path', item: this.currentLine, locations: []});
if (this.map) {
this.currentLine.addTo(this.map);
}
}
this.lastPosition = {x, y};
let latLng = this.nwahMap.gameCoordsToLatLng(x, y);
this.lastItem.locations.push({time: performance.now(), location: latLng});
this.lastItem.item.addLatLng(latLng);
this.cull();
}
remove() {
let items = this.items;
this.items = [];
items.forEach(x => this.map.removeLayer(x.item));
}
cull() {
let i = 0;
let goodAfter = performance.now() - this.expirationTime;
for (; i < this.items.length; i++) {
let item = this.items[i];
let okLocs = [];
for (let j = 0; j < item.locations.length; j++) {
if (item.locations[j].time < goodAfter) {
continue;
}
okLocs.push(item.locations[j]);
}
if (okLocs.length === 0) {
if (item.marked) {
break;
}
this.map.removeLayer(item.item);
continue;
}
item.locations = okLocs;
if (item.type === 'path') {
item.item.setLatLngs(item.locations.map(x => x.location));
}
break;
}
this.items = this.items.slice(i);
}
}

13
src/components/Bar.js Normal file
View file

@ -0,0 +1,13 @@
import {Component} from "preact";
export default class Bar extends Component {
render({current, base, type}, state, context) {
const ceilCurrent = Math.ceil(current);
const perc = (ceilCurrent / base) * 100;
return <div className={`bar ${type}`}>
<div className="inner-bar" style={{width: Math.max(perc) + '%'}}>
<span className="inner-text">{ceilCurrent}/{base} ({Math.ceil(perc)}%)</span>
</div>
</div>
}
}

47
src/components/Player.js Normal file
View file

@ -0,0 +1,47 @@
import {Component} from "preact";
import Bar from "./Bar";
import {colorFromName} from "../utils";
export default class Player extends Component {
state = {
head: null,
color: null,
};
async componentDidMount() {
let player = this.props.player;
this.setState({color: colorFromName(player.name)});
try {
let render = await this.props.renderer.render([player.head, player.hair]);
this.setState({head: URL.createObjectURL(render)});
} catch (e) {
this.setState({head: null});
}
}
render({player, map}, {head, color}, context) {
return <div className="player">
<div className="name" onClick={() => map.show(player.position)}>
<span className="dot" style={{backgroundColor: color}}/>
{player.name + " "}
<span className="level">lvl. {player.level}</span>
</div>
{head ?
<div className="head">
<img src={head} alt=""/>
</div> : null}
<div className="location">
{player.isOutside ? null : <><img alt="[Inside]" src="img/house.png"/> <span>{player.cell}</span></>}
</div>
<div className="health">
<Bar current={player.health} base={player.healthBase} type="health"/>
</div>
<div className="magicka">
<Bar current={player.magicka} base={player.magickaBase} type="magicka"/>
</div>
<div className="fatigue">
<Bar current={player.fatigue} base={player.fatigueBase} type="fatigue"/>
</div>
</div>
}
}

View file

@ -0,0 +1,10 @@
import {Component} from "preact";
import Player from "./Player";
export default class PlayerList extends Component {
render({players, renderer, map}) {
return <div className="player-list">
{players.map(x => <Player key={x.name} player={x} renderer={renderer} map={map}/>)}
</div>
}
}

View file

@ -0,0 +1,46 @@
import PlayerList from "./PlayerList";
import {Component} from "preact";
import {NwahMap} from "../NwahMap";
import {HeadRenderer} from "../head-render/HeadRenderer";
export default class PlayerMap extends Component {
state = {
players: [],
mapElement: document.createElement('div'),
renderer: new HeadRenderer(),
follow: false,
};
constructor(props) {
super();
this.state.map = new NwahMap(props);
this.state.mapElement.classList.add('map');
}
componentDidMount() {
this.base.appendChild(this.state.mapElement);
this.state.map.init(this.state.mapElement, this);
this.state.renderer.init(200, 200);
}
componentDidUpdate() {
this.state.map.setFollow(this.state.follow);
}
render({}, {players, renderer, map, follow}, {}) {
return <main>
<div className="head">
<h2>N'wah map</h2>
<span>
<label>Follow players
<input type="checkbox" checked={follow}
onChange={(e) => this.setState({follow: e.target.checked})}/>
</label>
</span>
</div>
<div className="sidebar">
<PlayerList players={players} renderer={renderer} map={map}/>
</div>
</main>
}
}

View file

@ -0,0 +1,134 @@
import * as THREE from 'three';
import {Color, Group} from 'three';
import {ShapeCollection} from "./ShapeCollection";
export class HeadRenderer {
constructor() {
this.shapes = {};
this.renderQueue = [];
this.isRendering = false;
this.scene = null;
this.renderer = null;
this.camera = null;
this.canvas = null;
}
init(width = window.innerWidth, height = window.innerHeight) {
if (window.OffscreenCanvas !== undefined) {
this.canvas = new OffscreenCanvas(width, height);
} else {
let canvasElement = document.createElement("canvas");
canvasElement.width = width;
canvasElement.height = height;
this.canvas = canvasElement;
}
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
alpha: true
});
this.renderer.setSize(width, height);
this.camera = new THREE.PerspectiveCamera(70, width / height, 1, 200);
this.camera.position.z = 40;
this.scene = new THREE.Scene();
let light = new THREE.PointLight(0xffffff, 1);
light.position.set(1, 1, 2);
// Make sure light follow the camera
this.camera.add(light);
// Add camera to scene so light gets rendered
this.scene.add(this.camera);
let ambientLight = new THREE.AmbientLight(0x404040, 1);
this.scene.add(ambientLight);
}
async draw() {
this.renderer.render(this.scene, this.camera);
return new Promise(async (res, rej) => {
window.requestAnimationFrame(async () => {
if (this.canvas.convertToBlob) {
await this.canvas.convertToBlob({
type: 'image/png'
}).then(res, rej);
} else {
this.canvas.toBlob((blob) => {
if (blob) return res(blob);
return rej();
}, 'image/png')
}
})
})
}
render(objects) {
return new Promise(async (res) => {
this.renderQueue.push([objects, res]);
if (!this.isRendering) {
await this.startRender();
}
})
}
async startRender() {
if (this.isRendering) return;
this.isRendering = true;
this.clear();
while (this.renderQueue.length > 0) {
let [objects, res] = this.renderQueue.shift();
console.log(objects, res);
let shapePromises = objects.map(async x => {
let req = await fetch(`blob/shape/${x}.json`);
let json = await req.json();
let shapeColl = await ShapeCollection.forceLoad(json, 'blob/texture/');
this.add(shapeColl);
});
await Promise.all(shapePromises);
this.rotate(-5 * (Math.PI / 180), -30 * (Math.PI / 180), 0);
res(await this.draw());
this.clear();
}
this.isRendering = false;
}
add(shapeCollection) {
this.shapes[shapeCollection.id] = shapeCollection;
this.scene.add(shapeCollection.getGroup());
}
remove(shapeCollection) {
delete this.shapes[shapeCollection.id];
this.scene.remove(shapeCollection.getGroup());
}
clear() {
for (let shape of Object.values(this.shapes)) {
this.remove(shape)
}
}
expose() {
window.scene = this.scene;
window.camera = this.camera;
window.renderer = this.renderer;
}
rotate(x, y, z) {
for (let shape of Object.values(this.shapes)) {
let group = shape.getGroup();
group.rotateX(x);
group.rotateY(y);
group.rotateZ(z);
}
}
}

147
src/head-render/Shape.js Normal file
View file

@ -0,0 +1,147 @@
import {
Face3,
Geometry, Matrix4,
Mesh, MeshPhongMaterial,
RepeatWrapping,
TextureLoader,
Vector2,
Vector3,
} from 'three'
import {uuidv4} from "../utils";
export class Shape {
constructor({
vertices = [],
faces = [],
texture = null,
uvSets = [],
normals = [],
translation = [0, 0, 0],
rotation
} = {}, filePrefix = "", onLoad = () => {}, forceLoad = false) {
this.id = uuidv4();
this.vertices = vertices;
this.translation = translation;
this.rotation = rotation;
this.faces = faces;
this.textureInfo = texture;
this.normals = normals;
this.uvSets = uvSets;
this.filePrefix = filePrefix;
this.isLoaded = false;
this.onLoad = onLoad;
this.geometry = null;
this.mesh = null;
this.texture = null;
this.material = null;
if (forceLoad) {
this.getMaterial();
}
}
getMaterial() {
if (this.material) {
return this.material;
}
const params = {
color: 0xffffff,
transparent: true,
alphaTest: 0.2,
};
if (this.textureInfo) {
this.texture = new TextureLoader().load(this.filePrefix + this.textureInfo.file, () => {
this.isLoaded = true;
this.onLoad();
}, null, (err) => {
console.log(err)
});
if (this.textureInfo.wrapS) {
this.texture.wrapS = RepeatWrapping;
}
if (this.textureInfo.wrapT) {
this.texture.wrapT = RepeatWrapping;
}
this.texture.flipY = false;
params.map = this.texture;
}
return this.material = new MeshPhongMaterial(params);
}
getGeometry() {
if (this.geometry) {
return this.geometry;
}
const geometry = this.geometry = new Geometry();
for (let [x, y, z] of this.vertices) {
geometry.vertices.push(new Vector3(x, y, z))
}
const uvSets = [];
for (let i = 0; i < this.uvSets.length; i++) {
uvSets.push([]);
}
for (let i = 0; i < this.faces.length; i++) {
let [a, b, c] = this.faces[i];
// Add faces with normals
// Normals are stored per vector
geometry.faces.push(new Face3(a, b, c, [
new Vector3(this.normals[a][0], this.normals[a][1], this.normals[a][2]),
new Vector3(this.normals[b][0], this.normals[b][1], this.normals[b][2]),
new Vector3(this.normals[c][0], this.normals[c][1], this.normals[c][2])
]));
for (let j = 0; j < this.uvSets.length; j++) {
uvSets[j].push([
new Vector2(this.uvSets[j][a][0], this.uvSets[j][a][1]),
new Vector2(this.uvSets[j][b][0], this.uvSets[j][b][1]),
new Vector2(this.uvSets[j][c][0], this.uvSets[j][c][1]),
])
}
}
geometry.faceVertexUvs = uvSets;
// First rotate
let m = new Matrix4();
m.makeBasis(new Vector3(...this.rotation[0]), new Vector3(...this.rotation[1]), new Vector3(...this.rotation[2]));
geometry.applyMatrix4(m);
// Then translate
geometry.translate(this.translation[0], this.translation[1], this.translation[2]);
// Scale up a bit
geometry.scale(2, 2, 2);
// Rotate the face so it faces us \o/
geometry.rotateX(290 * (Math.PI / 180));
geometry.computeFaceNormals();
geometry.computeVertexNormals();
return this.geometry;
}
getMesh() {
if (this.mesh) {
return this.mesh;
}
this.mesh = new Mesh(this.getGeometry(), this.getMaterial());
return this.mesh;
}
}

View file

@ -0,0 +1,51 @@
import {Shape} from "./Shape";
import {Group} from "three";
import {uuidv4} from "../utils";
export class ShapeCollection {
constructor({shapes = [], name = ""}, filePrefix = "", onLoad = () => {
}, forceLoad = false) {
this.id = uuidv4();
let loaded = 0;
this.shapes = shapes.map((s) => new Shape(s, filePrefix, () => {
loaded++;
if (loaded === shapes.length) {
this.isLoaded = true;
this.onLoad();
}
}, forceLoad));
this.isLoaded = false;
this.onLoad = onLoad;
this.group = null;
if (this.shapes.length === 0) {
this.onLoad();
this.isLoaded = true;
}
}
static forceLoad(shapeCollection, filePrefix) {
return new Promise((res, rej) => {
let shapeColl = new ShapeCollection(shapeCollection, filePrefix, () => {
res(shapeColl)
}, true)
});
}
getGroup() {
if (this.group) {
return this.group;
}
// Create group for all shapes
this.group = new Group();
for (let shape of this.shapes) {
this.group.add(shape.getMesh())
}
return this.group
}
}

6
src/index.js Normal file
View file

@ -0,0 +1,6 @@
import {render} from 'preact'
import PlayerMap from "./components/PlayerMap";
window.addEventListener('load', async () => {
render(<PlayerMap websocketUrl={`ws://${location.host}/ws/players`}/>, document.body);
});

44
src/utils.js Normal file
View file

@ -0,0 +1,44 @@
export const PI2 = Math.PI * 2;
export function normalizeRadians(rad) {
return ((rad + Math.PI) % PI2) - Math.PI
}
// I found this on the internet
export function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
const COLORS = [
'#fc5c65',
'#fd9644',
'#fed330',
'#26de81',
'#2bcbba',
'#eb3b5a',
'#fa8231',
'#f7b731',
'#20bf6b',
'#0fb9b1',
'#45aaf2',
'#4b7bec',
'#a55eea',
'#d1d8e0',
'#778ca3',
'#2d98da',
'#3867d6',
'#8854d0',
'#a5b1c2',
'#4b6584',
];
export function colorFromName(name) {
let nr = name
.split("")
.map(x => (x.charCodeAt(0) - 97))
.reduce((a, b) => a + b);
return COLORS[nr % COLORS.length];
}

53
webpack.config.js Normal file
View file

@ -0,0 +1,53 @@
const path = require('path');
const webpack = require("webpack");
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
path: path.resolve(__dirname, 'public/js/dist'),
filename: 'bundle.js'
},
plugins: [
new webpack.ProvidePlugin({
React: 'react'
})
],
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: [["@babel/preset-env", {
"targets": {
"browsers": ["last 2 versions"]
},
exclude: ['transform-regenerator'],
modules: false
}]],
plugins: [
"@babel/plugin-transform-react-jsx",
'@babel/plugin-proposal-class-properties',
'@babel/plugin-transform-runtime'
]
}
}
},
{
test: /\.js$/,
use: ["source-map-loader"],
enforce: "pre"
}
]
},
"resolve": {
"alias": {
"react": "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat",
},
}
};

3853
yarn.lock Normal file

File diff suppressed because it is too large Load diff