First release
@ -1,2 +1,6 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/public/tileset/mwmap/
|
/public/tileset/mwmap/
|
||||||
|
/public/blob/*
|
||||||
|
!/public/blob/.gitkeep
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
@ -1,2 +1,31 @@
|
|||||||
# nwahmap
|
# nwahmap
|
||||||
You N'wah really need a map to get around?
|
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 {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 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 {
|
.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 {
|
.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%;
|
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;
|
||||||
}
|
}
|
After Width: | Height: | Size: 910 B |
After Width: | Height: | Size: 508 B |
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 696 B |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 618 B |
@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|