First release

master
eater 4 years ago
parent 948d6886a0
commit a2a4f5e891
Signed by: eater
GPG Key ID: AD2560A0F84F0759

4
.gitignore vendored

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

@ -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.

@ -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;

@ -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"
}
}

@ -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);
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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

@ -1,21 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>N'wah map</title>
<link rel="stylesheet" href="third-party/leaflet/leaflet.css">
<link rel="stylesheet" href="css/main.css">
<script src="third-party/leaflet/leaflet.js"></script>
<script defer src="js/main.js"></script>
<meta charset="UTF-8">
<title>N'wah map</title>
<link rel="stylesheet" href="css/leaflet.css">
<link rel="stylesheet" href="css/main.css">
<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>

File diff suppressed because one or more lines are too long

@ -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

@ -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()

@ -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
});

@ -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');
},
});

@ -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'
})
};

@ -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));
}
}

@ -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})`
}
});

@ -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));
}
}
}

@ -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);
}
}

@ -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>
}
}

@ -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>
}
}

@ -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>
}
}

@ -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>
}
}

@ -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);
}
}
}

@ -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;
}
}

@ -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
}
}

@ -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);
});

@ -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];
}

@ -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",
},
}
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save