use local version of prismarine-viewer for futher modifications & improvements

This commit is contained in:
Vitaly 2023-08-12 17:40:07 +03:00
commit b6833955b0
39 changed files with 25010 additions and 1 deletions

View file

@ -66,7 +66,7 @@
"mocha": "^10.2.0",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"prismarine-viewer": "github:PrismarineJS/prismarine-viewer",
"prismarine-viewer": "./prismarine-viewer",
"process": "github:PrismarineJS/node-process",
"stream-browserify": "^3.0.0",
"three": "0.128.0",

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- "."
- "prismarine-viewer"

38
prismarine-viewer/index.d.ts vendored Normal file
View file

@ -0,0 +1,38 @@
import {Bot} from "mineflayer";
export function mineflayer(bot: Bot, settings: {
viewDistance?: number;
firstPerson?: boolean;
port?: number;
prefix?: string;
});
export function standalone(options: {
version: versions;
world: (x: number, y: number, z: number) => 0 | 1;
center?: Vec3;
viewDistance?: number;
port?: number;
prefix?: string;
});
export function headless(bot: Bot, settings: {
viewDistance?: number;
output?: string;
frames?: number;
width?: number;
height?: number;
logFFMPEG?: boolean;
jpegOption: any;
});
export const viewer: {
Viewer: any;
WorldView: any;
MapControls: any;
Entitiy: any;
getBufferFromStream: (stream: any) => Promise<Buffer>;
};
export const supportedVersions: versions[];
export type versions = '1.8.8' | '1.9.4' | '1.10.2' | '1.11.2' | '1.12.2' | '1.13.2' | '1.14.4' | '1.15.2' | '1.16.1' | '1.16.4' | '1.17.1' | '1.18.1';

View file

@ -0,0 +1,7 @@
module.exports = {
mineflayer: require('./lib/mineflayer'),
standalone: require('./lib/standalone'),
headless: require('./lib/headless'),
viewer: require('./viewer'),
supportedVersions: ['1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.1', '1.16.4', '1.17.1', '1.18.1']
}

View file

@ -0,0 +1,5 @@
module.exports = {
launch: {
args: ['--no-sandbox', '--disable-setuid-sandbox']
}
}

View file

@ -0,0 +1,4 @@
module.exports = {
preset: 'jest-puppeteer',
testRegex: './*\\.test\\.js$'
}

View file

@ -0,0 +1,12 @@
const path = require('path')
const compression = require('compression')
const express = require('express')
function setupRoutes (app, prefix = '') {
app.use(compression())
app.use(prefix + '/', express.static(path.join(__dirname, '../public')))
}
module.exports = {
setupRoutes
}

View file

@ -0,0 +1,135 @@
/* global THREE */
function safeRequire (path) {
try {
return require(path)
} catch (e) {
return {}
}
}
const { spawn } = require('child_process')
const net = require('net')
global.THREE = require('three')
global.Worker = require('worker_threads').Worker
const { createCanvas } = safeRequire('node-canvas-webgl/lib')
const { WorldView, Viewer, getBufferFromStream } = require('../viewer')
module.exports = (bot, { viewDistance = 6, output = 'output.mp4', frames = -1, width = 512, height = 512, logFFMPEG = false, jpegOptions }) => {
const canvas = createCanvas(width, height)
const renderer = new THREE.WebGLRenderer({ canvas })
const viewer = new Viewer(renderer)
viewer.setVersion(bot.version)
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
// Load world
const worldView = new WorldView(bot.world, viewDistance, bot.entity.position)
viewer.listen(worldView)
worldView.init(bot.entity.position)
function botPosition () {
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
worldView.updatePosition(bot.entity.position)
}
// Render loop streaming
const rtmpOutput = output.startsWith('rtmp://')
const ffmpegOutput = output.endsWith('mp4')
let client = null
if (rtmpOutput) {
const fps = 20
const gop = fps * 2
const gopMin = fps
const probesize = '42M'
const cbr = '1000k'
const threads = 4
const args = `-y -r ${fps} -probesize ${probesize} -i pipe:0 -f flv -ac 2 -ar 44100 -vcodec libx264 -g ${gop} -keyint_min ${gopMin} -b:v ${cbr} -minrate ${cbr} -maxrate ${cbr} -pix_fmt yuv420p -s 1280x720 -preset ultrafast -tune film -threads ${threads} -strict normal -bufsize ${cbr} ${output}`.split(' ')
client = spawn('ffmpeg', args)
if (logFFMPEG) {
client.stdout.on('data', (data) => {
console.log(`stdout: ${data}`)
})
client.stderr.on('data', (data) => {
console.error(`stderr: ${data}`)
})
}
update()
} else if (ffmpegOutput) {
client = spawn('ffmpeg', ['-y', '-i', 'pipe:0', output])
if (logFFMPEG) {
client.stdout.on('data', (data) => {
console.log(`stdout: ${data}`)
})
client.stderr.on('data', (data) => {
console.error(`stderr: ${data}`)
})
}
update()
} else {
const [host, port] = output.split(':')
client = new net.Socket()
client.connect(parseInt(port, 10), host, () => {
update()
})
}
// Force end of stream
bot.on('end', () => { frames = 0 })
let idx = 0
function update () {
viewer.update()
renderer.render(viewer.scene, viewer.camera)
const imageStream = canvas.createJPEGStream({
bufsize: 4096,
quality: 1,
progressive: false,
...jpegOptions
})
if (rtmpOutput || ffmpegOutput) {
imageStream.on('data', (chunk) => {
if (client.stdin.writable) {
client.stdin.write(chunk)
} else {
console.log('Error: ffmpeg stdin closed!')
}
})
imageStream.on('end', () => {
idx++
if (idx < frames || frames < 0) {
setTimeout(update, 16)
} else {
console.log('done streaming')
client.stdin.end()
}
})
imageStream.on('error', () => { })
} else {
getBufferFromStream(imageStream).then((buffer) => {
const sizebuff = new Uint8Array(4)
const view = new DataView(sizebuff.buffer, 0)
view.setUint32(0, buffer.length, true)
client.write(sizebuff)
client.write(buffer)
idx++
if (idx < frames || frames < 0) {
setTimeout(update, 16)
} else {
client.end()
}
}).catch(() => {})
}
}
// Register events
bot.on('move', botPosition)
worldView.listenToBot(bot)
return client
}

View file

@ -0,0 +1,71 @@
/* global THREE */
global.THREE = require('three')
const TWEEN = require('@tweenjs/tween.js')
require('three/examples/js/controls/OrbitControls')
const { Viewer, Entity } = require('../viewer')
const io = require('socket.io-client')
const socket = io()
let firstPositionUpdate = true
const renderer = new THREE.WebGLRenderer()
renderer.setPixelRatio(window.devicePixelRatio || 1)
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
const viewer = new Viewer(renderer)
let controls = new THREE.OrbitControls(viewer.camera, renderer.domElement)
function animate () {
window.requestAnimationFrame(animate)
if (controls) controls.update()
viewer.update()
renderer.render(viewer.scene, viewer.camera)
}
animate()
window.addEventListener('resize', () => {
viewer.camera.aspect = window.innerWidth / window.innerHeight
viewer.camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
socket.on('version', (version) => {
viewer.setVersion(version)
firstPositionUpdate = true
viewer.listen(socket)
let botMesh
socket.on('position', ({ pos, addMesh, yaw, pitch }) => {
if (yaw !== undefined && pitch !== undefined) {
if (controls) {
controls.dispose()
controls = null
}
viewer.setFirstPersonCamera(pos, yaw, pitch)
return
}
if (pos.y > 0 && firstPositionUpdate) {
controls.target.set(pos.x, pos.y, pos.z)
viewer.camera.position.set(pos.x, pos.y + 20, pos.z + 20)
controls.update()
firstPositionUpdate = false
}
if (addMesh) {
if (!botMesh) {
botMesh = new Entity('1.16.4', 'player', viewer.scene).mesh
viewer.scene.add(botMesh)
}
new TWEEN.Tween(botMesh.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
const da = (yaw - botMesh.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(botMesh.rotation).to({ y: botMesh.rotation.y + dy }, 50).start()
}
})
})

View file

@ -0,0 +1,91 @@
const EventEmitter = require('events')
const { WorldView } = require('../viewer')
module.exports = (bot, { viewDistance = 6, firstPerson = false, port = 3000, prefix = '' }) => {
const express = require('express')
const app = express()
const http = require('http').createServer(app)
const io = require('socket.io')(http, { path: prefix + '/socket.io' })
const { setupRoutes } = require('./common')
setupRoutes(app, prefix)
const sockets = []
const primitives = {}
bot.viewer = new EventEmitter()
bot.viewer.erase = (id) => {
delete primitives[id]
for (const socket of sockets) {
socket.emit('primitive', { id })
}
}
bot.viewer.drawBoxGrid = (id, start, end, color = 'aqua') => {
primitives[id] = { type: 'boxgrid', id, start, end, color }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
bot.viewer.drawLine = (id, points, color = 0xff0000) => {
primitives[id] = { type: 'line', id, points, color }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
bot.viewer.drawPoints = (id, points, color = 0xff0000, size = 5) => {
primitives[id] = { type: 'points', id, points, color, size }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
io.on('connection', (socket) => {
socket.emit('version', bot.version)
sockets.push(socket)
const worldView = new WorldView(bot.world, viewDistance, bot.entity.position, socket)
worldView.init(bot.entity.position)
worldView.on('blockClicked', (block, face, button) => {
bot.viewer.emit('blockClicked', block, face, button)
})
for (const id in primitives) {
socket.emit('primitive', primitives[id])
}
function botPosition () {
const packet = { pos: bot.entity.position, yaw: bot.entity.yaw, addMesh: true }
if (firstPerson) {
packet.pitch = bot.entity.pitch
}
socket.emit('position', packet)
worldView.updatePosition(bot.entity.position)
}
bot.on('move', botPosition)
worldView.listenToBot(bot)
socket.on('disconnect', () => {
bot.removeListener('move', botPosition)
worldView.removeListenersFromBot(bot)
sockets.splice(sockets.indexOf(socket), 1)
})
})
http.listen(port, () => {
console.log(`Prismarine viewer web server running on *:${port}`)
})
bot.viewer.close = () => {
http.close()
for (const socket of sockets) {
socket.disconnect()
}
}
}

View file

@ -0,0 +1,52 @@
const { Vec3 } = require('vec3')
module.exports = ({ version, world, center = new Vec3(0, 0, 0), viewDistance = 4, port = 3000, prefix = '' }) => {
const express = require('express')
const app = express()
const http = require('http').createServer(app)
const io = require('socket.io')(http)
const { setupRoutes } = require('./common')
setupRoutes(app, prefix)
const sockets = []
const viewer = { world }
async function sendChunks (sockets) {
const cx = Math.floor(center.x / 16)
const cz = Math.floor(center.z / 16)
for (let x = cx - viewDistance; x <= cx + viewDistance; x++) {
for (let z = cz - viewDistance; z <= cz + viewDistance; z++) {
const chunk = (await viewer.world.getColumn(x, z)).toJson()
for (const socket of sockets) {
socket.emit('loadChunk', { x: x * 16, z: z * 16, chunk })
}
}
}
}
viewer.update = () => {
sendChunks(sockets)
}
io.on('connection', (socket) => {
socket.emit('version', version)
sockets.push(socket)
sendChunks([socket])
socket.emit('position', { pos: center, addMesh: false })
socket.on('disconnect', () => {
sockets.splice(sockets.indexOf(socket), 1)
})
})
http.listen(port, () => {
console.log(`Prismarine viewer web server running on *:${port}`)
})
return viewer
}

View file

@ -0,0 +1,57 @@
{
"name": "prismarine-viewer",
"version": "1.25.0",
"description": "Web based viewer",
"main": "index.js",
"scripts": {
"test": "jest --verbose --runInBand --forceExit",
"pretest": "npm run lint",
"lint": "standard",
"postinstall": "node viewer/prerender.js && webpack",
"fix": "standard --fix"
},
"author": "PrismarineJS",
"license": "MIT",
"standard": {
"ignore": [
"examples/electron/",
"examples/exporter/",
"examples/standalone/",
"examples/web_client/"
]
},
"dependencies": {
"@tweenjs/tween.js": "^20.0.3",
"compression": "^1.7.4",
"express": "^4.17.1",
"minecraft-data": "^3.0.0",
"prismarine-block": "^1.7.3",
"prismarine-chunk": "^1.22.0",
"prismarine-world": "^3.3.1",
"socket.io": "^4.0.0",
"socket.io-client": "^4.0.0",
"three": "0.128.0",
"three.meshline": "^1.3.0",
"vec3": "^0.1.7"
},
"devDependencies": {
"assert": "^2.0.0",
"buffer": "^6.0.3",
"process": "^0.11.10",
"minecraft-assets": "^1.9.0",
"jest": "^27.0.4",
"jest-puppeteer": "^6.0.0",
"minecraft-wrap": "^1.3.0",
"mineflayer": "^4.0.0",
"mineflayer-pathfinder": "^2.0.0",
"prismarine-schematic": "^1.2.0",
"minecrafthawkeye": "^1.2.5",
"prismarine-viewer": "file:./",
"puppeteer": "^16.0.0",
"standard": "^17.0.0",
"webpack": "^5.10.2",
"webpack-cli": "^5.1.1",
"canvas": "^2.11.2",
"fs-extra": "^11.0.0"
}
}

View file

@ -0,0 +1,20 @@
const nodeIndex = parseInt(process.env.CIRCLE_NODE_INDEX)
const nodeTotal = parseInt(process.env.CIRCLE_NODE_TOTAL)
const parallel = process.env.CIRCLE_NODE_INDEX !== undefined && process.env.CIRCLE_NODE_TOTAL !== undefined
const viewer = require('../')
// expected values :
// (0,4,10) -> (0,2)
// (1,4,10) -> (3,5)
// (2,4,10) -> (6,8)
// (3,4,10) -> (9,9)
function testedRange (nodeIndex, nodeTotal, numberOfVersions) {
const nbFirsts = Math.ceil(numberOfVersions / nodeTotal)
if (nodeIndex === (nodeTotal - 1)) { return { firstVersion: nbFirsts * nodeIndex, lastVersion: numberOfVersions - 1 } }
return { firstVersion: nodeIndex * nbFirsts, lastVersion: (nodeIndex + 1) * nbFirsts - 1 }
}
console.log({ nodeIndex, nodeTotal, versions: viewer.supportedVersions.length })
const { firstVersion, lastVersion } = parallel ? testedRange(nodeIndex, nodeTotal, viewer.supportedVersions.length) : { firstVersion: 0, lastVersion: viewer.supportedVersions.length - 1 }
module.exports = { firstVersion, lastVersion }

View file

@ -0,0 +1,12 @@
/* eslint-env jest */
/* global page */
describe('Google', () => {
beforeAll(async () => {
await page.goto('https://google.com')
}, 20000)
it('should display "google" text on page', async () => {
await expect(page).toMatch('google')
}, 20000)
})

View file

@ -0,0 +1,109 @@
/* eslint-env jest */
/* global page */
const supportedVersions = require('../').supportedVersions
const path = require('path')
const MC_SERVER_PATH = path.join(__dirname, 'server')
const os = require('os')
const Wrap = require('minecraft-wrap').Wrap
const { firstVersion, lastVersion } = require('./parallel')
const download = require('minecraft-wrap').download
supportedVersions.forEach(function (supportedVersion, i) {
if (!(i >= firstVersion && i <= lastVersion)) { return }
const PORT = Math.round(30000 + Math.random() * 20000)
const mcData = require('minecraft-data')(supportedVersion)
const version = mcData.version
const MC_SERVER_JAR_DIR = process.env.MC_SERVER_JAR_DIR || os.tmpdir()
const MC_SERVER_JAR = MC_SERVER_JAR_DIR + '/minecraft_server.' + version.minecraftVersion + '.jar'
const wrap = new Wrap(MC_SERVER_JAR, MC_SERVER_PATH + '_' + supportedVersion, {
minMem: 1024,
maxMem: 1024
})
wrap.on('line', function (line) {
console.log(line)
})
describe('client ' + version.minecraftVersion, function () {
beforeAll(download.bind(null, version.minecraftVersion, MC_SERVER_JAR), 3 * 60 * 1000)
afterAll(function (done) {
wrap.deleteServerData(function (err) {
if (err) { console.log(err) }
done(err)
})
}, 3 * 60 * 1000)
describe('offline', function () {
beforeAll(function (done) {
console.log(new Date() + 'starting server ' + version.minecraftVersion)
wrap.startServer({
'online-mode': 'false',
'server-port': PORT,
motd: 'test1234',
'max-players': 120
}, function (err) {
if (err) { console.log(err) }
console.log(new Date() + 'started server ' + version.minecraftVersion)
done(err)
})
}, 3 * 60 * 1000)
afterAll(function (done) {
console.log(new Date() + 'stopping server' + version.minecraftVersion)
wrap.stopServer(function (err) {
if (err) { console.log(err) }
console.log(new Date() + 'stopped server ' + version.minecraftVersion)
done(err)
})
}, 3 * 60 * 1000)
it('doesn\'t crash', function (done) {
console.log('test')
done()
})
it('starts the viewer', function (done) {
const mineflayer = require('mineflayer')
const mineflayerViewer = require('../').mineflayer
setTimeout(() => done(new Error('too slow !!!')), 180000)
const bot = mineflayer.createBot({
username: 'Bot',
port: PORT,
version: supportedVersion
})
bot.once('spawn', () => {
mineflayerViewer(bot, { port: 3000 })
function exit (err) {
bot.viewer.close()
bot.end()
done(err)
}
page.goto('http://localhost:3000').then(() => {
page.on('console', msg => console.log('PAGE LOG:', msg.text()))
page.on('error', err => {
exit(err)
})
page.on('pageerror', pageerr => {
exit(pageerr)
})
setTimeout(() => {
page.screenshot({ path: path.join(__dirname, `test_${supportedVersion}.png`) }).then(() => exit()).catch(err => exit(err))
}, 120000)
}).catch(err => exit(err))
})
}, 180000)
})
})
})

View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"module": "commonjs",
"strictNullChecks": true
},
"files": [
"index.d.ts"
]
}

2
prismarine-viewer/viewer/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
public/*
!public/.empty

View file

@ -0,0 +1,7 @@
module.exports = {
Viewer: require('./lib/viewer').Viewer,
WorldView: require('./lib/worldView').WorldView,
MapControls: require('./lib/controls').MapControls,
Entity: require('./lib/entity/Entity'),
getBufferFromStream: require('./lib/simpleUtils').getBufferFromStream
}

View file

@ -0,0 +1,47 @@
const fs = require('fs')
const { Canvas, Image } = require('canvas')
const path = require('path')
function nextPowerOfTwo (n) {
if (n === 0) return 1
n--
n |= n >> 1
n |= n >> 2
n |= n >> 4
n |= n >> 8
n |= n >> 16
return n + 1
}
function makeTextureAtlas (mcAssets) {
const blocksTexturePath = path.join(mcAssets.directory, '/blocks')
const textureFiles = fs.readdirSync(blocksTexturePath).filter(file => file.endsWith('.png'))
const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length)))
const tileSize = 16
const imgSize = texSize * tileSize
const canvas = new Canvas(imgSize, imgSize, 'png')
const g = canvas.getContext('2d')
const texturesIndex = {}
for (const i in textureFiles) {
const x = (i % texSize) * tileSize
const y = Math.floor(i / texSize) * tileSize
const name = textureFiles[i].split('.')[0]
texturesIndex[name] = { u: x / imgSize, v: y / imgSize, su: tileSize / imgSize, sv: tileSize / imgSize }
const img = new Image()
img.src = 'data:image/png;base64,' + fs.readFileSync(path.join(blocksTexturePath, textureFiles[i]), 'base64')
g.drawImage(img, 0, 0, 16, 16, x, y, 16, 16)
}
return { image: canvas.toBuffer(), canvas, json: { size: tileSize / imgSize, textures: texturesIndex } }
}
module.exports = {
makeTextureAtlas
}

923
prismarine-viewer/viewer/lib/controls.js vendored Normal file
View file

@ -0,0 +1,923 @@
/* eslint-disable */
// Similar to THREE MapControls with more Minecraft-like
// controls.
// Defaults:
// Shift = Move Down, Space = Move Up
// W/Z - north, S - south, A/Q - west, D - east
const STATE = {
NONE: -1,
ROTATE: 0,
DOLLY: 1,
PAN: 2,
TOUCH_ROTATE: 3,
TOUCH_PAN: 4,
TOUCH_DOLLY_PAN: 5,
TOUCH_DOLLY_ROTATE: 6
}
class MapControls {
constructor(camera, domElement) {
this.enabled = true
this.object = camera
this.element = domElement
// Mouse buttons
this.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }
// Touch fingers
this.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }
this.controlMap = {
MOVE_FORWARD: ['KeyW', 'KeyZ'],
MOVE_BACKWARD: 'KeyS',
MOVE_LEFT: ['KeyA', 'KeyQ'],
MOVE_RIGHT: 'KeyD',
MOVE_DOWN: 'ShiftLeft',
MOVE_UP: 'Space'
}
this.target = new THREE.Vector3()
// How far you can dolly in and out ( PerspectiveCamera only )
this.minDistance = 0
this.maxDistance = Infinity
// How far you can zoom in and out ( OrthographicCamera only )
this.minZoom = 0
this.maxZoom = Infinity
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to Math.PI radians.
this.minPolarAngle = 0 // radians
this.maxPolarAngle = Math.PI // radians
// How far you can orbit horizontally, upper and lower limits.
// If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
this.minAzimuthAngle = -Infinity // radians
this.maxAzimuthAngle = Infinity // radians
// Set to true to enable damping (inertia)
// If damping is enabled, you must call controls.update() in your animation loop
this.enableDamping = false
this.dampingFactor = 0.01
// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
// Set to false to disable zooming
this.enableZoom = true
this.enableTouchZoom = true
this.zoomSpeed = 1.0
// Set to false to disable rotating
this.enableRotate = true
this.enableTouchRotate = true
this.rotateSpeed = 1.0
// Set to false to disable panning
this.enablePan = true
this.enableTouchPan = true
this.panSpeed = 1.0
this.screenSpacePanning = false // if false, pan orthogonal to world-space direction camera.up
this.keyPanDistance = 32 // how far to pan
this.keyPanSpeed = 10 // pixels moved per arrow key push
this.verticalTranslationSpeed = 0.5 // how much Y increments moving up/down
this.keyDowns = []
// State-related stuff
this.changeEvent = { type: 'change' }
this.startEvent = { type: 'start' }
this.endEvent = { type: 'end' }
this.state = STATE.NONE
this.EPS = 0.000001
this.spherical = new THREE.Spherical()
this.sphericalDelta = new THREE.Spherical()
this.scale = 1
this.panOffset = new THREE.Vector3()
this.zoomChanged = false
this.rotateStart = new THREE.Vector2()
this.rotateEnd = new THREE.Vector2()
this.rotateDelta = new THREE.Vector2()
this.panStart = new THREE.Vector2()
this.panEnd = new THREE.Vector2()
this.panDelta = new THREE.Vector2()
this.dollyStart = new THREE.Vector2()
this.dollyEnd = new THREE.Vector2()
this.dollyDelta = new THREE.Vector2()
// for reset
this.target0 = this.target.clone()
this.position0 = this.object.position.clone()
this.zoom0 = this.object.zoom
this.ticks = 0
// register event handlers
this.onPointerMove = this.onPointerMove.bind(this)
this.onPointerUp = this.onPointerUp.bind(this)
this.onPointerDown = this.onPointerDown.bind(this)
this.onMouseWheel = this.onMouseWheel.bind(this)
this.onTouchStart = this.onTouchStart.bind(this)
this.onTouchEnd = this.onTouchEnd.bind(this)
this.onTouchMove = this.onTouchMove.bind(this)
this.onContextMenu = this.onContextMenu.bind(this)
this.onKeyDown = this.onKeyDown.bind(this)
this.onKeyUp = this.onKeyUp.bind(this)
this.registerHandlers()
}
//#region Public Methods
setRotationOrigin(position) {
this.target = position.clone()
}
unsetRotationOrigin() {
this.target = new THREE.Vector3()
}
getPolarAngle() {
return this.spherical.phi
}
getAzimuthalAngle() {
return this.spherical.theta
}
saveState() {
this.target0.copy(this.target)
this.position0.copy(this.object.position)
this.zoom0 = this.object.zoom
}
reset() {
this.target.copy(this.target0)
this.object.position.copy(this.position0)
this.object.zoom = this.zoom0
this.object.updateProjectionMatrix()
this.dispatchEvent(this.changeEvent)
this.update(true)
this.state = STATE.NONE
}
// this method is exposed, but perhaps it would be better if we can make it private...
update(force) {
// tick controls if called from render loop
if (!force) {
this.tickControls()
}
var offset = new THREE.Vector3()
// so camera.up is the orbit axis
var quat = new THREE.Quaternion().setFromUnitVectors(this.object.up, new THREE.Vector3(0, 1, 0))
var quatInverse = quat.clone().invert()
var lastPosition = new THREE.Vector3()
var lastQuaternion = new THREE.Quaternion()
var twoPI = 2 * Math.PI
var position = this.object.position
offset.copy(position).sub(this.target)
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat)
// angle from z-axis around y-axis
this.spherical.setFromVector3(offset)
if (this.autoRotate && this.state === STATE.NONE) {
this.rotateLeft(this.getAutoRotationAngle())
}
if (this.enableDamping) {
this.spherical.theta += this.sphericalDelta.theta * this.dampingFactor
this.spherical.phi += this.sphericalDelta.phi * this.dampingFactor
} else {
this.spherical.theta += this.sphericalDelta.theta
this.spherical.phi += this.sphericalDelta.phi
}
// restrict theta to be between desired limits
var min = this.minAzimuthAngle
var max = this.maxAzimuthAngle
if (isFinite(min) && isFinite(max)) {
if (min < - Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI
if (max < - Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI
if (min < max) {
this.spherical.theta = Math.max(min, Math.min(max, this.spherical.theta))
} else {
this.spherical.theta = (this.spherical.theta > (min + max) / 2) ?
Math.max(min, this.spherical.theta) :
Math.min(max, this.spherical.theta)
}
}
// restrict phi to be between desired limits
this.spherical.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.spherical.phi))
this.spherical.makeSafe()
this.spherical.radius *= this.scale
// restrict radius to be between desired limits
this.spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, this.spherical.radius))
// move target to panned location
if (this.enableDamping === true) {
this.target.addScaledVector(this.panOffset, this.dampingFactor)
} else {
this.target.add(this.panOffset)
}
offset.setFromSpherical(this.spherical)
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse)
position.copy(this.target).add(offset)
this.object.lookAt(this.target)
if (this.enableDamping === true) {
this.sphericalDelta.theta *= (1 - this.dampingFactor)
this.sphericalDelta.phi *= (1 - this.dampingFactor)
this.panOffset.multiplyScalar(1 - this.dampingFactor)
} else {
this.sphericalDelta.set(0, 0, 0)
this.panOffset.set(0, 0, 0)
}
this.scale = 1
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (this.zoomChanged ||
lastPosition.distanceToSquared(this.object.position) > this.EPS ||
8 * (1 - lastQuaternion.dot(this.object.quaternion)) > this.EPS) {
this.dispatchEvent(this.changeEvent)
lastPosition.copy(this.object.position)
lastQuaternion.copy(this.object.quaternion)
this.zoomChanged = false
return true
}
return false
}
//#endregion
//#region Orbit Controls
getAutoRotationAngle() {
return 2 * Math.PI / 60 / 60 * this.autoRotateSpeed
}
getZoomScale() {
return Math.pow(0.95, this.zoomSpeed)
}
rotateLeft(angle) {
this.sphericalDelta.theta -= angle
}
rotateUp(angle) {
this.sphericalDelta.phi -= angle
}
panLeft(distance, objectMatrix) {
let v = new THREE.Vector3()
v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix
v.multiplyScalar(- distance)
this.panOffset.add(v)
}
panUp(distance, objectMatrix) {
let v = new THREE.Vector3()
if (this.screenSpacePanning === true) {
v.setFromMatrixColumn(objectMatrix, 1)
} else {
v.setFromMatrixColumn(objectMatrix, 0)
v.crossVectors(this.object.up, v)
}
v.multiplyScalar(distance)
this.panOffset.add(v)
}
// Patch - translate Y
translateY(delta) {
this.panOffset.y += delta
}
// deltaX and deltaY are in pixels; right and down are positive
pan(deltaX, deltaY, distance) {
let offset = new THREE.Vector3()
if (this.object.isPerspectiveCamera) {
// perspective
var position = this.object.position
offset.copy(position).sub(this.target)
var targetDistance = offset.length()
// half of the fov is center to top of screen
targetDistance *= Math.tan((this.object.fov / 2) * Math.PI / 180.0)
targetDistance = distance || targetDistance
// we use only clientHeight here so aspect ratio does not distort speed
this.panLeft(2 * deltaX * targetDistance / this.element.clientHeight, this.object.matrix)
this.panUp(2 * deltaY * targetDistance / this.element.clientHeight, this.object.matrix)
} else if (this.object.isOrthographicCamera) {
// orthographic
this.panLeft(deltaX * (this.object.right - this.object.left) / this.object.zoom / this.element.clientWidth, this.object.matrix)
this.panUp(deltaY * (this.object.top - this.object.bottom) / this.object.zoom / this.element.clientHeight, this.object.matrix)
} else {
// camera neither orthographic nor perspective
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.')
this.enablePan = false
}
}
dollyOut(dollyScale) {
if (this.object.isPerspectiveCamera) {
this.scale /= dollyScale
} else if (this.object.isOrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom * dollyScale))
this.object.updateProjectionMatrix()
this.zoomChanged = true
} else {
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
this.enableZoom = false
}
}
dollyIn(dollyScale) {
if (this.object.isPerspectiveCamera) {
this.scale *= dollyScale
} else if (this.object.isOrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom / dollyScale))
this.object.updateProjectionMatrix()
this.zoomChanged = true
} else {
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
this.enableZoom = false
}
}
//#endregion
//#region Event Callbacks - update the object state
handleMouseDownRotate(event) {
this.rotateStart.set(event.clientX, event.clientY)
}
handleMouseDownDolly(event) {
this.dollyStart.set(event.clientX, event.clientY)
}
handleMouseDownPan(event) {
this.panStart.set(event.clientX, event.clientY)
}
handleMouseMoveRotate(event) {
this.rotateEnd.set(event.clientX, event.clientY)
this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(this.rotateSpeed)
this.rotateLeft(2 * Math.PI * this.rotateDelta.x / this.element.clientHeight) // yes, height
this.rotateUp(2 * Math.PI * this.rotateDelta.y / this.element.clientHeight)
this.rotateStart.copy(this.rotateEnd)
this.update(true)
}
handleMouseMoveDolly(event) {
this.dollyEnd.set(event.clientX, event.clientY)
this.dollyDelta.subVectors(this.dollyEnd, this.dollyStart)
if (this.dollyDelta.y > 0) {
this.dollyOut(this.getZoomScale())
} else if (this.dollyDelta.y < 0) {
this.dollyIn(this.getZoomScale())
}
this.dollyStart.copy(this.dollyEnd)
this.update(true)
}
handleMouseMovePan(event) {
this.panEnd.set(event.clientX, event.clientY)
this.panDelta.subVectors(this.panEnd, this.panStart).multiplyScalar(this.panSpeed)
this.pan(this.panDelta.x, this.panDelta.y)
this.panStart.copy(this.panEnd)
this.update(true)
}
handleMouseUp(/*event*/) {
// no-op
}
handleMouseWheel(event) {
if (event.deltaY < 0) {
this.dollyIn(this.getZoomScale())
} else if (event.deltaY > 0) {
this.dollyOut(this.getZoomScale())
}
this.update(true)
}
//#endregion
//#region Mouse/Keyboard handlers
// Called when the cursor location has moved
onPointerMove(event) {
if (!this.enabled || (this.state == STATE.NONE)) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseMove(event)
break
// TODO touch
}
}
// Called when the cursor is no longer behind held
onPointerUp(event) {
if (!this.enabled) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseUp(event)
break
// TODO touch
}
}
// On left click or tap
onPointerDown(event) {
if (!this.enabled) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseDown(event)
break
// TODO touch
}
}
onMouseDown(event) {
// Prevent the browser from scrolling.
event.preventDefault()
// Manually set the focus since calling preventDefault above
// prevents the browser from setting it automatically.
this.element.focus ? this.element.focus() : window.focus()
var mouseAction
switch (event.button) {
case 0:
mouseAction = this.mouseButtons.LEFT
break
case 1:
mouseAction = this.mouseButtons.MIDDLE
break
case 2:
mouseAction = this.mouseButtons.RIGHT
break
default:
mouseAction = - 1
}
switch (mouseAction) {
case THREE.MOUSE.DOLLY:
if (this.enableZoom === false) return
this.handleMouseDownDolly(event)
this.state = STATE.DOLLY
break
case THREE.MOUSE.ROTATE:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (this.enablePan === false) return
this.handleMouseDownPan(event)
this.state = STATE.PAN
} else {
if (this.enableRotate === false) return
this.handleMouseDownRotate(event)
this.state = STATE.ROTATE
}
break
case THREE.MOUSE.PAN:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (this.enableRotate === false) return
this.handleMouseDownRotate(event)
this.state = STATE.ROTATE
} else {
if (this.enablePan === false) return
this.handleMouseDownPan(event)
this.state = STATE.PAN
}
break
default:
this.state = STATE.NONE
}
}
onMouseMove(event) {
if (this.enabled === false) return
event.preventDefault()
switch (this.state) {
case STATE.ROTATE:
if (this.enableRotate === false) return
this.handleMouseMoveRotate(event)
break
case STATE.DOLLY:
if (this.enableZoom === false) return
this.handleMouseMoveDolly(event)
break
case STATE.PAN:
if (this.enablePan === false) return
this.handleMouseMovePan(event)
break
}
}
onMouseUp(event) {
this.state = STATE.NONE
}
onMouseWheel(event) {
if (this.enabled === false || this.enableZoom === false || (this.state !== STATE.NONE && this.state !== STATE.ROTATE)) return
event.preventDefault()
event.stopPropagation()
this.dispatchEvent(this.startEvent)
this.handleMouseWheel(event)
this.dispatchEvent(this.endEvent)
}
//#endregion
//#region Touch handlers
handleTouchStartRotate(event) {
if (event.touches.length == 1) {
this.rotateStart.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.rotateStart.set(x, y)
}
}
handleTouchStartPan(event) {
if (event.touches.length == 1) {
this.panStart.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.panStart.set(x, y)
}
}
handleTouchStartDolly(event) {
var dx = event.touches[0].pageX - event.touches[1].pageX
var dy = event.touches[0].pageY - event.touches[1].pageY
var distance = Math.sqrt(dx * dx + dy * dy)
this.dollyStart.set(0, distance)
}
handleTouchStartDollyPan(event) {
if (this.enableTouchZoom) this.handleTouchStartDolly(event)
if (this.enableTouchPan) this.handleTouchStartPan(event)
}
handleTouchStartDollyRotate(event) {
if (this.enableTouchZoom) this.handleTouchStartDolly(event)
if (this.enableTouchRotate) this.handleTouchStartRotate(event)
}
handleTouchMoveRotate(event) {
if (event.touches.length == 1) {
this.rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.rotateEnd.set(x, y)
}
this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(this.rotateSpeed)
this.rotateLeft(2 * Math.PI * this.rotateDelta.x / this.element.clientHeight) // yes, height
this.rotateUp(2 * Math.PI * this.rotateDelta.y / this.element.clientHeight)
this.rotateStart.copy(this.rotateEnd)
}
handleTouchMovePan(event) {
if (event.touches.length == 1) {
this.panEnd.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.panEnd.set(x, y)
}
this.panDelta.subVectors(this.panEnd, this.panStart).multiplyScalar(this.panSpeed)
this.pan(this.panDelta.x, this.panDelta.y)
this.panStart.copy(this.panEnd)
}
handleTouchMoveDolly(event) {
var dx = event.touches[0].pageX - event.touches[1].pageX
var dy = event.touches[0].pageY - event.touches[1].pageY
var distance = Math.sqrt(dx * dx + dy * dy)
this.dollyEnd.set(0, distance)
this.dollyDelta.set(0, Math.pow(this.dollyEnd.y / this.dollyStart.y, this.zoomSpeed))
this.dollyOut(this.dollyDelta.y)
this.dollyStart.copy(this.dollyEnd)
}
handleTouchMoveDollyPan(event) {
if (this.enableTouchZoom) this.handleTouchMoveDolly(event)
if (this.enableTouchPan) this.handleTouchMovePan(event)
}
handleTouchMoveDollyRotate(event) {
if (this.enableTouchZoom) this.handleTouchMoveDolly(event)
if (this.enableTouchRotate) this.handleTouchMoveRotate(event)
}
handleTouchEnd( /*event*/) {
// no-op
}
//#endregion
tickControls() {
const control = this.controlMap
for (var keyCode of this.keyDowns) {
if (control.MOVE_FORWARD.includes(keyCode)) {
this.pan(0, this.keyPanSpeed, this.keyPanDistance)
} else if (control.MOVE_BACKWARD.includes(keyCode)) {
this.pan(0, -this.keyPanSpeed, this.keyPanDistance)
} else if (control.MOVE_LEFT.includes(keyCode)) {
this.pan(this.keyPanSpeed, 0, this.keyPanDistance)
} else if (control.MOVE_RIGHT.includes(keyCode)) {
this.pan(-this.keyPanSpeed, 0, this.keyPanDistance)
} else if (control.MOVE_UP.includes(keyCode)) {
this.translateY(+this.verticalTranslationSpeed)
} else if (control.MOVE_DOWN.includes(keyCode)) {
this.translateY(-this.verticalTranslationSpeed)
}
}
}
onKeyDown(e) {
if (!this.enabled) return
if (e.code && !this.keyDowns.includes(e.code)) {
this.keyDowns.push(e.code)
// console.debug('[control] Key down: ', this.keyDowns)
}
}
onKeyUp(event) {
// console.log('[control] Key up: ', event.code, this.keyDowns)
this.keyDowns = this.keyDowns.filter(code => code != event.code)
}
onTouchStart(event) {
if (this.enabled === false) return
event.preventDefault() // prevent scrolling
switch (event.touches.length) {
case 1:
switch (this.touches.ONE) {
case THREE.TOUCH.ROTATE:
if (this.enableTouchRotate === false) return
this.handleTouchStartRotate(event)
this.state = STATE.TOUCH_ROTATE
break
case THREE.TOUCH.PAN:
if (this.enableTouchPan === false) return
this.handleTouchStartPan(event)
this.state = STATE.TOUCH_PAN
break
default:
this.state = STATE.NONE
}
break
case 2:
switch (this.touches.TWO) {
case THREE.TOUCH.DOLLY_PAN:
if (this.enableTouchZoom === false && this.enableTouchPan === false) return
this.handleTouchStartDollyPan(event)
this.state = STATE.TOUCH_DOLLY_PAN
break
case THREE.TOUCH.DOLLY_ROTATE:
if (this.enableTouchZoom === false && this.enableTouchRotate === false) return
this.handleTouchStartDollyRotate(event)
this.state = STATE.TOUCH_DOLLY_ROTATE
break
default:
this.state = STATE.NONE
}
break
default:
this.state = STATE.NONE
}
if (this.state !== STATE.NONE) {
this.dispatchEvent(this.startEvent)
}
}
onTouchMove(event) {
if (this.enabled === false) return
event.preventDefault() // prevent scrolling
event.stopPropagation()
switch (this.state) {
case STATE.TOUCH_ROTATE:
if (this.enableTouchRotate === false) return
this.handleTouchMoveRotate(event)
this.update()
break
case STATE.TOUCH_PAN:
if (this.enableTouchPan === false) return
this.handleTouchMovePan(event)
this.update()
break
case STATE.TOUCH_DOLLY_PAN:
if (this.enableTouchZoom === false && this.enableTouchPan === false) return
this.handleTouchMoveDollyPan(event)
this.update()
break
case STATE.TOUCH_DOLLY_ROTATE:
if (this.enableTouchZoom === false && this.enableTouchRotate === false) return
this.handleTouchMoveDollyRotate(event)
this.update()
break
default:
this.state = STATE.NONE
}
}
onTouchEnd(event) {
if (this.enabled === false) return
this.handleTouchEnd(event)
this.dispatchEvent(this.endEvent)
this.state = STATE.NONE
}
onContextMenu(event) {
// Disable context menu
if (this.enabled) event.preventDefault()
}
registerHandlers() {
this.element.addEventListener('pointermove', this.onPointerMove, false, {passive: true})
this.element.addEventListener('pointerup', this.onPointerUp, false, {passive: true})
this.element.addEventListener('pointerdown', this.onPointerDown, false, {passive: true})
this.element.addEventListener('wheel', this.onMouseWheel, true, {passive: true})
this.element.addEventListener('touchstart', this.onTouchStart, false, {passive: true})
this.element.addEventListener('touchend', this.onTouchEnd, false, {passive: true})
this.element.addEventListener('touchmove', this.onTouchMove, false, {passive: true})
this.element.ownerDocument.addEventListener('contextmenu', this.onContextMenu, false, {passive: true})
this.element.ownerDocument.addEventListener('keydown', this.onKeyDown, false, {passive: true})
this.element.ownerDocument.addEventListener('keyup', this.onKeyUp, false, {passive: true})
console.log('[controls] registered handlers', this.element)
}
unregisterHandlers() {
this.element.removeEventListener('pointermove', this.onPointerMove, false, {passive: true})
this.element.removeEventListener('pointerup', this.onPointerUp, false, {passive: true})
this.element.removeEventListener('pointerdown', this.onPointerDown, false, {passive: true})
this.element.removeEventListener('wheel', this.onMouseWheel, true, {passive: true})
this.element.removeEventListener('touchstart', this.onTouchStart, false, {passive: true})
this.element.removeEventListener('touchend', this.onTouchEnd, false, {passive: true})
this.element.removeEventListener('touchmove', this.onTouchMove, false, {passive: true})
this.element.ownerDocument.removeEventListener('contextmenu', this.onContextMenu, false, {passive: true})
this.element.ownerDocument.removeEventListener('keydown', this.onKeyDown, false, {passive: true})
this.element.ownerDocument.removeEventListener('keyup', this.onKeyUp, false, {passive: true})
console.log('[controls] unregistered handlers', this.element)
}
dispatchEvent() {
// no-op
}
}
module.exports = { MapControls }

View file

@ -0,0 +1,6 @@
module.exports = {
dispose3 (o) {
o.geometry?.dispose()
o.dispose?.()
}
}

View file

@ -0,0 +1,88 @@
const THREE = require('three')
const TWEEN = require('@tweenjs/tween.js')
const Entity = require('./entity/Entity')
const { dispose3 } = require('./dispose')
function getEntityMesh (entity, scene) {
if (entity.name) {
try {
const e = new Entity('1.16.4', entity.name, scene)
if (entity.username !== undefined) {
const canvas = document.createElement('canvas')
canvas.width = 500
canvas.height = 100
const ctx = canvas.getContext('2d')
ctx.font = '50pt Arial'
ctx.fillStyle = '#000000'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
const txt = entity.username
ctx.fillText(txt, 100, 0)
const tex = new THREE.Texture(canvas)
tex.needsUpdate = true
const spriteMat = new THREE.SpriteMaterial({ map: tex })
const sprite = new THREE.Sprite(spriteMat)
sprite.position.y += entity.height + 0.6
e.mesh.add(sprite)
}
return e.mesh
} catch (err) {
console.log(err)
}
}
const geometry = new THREE.BoxGeometry(entity.width, entity.height, entity.width)
geometry.translate(0, entity.height / 2, 0)
const material = new THREE.MeshBasicMaterial({ color: 0xff00ff })
const cube = new THREE.Mesh(geometry, material)
return cube
}
class Entities {
constructor (scene) {
this.scene = scene
this.entities = {}
}
clear () {
for (const mesh of Object.values(this.entities)) {
this.scene.remove(mesh)
dispose3(mesh)
}
this.entities = {}
}
update (entity) {
if (!this.entities[entity.id]) {
const mesh = getEntityMesh(entity, this.scene)
if (!mesh) return
this.entities[entity.id] = mesh
this.scene.add(mesh)
}
const e = this.entities[entity.id]
if (entity.delete) {
this.scene.remove(e)
dispose3(e)
delete this.entities[entity.id]
}
if (entity.pos) {
new TWEEN.Tween(e.position).to({ x: entity.pos.x, y: entity.pos.y, z: entity.pos.z }, 50).start()
}
if (entity.yaw) {
const da = (entity.yaw - e.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, 50).start()
}
}
}
module.exports = { Entities }

View file

@ -0,0 +1,222 @@
/* global THREE */
const entities = require('./entities.json')
const { loadTexture } = globalThis.isElectron ? require('../utils.electron.js') : require('../utils')
const elemFaces = {
up: {
dir: [0, 1, 0],
u0: [0, 0, 1],
v0: [0, 0, 0],
u1: [1, 0, 1],
v1: [0, 0, 1],
corners: [
[0, 1, 1, 0, 0],
[1, 1, 1, 1, 0],
[0, 1, 0, 0, 1],
[1, 1, 0, 1, 1]
]
},
down: {
dir: [0, -1, 0],
u0: [1, 0, 1],
v0: [0, 0, 0],
u1: [2, 0, 1],
v1: [0, 0, 1],
corners: [
[1, 0, 1, 0, 0],
[0, 0, 1, 1, 0],
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1]
]
},
east: {
dir: [1, 0, 0],
u0: [0, 0, 0],
v0: [0, 0, 1],
u1: [0, 0, 1],
v1: [0, 1, 1],
corners: [
[1, 1, 1, 0, 0],
[1, 0, 1, 0, 1],
[1, 1, 0, 1, 0],
[1, 0, 0, 1, 1]
]
},
west: {
dir: [-1, 0, 0],
u0: [1, 0, 1],
v0: [0, 0, 1],
u1: [1, 0, 2],
v1: [0, 1, 1],
corners: [
[0, 1, 0, 0, 0],
[0, 0, 0, 0, 1],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1]
]
},
north: {
dir: [0, 0, -1],
u0: [0, 0, 1],
v0: [0, 0, 1],
u1: [1, 0, 1],
v1: [0, 1, 1],
corners: [
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1],
[1, 1, 0, 0, 0],
[0, 1, 0, 1, 0]
]
},
south: {
dir: [0, 0, 1],
u0: [1, 0, 2],
v0: [0, 0, 1],
u1: [2, 0, 2],
v1: [0, 1, 1],
corners: [
[0, 0, 1, 0, 1],
[1, 0, 1, 1, 1],
[0, 1, 1, 0, 0],
[1, 1, 1, 1, 0]
]
}
}
function dot (a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
function addCube (attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
const cubeRotation = new THREE.Euler(0, 0, 0)
if (cube.rotation) {
cubeRotation.x = -cube.rotation[0] * Math.PI / 180
cubeRotation.y = -cube.rotation[1] * Math.PI / 180
cubeRotation.z = -cube.rotation[2] * Math.PI / 180
}
for (const { dir, corners, u0, v0, u1, v1 } of Object.values(elemFaces)) {
const ndx = Math.floor(attr.positions.length / 3)
for (const pos of corners) {
const u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
const v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight
const inflate = cube.inflate ? cube.inflate : 0
let vecPos = new THREE.Vector3(
cube.origin[0] + pos[0] * cube.size[0] + (pos[0] ? inflate : -inflate),
cube.origin[1] + pos[1] * cube.size[1] + (pos[1] ? inflate : -inflate),
cube.origin[2] + pos[2] * cube.size[2] + (pos[2] ? inflate : -inflate)
)
vecPos = vecPos.applyEuler(cubeRotation)
vecPos = vecPos.sub(bone.position)
vecPos = vecPos.applyEuler(bone.rotation)
vecPos = vecPos.add(bone.position)
attr.positions.push(vecPos.x, vecPos.y, vecPos.z)
attr.normals.push(...dir)
attr.uvs.push(u, v)
attr.skinIndices.push(boneId, 0, 0, 0)
attr.skinWeights.push(1, 0, 0, 0)
}
attr.indices.push(
ndx, ndx + 1, ndx + 2,
ndx + 2, ndx + 1, ndx + 3
)
}
}
function getMesh (texture, jsonModel) {
const bones = {}
const geoData = {
positions: [],
normals: [],
uvs: [],
indices: [],
skinIndices: [],
skinWeights: []
}
let i = 0
for (const jsonBone of jsonModel.bones) {
const bone = new THREE.Bone()
if (jsonBone.pivot) {
bone.position.x = jsonBone.pivot[0]
bone.position.y = jsonBone.pivot[1]
bone.position.z = jsonBone.pivot[2]
}
if (jsonBone.bind_pose_rotation) {
bone.rotation.x = -jsonBone.bind_pose_rotation[0] * Math.PI / 180
bone.rotation.y = -jsonBone.bind_pose_rotation[1] * Math.PI / 180
bone.rotation.z = -jsonBone.bind_pose_rotation[2] * Math.PI / 180
} else if (jsonBone.rotation) {
bone.rotation.x = -jsonBone.rotation[0] * Math.PI / 180
bone.rotation.y = -jsonBone.rotation[1] * Math.PI / 180
bone.rotation.z = -jsonBone.rotation[2] * Math.PI / 180
}
bones[jsonBone.name] = bone
if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight)
}
}
i++
}
const rootBones = []
for (const jsonBone of jsonModel.bones) {
if (jsonBone.parent) bones[jsonBone.parent].add(bones[jsonBone.name])
else rootBones.push(bones[jsonBone.name])
}
const skeleton = new THREE.Skeleton(Object.values(bones))
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(geoData.positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(geoData.normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(geoData.uvs, 2))
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(geoData.skinIndices, 4))
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(geoData.skinWeights, 4))
geometry.setIndex(geoData.indices)
const material = new THREE.MeshLambertMaterial({ transparent: true, skinning: true, alphaTest: 0.1 })
const mesh = new THREE.SkinnedMesh(geometry, material)
mesh.add(...rootBones)
mesh.bind(skeleton)
mesh.scale.set(1 / 16, 1 / 16, 1 / 16)
loadTexture(texture, texture => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
material.map = texture
})
return mesh
}
class Entity {
constructor (version, type, scene) {
const e = entities[type]
if (!e) throw new Error(`Unknown entity ${type}`)
this.mesh = new THREE.Object3D()
for (const [name, jsonModel] of Object.entries(e.geometry)) {
const texture = e.textures[name]
if (!texture) continue
// console.log(JSON.stringify(jsonModel, null, 2))
const mesh = getMesh(texture.replace('textures', 'textures/' + version) + '.png', jsonModel)
/* const skeletonHelper = new THREE.SkeletonHelper( mesh )
skeletonHelper.material.linewidth = 2
scene.add( skeletonHelper ) */
this.mesh.add(mesh)
}
}
}
module.exports = Entity

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,507 @@
const { Vec3 } = require('vec3')
const tints = require('minecraft-data')('1.16.2').tints
for (const key of Object.keys(tints)) {
tints[key] = prepareTints(tints[key])
}
function prepareTints (tints) {
const map = new Map()
const defaultValue = tintToGl(tints.default)
for (let { keys, color } of tints.data) {
color = tintToGl(color)
for (const key of keys) {
map.set(`${key}`, color)
}
}
return new Proxy(map, {
get: (target, key) => {
return target.has(key) ? target.get(key) : defaultValue
}
})
}
function tintToGl (tint) {
const r = (tint >> 16) & 0xff
const g = (tint >> 8) & 0xff
const b = tint & 0xff
return [r / 255, g / 255, b / 255]
}
const elemFaces = {
up: {
dir: [0, 1, 0],
mask1: [1, 1, 0],
mask2: [0, 1, 1],
corners: [
[0, 1, 1, 0, 1],
[1, 1, 1, 1, 1],
[0, 1, 0, 0, 0],
[1, 1, 0, 1, 0]
]
},
down: {
dir: [0, -1, 0],
mask1: [1, 1, 0],
mask2: [0, 1, 1],
corners: [
[1, 0, 1, 0, 1],
[0, 0, 1, 1, 1],
[1, 0, 0, 0, 0],
[0, 0, 0, 1, 0]
]
},
east: {
dir: [1, 0, 0],
mask1: [1, 1, 0],
mask2: [1, 0, 1],
corners: [
[1, 1, 1, 0, 0],
[1, 0, 1, 0, 1],
[1, 1, 0, 1, 0],
[1, 0, 0, 1, 1]
]
},
west: {
dir: [-1, 0, 0],
mask1: [1, 1, 0],
mask2: [1, 0, 1],
corners: [
[0, 1, 0, 0, 0],
[0, 0, 0, 0, 1],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1]
]
},
north: {
dir: [0, 0, -1],
mask1: [1, 0, 1],
mask2: [0, 1, 1],
corners: [
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1],
[1, 1, 0, 0, 0],
[0, 1, 0, 1, 0]
]
},
south: {
dir: [0, 0, 1],
mask1: [1, 0, 1],
mask2: [0, 1, 1],
corners: [
[0, 0, 1, 0, 1],
[1, 0, 1, 1, 1],
[0, 1, 1, 0, 0],
[1, 1, 1, 1, 0]
]
}
}
function getLiquidRenderHeight (world, block, type) {
if (!block || block.type !== type) return 1 / 9
if (block.metadata === 0) { // source block
const blockAbove = world.getBlock(block.position.offset(0, 1, 0))
if (blockAbove && blockAbove.type === type) return 1
return 8 / 9
}
return ((block.metadata >= 8 ? 8 : 7 - block.metadata) + 1) / 9
}
function renderLiquid (world, cursor, texture, type, biome, water, attr) {
const heights = []
for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) {
heights.push(getLiquidRenderHeight(world, world.getBlock(cursor.offset(x, 0, z)), type))
}
}
const cornerHeights = [
Math.max(Math.max(heights[0], heights[1]), Math.max(heights[3], heights[4])),
Math.max(Math.max(heights[1], heights[2]), Math.max(heights[4], heights[5])),
Math.max(Math.max(heights[3], heights[4]), Math.max(heights[6], heights[7])),
Math.max(Math.max(heights[4], heights[5]), Math.max(heights[7], heights[8]))
]
for (const face in elemFaces) {
const { dir, corners } = elemFaces[face]
const isUp = dir[1] === 1
const neighbor = world.getBlock(cursor.offset(...dir))
if (!neighbor) continue
if (neighbor.type === type) continue
if ((neighbor.isCube && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue
if (neighbor.position.y < 0) continue
let tint = [1, 1, 1]
if (water) {
let m = 1 // Fake lighting to improve lisibility
if (Math.abs(dir[0]) > 0) m = 0.6
else if (Math.abs(dir[2]) > 0) m = 0.8
tint = tints.water[biome]
tint = [tint[0] * m, tint[1] * m, tint[2] * m]
}
const u = texture.u
const v = texture.v
const su = texture.su
const sv = texture.sv
for (const pos of corners) {
const height = cornerHeights[pos[2] * 2 + pos[0]]
attr.t_positions.push(
(pos[0] ? 1 : 0) + (cursor.x & 15) - 8,
(pos[1] ? height : 0) + (cursor.y & 15) - 8,
(pos[2] ? 1 : 0) + (cursor.z & 15) - 8)
attr.t_normals.push(...dir)
attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
attr.t_colors.push(tint[0], tint[1], tint[2])
}
}
}
function vecadd3 (a, b) {
if (!b) return a
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
function vecsub3 (a, b) {
if (!b) return a
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
function matmul3 (matrix, vector) {
if (!matrix) return vector
return [
matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2]
]
}
function matmulmat3 (a, b) {
const te = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
const a11 = a[0][0]; const a12 = a[1][0]; const a13 = a[2][0]
const a21 = a[0][1]; const a22 = a[1][1]; const a23 = a[2][1]
const a31 = a[0][2]; const a32 = a[1][2]; const a33 = a[2][2]
const b11 = b[0][0]; const b12 = b[1][0]; const b13 = b[2][0]
const b21 = b[0][1]; const b22 = b[1][1]; const b23 = b[2][1]
const b31 = b[0][2]; const b32 = b[1][2]; const b33 = b[2][2]
te[0][0] = a11 * b11 + a12 * b21 + a13 * b31
te[1][0] = a11 * b12 + a12 * b22 + a13 * b32
te[2][0] = a11 * b13 + a12 * b23 + a13 * b33
te[0][1] = a21 * b11 + a22 * b21 + a23 * b31
te[1][1] = a21 * b12 + a22 * b22 + a23 * b32
te[2][1] = a21 * b13 + a22 * b23 + a23 * b33
te[0][2] = a31 * b11 + a32 * b21 + a33 * b31
te[1][2] = a31 * b12 + a32 * b22 + a33 * b32
te[2][2] = a31 * b13 + a32 * b23 + a33 * b33
return te
}
function buildRotationMatrix (axis, degree) {
const radians = degree / 180 * Math.PI
const cos = Math.cos(radians)
const sin = Math.sin(radians)
const axis0 = { x: 0, y: 1, z: 2 }[axis]
const axis1 = (axis0 + 1) % 3
const axis2 = (axis0 + 2) % 3
const matrix = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]
]
matrix[axis0][axis0] = 1
matrix[axis1][axis1] = cos
matrix[axis1][axis2] = -sin
matrix[axis2][axis1] = +sin
matrix[axis2][axis2] = cos
return matrix
}
function renderElement (world, cursor, element, doAO, attr, globalMatrix, globalShift, block, biome) {
const cullIfIdentical = block.name.indexOf('glass') >= 0
for (const face in element.faces) {
const eFace = element.faces[face]
const { corners, mask1, mask2 } = elemFaces[face]
const dir = matmul3(globalMatrix, elemFaces[face].dir)
if (eFace.cullface) {
const neighbor = world.getBlock(cursor.plus(new Vec3(...dir)))
if (!neighbor) continue
if (cullIfIdentical && neighbor.type === block.type) continue
if (!neighbor.transparent && neighbor.isCube) continue
if (neighbor.position.y < 0) continue
}
const minx = element.from[0]
const miny = element.from[1]
const minz = element.from[2]
const maxx = element.to[0]
const maxy = element.to[1]
const maxz = element.to[2]
const u = eFace.texture.u
const v = eFace.texture.v
const su = eFace.texture.su
const sv = eFace.texture.sv
const ndx = Math.floor(attr.positions.length / 3)
let tint = [1, 1, 1]
if (eFace.tintindex !== undefined) {
if (eFace.tintindex === 0) {
if (block.name === 'redstone_wire') {
tint = tints.redstone[`${block.getProperties().power}`]
} else if (block.name === 'birch_leaves' ||
block.name === 'spruce_leaves' ||
block.name === 'lily_pad') {
tint = tints.constant[block.name]
} else if (block.name.includes('leaves') || block.name === 'vine') {
tint = tints.foliage[biome]
} else {
tint = tints.grass[biome]
}
}
}
// UV rotation
const r = eFace.rotation || 0
const uvcs = Math.cos(r * Math.PI / 180)
const uvsn = -Math.sin(r * Math.PI / 180)
let localMatrix = null
let localShift = null
if (element.rotation) {
localMatrix = buildRotationMatrix(
element.rotation.axis,
element.rotation.angle
)
localShift = vecsub3(
element.rotation.origin,
matmul3(
localMatrix,
element.rotation.origin
)
)
}
const aos = []
for (const pos of corners) {
let vertex = [
(pos[0] ? maxx : minx),
(pos[1] ? maxy : miny),
(pos[2] ? maxz : minz)
]
vertex = vecadd3(matmul3(localMatrix, vertex), localShift)
vertex = vecadd3(matmul3(globalMatrix, vertex), globalShift)
vertex = vertex.map(v => v / 16)
attr.positions.push(
vertex[0] + (cursor.x & 15) - 8,
vertex[1] + (cursor.y & 15) - 8,
vertex[2] + (cursor.z & 15) - 8
)
attr.normals.push(...dir)
const baseu = (pos[3] - 0.5) * uvcs - (pos[4] - 0.5) * uvsn + 0.5
const basev = (pos[3] - 0.5) * uvsn + (pos[4] - 0.5) * uvcs + 0.5
attr.uvs.push(baseu * su + u, basev * sv + v)
let light = 1
if (doAO) {
const dx = pos[0] * 2 - 1
const dy = pos[1] * 2 - 1
const dz = pos[2] * 2 - 1
const cornerDir = matmul3(globalMatrix, [dx, dy, dz])
const side1Dir = matmul3(globalMatrix, [dx * mask1[0], dy * mask1[1], dz * mask1[2]])
const side2Dir = matmul3(globalMatrix, [dx * mask2[0], dy * mask2[1], dz * mask2[2]])
const side1 = world.getBlock(cursor.offset(...side1Dir))
const side2 = world.getBlock(cursor.offset(...side2Dir))
const corner = world.getBlock(cursor.offset(...cornerDir))
const side1Block = (side1 && side1.isCube) ? 1 : 0
const side2Block = (side2 && side2.isCube) ? 1 : 0
const cornerBlock = (corner && corner.isCube) ? 1 : 0
// TODO: correctly interpolate ao light based on pos (evaluate once for each corner of the block)
const ao = (side1Block && side2Block) ? 0 : (3 - (side1Block + side2Block + cornerBlock))
light = (ao + 1) / 4
aos.push(ao)
}
attr.colors.push(tint[0] * light, tint[1] * light, tint[2] * light)
}
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
attr.indices.push(
ndx, ndx + 3, ndx + 2,
ndx, ndx + 1, ndx + 3
)
} else {
attr.indices.push(
ndx, ndx + 1, ndx + 2,
ndx + 2, ndx + 1, ndx + 3
)
}
}
}
function getSectionGeometry (sx, sy, sz, world, blocksStates) {
const attr = {
sx: sx + 8,
sy: sy + 8,
sz: sz + 8,
positions: [],
normals: [],
colors: [],
uvs: [],
t_positions: [],
t_normals: [],
t_colors: [],
t_uvs: [],
indices: []
}
const cursor = new Vec3(0, 0, 0)
for (cursor.y = sy; cursor.y < sy + 16; cursor.y++) {
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
const block = world.getBlock(cursor)
const biome = block.biome.name
if (block.variant === undefined) {
block.variant = getModelVariants(block, blocksStates)
}
for (const variant of block.variant) {
if (!variant || !variant.model) continue
if (block.name === 'water') {
renderLiquid(world, cursor, variant.model.textures.particle, block.type, biome, true, attr)
} else if (block.name === 'lava') {
renderLiquid(world, cursor, variant.model.textures.particle, block.type, biome, false, attr)
} else {
let globalMatrix = null
let globalShift = null
for (const axis of ['x', 'y', 'z']) {
if (axis in variant) {
if (!globalMatrix) globalMatrix = buildRotationMatrix(axis, -variant[axis])
else globalMatrix = matmulmat3(globalMatrix, buildRotationMatrix(axis, -variant[axis]))
}
}
if (globalMatrix) {
globalShift = [8, 8, 8]
globalShift = vecsub3(globalShift, matmul3(globalMatrix, globalShift))
}
for (const element of variant.model.elements) {
renderElement(world, cursor, element, variant.model.ao, attr, globalMatrix, globalShift, block, biome)
}
}
}
}
}
}
let ndx = attr.positions.length / 3
for (let i = 0; i < attr.t_positions.length / 12; i++) {
attr.indices.push(
ndx, ndx + 1, ndx + 2,
ndx + 2, ndx + 1, ndx + 3,
// back face
ndx, ndx + 2, ndx + 1,
ndx + 2, ndx + 3, ndx + 1
)
ndx += 4
}
attr.positions.push(...attr.t_positions)
attr.normals.push(...attr.t_normals)
attr.colors.push(...attr.t_colors)
attr.uvs.push(...attr.t_uvs)
delete attr.t_positions
delete attr.t_normals
delete attr.t_colors
delete attr.t_uvs
attr.positions = new Float32Array(attr.positions)
attr.normals = new Float32Array(attr.normals)
attr.colors = new Float32Array(attr.colors)
attr.uvs = new Float32Array(attr.uvs)
return attr
}
function parseProperties (properties) {
if (typeof properties === 'object') { return properties }
const json = {}
for (const prop of properties.split(',')) {
const [key, value] = prop.split('=')
json[key] = value
}
return json
}
function matchProperties (block, properties) {
if (!properties) { return true }
properties = parseProperties(properties)
const blockProps = block.getProperties()
if (properties.OR) {
return properties.OR.some((or) => matchProperties(block, or))
}
for (const prop in blockProps) {
if (typeof properties[prop] === 'string' && !properties[prop].split('|').some((value) => value === blockProps[prop] + '')) {
return false
}
}
return true
}
function getModelVariants (block, blockStates) {
const state = blockStates[block.name]
if (!state) return []
if (state.variants) {
for (const [properties, variant] of Object.entries(state.variants)) {
if (!matchProperties(block, properties)) continue
if (variant instanceof Array) return [variant[0]]
return [variant]
}
}
if (state.multipart) {
const parts = state.multipart.filter(multipart => matchProperties(block, multipart.when))
let variants = []
for (const part of parts) {
if (part.apply instanceof Array) {
variants = [...variants, ...part.apply]
} else {
variants = [...variants, part.apply]
}
}
return variants
}
return []
}
module.exports = { getSectionGeometry }

View file

@ -0,0 +1,125 @@
function cleanupBlockName (name) {
if (name.startsWith('block') || name.startsWith('minecraft:block')) return name.split('/')[1]
return name
}
function getModel (name, blocksModels) {
name = cleanupBlockName(name)
const data = blocksModels[name]
if (!data) {
return null
}
let model = { textures: {}, elements: [], ao: true }
for (const axis in ['x', 'y', 'z']) {
if (axis in data) {
model[axis] = data[axis]
}
}
if (data.parent) {
model = getModel(data.parent, blocksModels)
}
if (data.textures) {
Object.assign(model.textures, JSON.parse(JSON.stringify(data.textures)))
}
if (data.elements) {
model.elements = JSON.parse(JSON.stringify(data.elements))
}
if (data.ambientocclusion !== undefined) {
model.ao = data.ambientocclusion
}
return model
}
function prepareModel (model, texturesJson) {
for (const tex in model.textures) {
let root = model.textures[tex]
while (root.charAt(0) === '#') {
root = model.textures[root.substr(1)]
}
model.textures[tex] = root
}
for (const tex in model.textures) {
let name = model.textures[tex]
name = cleanupBlockName(name)
model.textures[tex] = texturesJson[name]
}
for (const elem of model.elements) {
for (const sideName of Object.keys(elem.faces)) {
const face = elem.faces[sideName]
if (face.texture.charAt(0) === '#') {
face.texture = JSON.parse(JSON.stringify(model.textures[face.texture.substr(1)]))
} else {
let name = face.texture
name = cleanupBlockName(name)
face.texture = JSON.parse(JSON.stringify(texturesJson[name]))
}
let uv = face.uv
if (!uv) {
const _from = elem.from
const _to = elem.to
// taken from https://github.com/DragonDev1906/Minecraft-Overviewer/
uv = {
north: [_to[0], 16 - _to[1], _from[0], 16 - _from[1]],
east: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
south: [_from[0], 16 - _to[1], _to[0], 16 - _from[1]],
west: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
up: [_from[0], _from[2], _to[0], _to[2]],
down: [_to[0], _from[2], _from[0], _to[2]]
}[sideName]
}
const su = (uv[2] - uv[0]) * face.texture.su / 16
const sv = (uv[3] - uv[1]) * face.texture.sv / 16
face.texture.bu = face.texture.u + 0.5 * face.texture.su
face.texture.bv = face.texture.v + 0.5 * face.texture.sv
face.texture.u += uv[0] * face.texture.su / 16
face.texture.v += uv[1] * face.texture.sv / 16
face.texture.su = su
face.texture.sv = sv
}
}
}
function resolveModel (name, blocksModels, texturesJson) {
const model = getModel(name, blocksModels)
prepareModel(model, texturesJson.textures)
return model
}
function prepareBlocksStates (mcAssets, atlas) {
const blocksStates = mcAssets.blocksStates
for (const block of Object.values(blocksStates)) {
if (!block) continue
if (block.variants) {
for (const variant of Object.values(block.variants)) {
if (variant instanceof Array) {
for (const v of variant) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json)
}
} else {
variant.model = resolveModel(variant.model, mcAssets.blocksModels, atlas.json)
}
}
}
if (block.multipart) {
for (const variant of block.multipart) {
if (variant.apply instanceof Array) {
for (const v of variant.apply) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json)
}
} else {
variant.apply.model = resolveModel(variant.apply.model, mcAssets.blocksModels, atlas.json)
}
}
}
}
return blocksStates
}
module.exports = { prepareBlocksStates }

View file

@ -0,0 +1,141 @@
const THREE = require('three')
const { MeshLine, MeshLineMaterial } = require('three.meshline')
const { dispose3 } = require('./dispose')
function getMesh (primitive, camera) {
if (primitive.type === 'line') {
const color = primitive.color ? primitive.color : 0xff0000
const resolution = new THREE.Vector2(window.innerWidth / camera.zoom, window.innerHeight / camera.zoom)
const material = new MeshLineMaterial({ color, resolution, sizeAttenuation: false, lineWidth: 8 })
const points = []
for (const p of primitive.points) {
points.push(p.x, p.y, p.z)
}
const line = new MeshLine()
line.setPoints(points)
return new THREE.Mesh(line, material)
} else if (primitive.type === 'boxgrid') {
const color = primitive.color ? primitive.color : 'aqua'
const sx = primitive.end.x - primitive.start.x
const sy = primitive.end.y - primitive.start.y
const sz = primitive.end.z - primitive.start.z
const boxGeometry = new THREE.BoxBufferGeometry(sx, sy, sz, sx, sy, sz)
boxGeometry.attributes.positionStart = boxGeometry.attributes.position.clone()
const gridGeometry = GridBoxGeometry(boxGeometry, false)
const grid = new THREE.LineSegments(gridGeometry, new THREE.LineBasicMaterial({ color }))
grid.position.x = primitive.start.x + sx / 2
grid.position.y = primitive.start.y + sy / 2
grid.position.z = primitive.start.z + sz / 2
return grid
} else if (primitive.type === 'points') {
const color = primitive.color ? primitive.color : 'aqua'
const size = primitive.size ? primitive.size : 5
const points = []
for (const p of primitive.points) {
points.push(p.x, p.y, p.z)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3))
const material = new THREE.PointsMaterial({ color, size, sizeAttenuation: false })
return new THREE.Points(geometry, material)
}
return null
}
class Primitives {
constructor (scene, camera) {
this.scene = scene
this.camera = camera
this.primitives = {}
}
clear () {
for (const mesh of Object.values(this.primitives)) {
this.scene.remove(mesh)
dispose3(mesh)
}
this.primitives = {}
}
update (primitive) {
if (this.primitives[primitive.id]) {
this.scene.remove(this.primitives[primitive.id])
dispose3(this.primitives[primitive.id])
delete this.primitives[primitive.id]
}
const mesh = getMesh(primitive, this.camera)
if (!mesh) return
this.primitives[primitive.id] = mesh
this.scene.add(mesh)
}
}
function GridBoxGeometry (geometry, independent) {
if (!(geometry instanceof THREE.BoxBufferGeometry)) {
console.log("GridBoxGeometry: the parameter 'geometry' has to be of the type THREE.BoxBufferGeometry")
return geometry
}
independent = independent !== undefined ? independent : false
const newGeometry = new THREE.BoxBufferGeometry()
const position = geometry.attributes.position
newGeometry.attributes.position = independent === false ? position : position.clone()
const segmentsX = geometry.parameters.widthSegments || 1
const segmentsY = geometry.parameters.heightSegments || 1
const segmentsZ = geometry.parameters.depthSegments || 1
let startIndex = 0
const indexSide1 = indexSide(segmentsZ, segmentsY, startIndex)
startIndex += (segmentsZ + 1) * (segmentsY + 1)
const indexSide2 = indexSide(segmentsZ, segmentsY, startIndex)
startIndex += (segmentsZ + 1) * (segmentsY + 1)
const indexSide3 = indexSide(segmentsX, segmentsZ, startIndex)
startIndex += (segmentsX + 1) * (segmentsZ + 1)
const indexSide4 = indexSide(segmentsX, segmentsZ, startIndex)
startIndex += (segmentsX + 1) * (segmentsZ + 1)
const indexSide5 = indexSide(segmentsX, segmentsY, startIndex)
startIndex += (segmentsX + 1) * (segmentsY + 1)
const indexSide6 = indexSide(segmentsX, segmentsY, startIndex)
let fullIndices = []
fullIndices = fullIndices.concat(indexSide1)
fullIndices = fullIndices.concat(indexSide2)
fullIndices = fullIndices.concat(indexSide3)
fullIndices = fullIndices.concat(indexSide4)
fullIndices = fullIndices.concat(indexSide5)
fullIndices = fullIndices.concat(indexSide6)
newGeometry.setIndex(fullIndices)
function indexSide (x, y, shift) {
const indices = []
for (let i = 0; i < y + 1; i++) {
let index11 = 0
let index12 = 0
for (let j = 0; j < x; j++) {
index11 = (x + 1) * i + j
index12 = index11 + 1
const index21 = index11
const index22 = index11 + (x + 1)
indices.push(shift + index11, shift + index12)
if (index22 < ((x + 1) * (y + 1) - 1)) {
indices.push(shift + index21, shift + index22)
}
}
if ((index12 + x + 1) <= ((x + 1) * (y + 1) - 1)) {
indices.push(shift + index12, shift + index12 + x + 1)
}
}
return indices
}
return newGeometry
}
module.exports = { Primitives }

View file

@ -0,0 +1,55 @@
function getBufferFromStream (stream) {
return new Promise(
(resolve, reject) => {
let buffer = Buffer.from([])
stream.on('data', buf => {
buffer = Buffer.concat([buffer, buf])
})
stream.on('end', () => resolve(buffer))
stream.on('error', reject)
}
)
}
function spiral (X, Y, fun) { // TODO: move that to spiralloop package
let x = 0
let y = 0
let dx = 0
let dy = -1
const N = Math.max(X, Y) * Math.max(X, Y)
const hX = X / 2
const hY = Y / 2
for (let i = 0; i < N; i++) {
if (-hX < x && x <= hX && -hY < y && y <= hY) {
fun(x, y)
}
if (x === y || (x < 0 && x === -y) || (x > 0 && x === 1 - y)) {
const tmp = dx
dx = -dy
dy = tmp
}
x += dx
y += dy
}
}
class ViewRect {
constructor (cx, cz, viewDistance) {
this.x0 = cx - viewDistance
this.x1 = cx + viewDistance
this.z0 = cz - viewDistance
this.z1 = cz + viewDistance
}
contains (x, z) {
return this.x0 < x && x <= this.x1 && this.z0 < z && z <= this.z1
}
}
function chunkPos (pos) {
const x = Math.floor(pos.x / 16)
const z = Math.floor(pos.z / 16)
return [x, z]
}
module.exports = { getBufferFromStream, spiral, ViewRect, chunkPos }

View file

@ -0,0 +1,17 @@
const THREE = require('three')
const path = require('path')
const textureCache = {}
function loadTexture (texture, cb) {
if (!textureCache[texture]) {
const url = path.resolve(__dirname, '../../public/' + texture)
textureCache[texture] = new THREE.TextureLoader().load(url)
}
cb(textureCache[texture])
}
function loadJSON (json, cb) {
cb(require(path.resolve(__dirname, '../../public/' + json)))
}
module.exports = { loadTexture, loadJSON }

View file

@ -0,0 +1,28 @@
function safeRequire (path) {
try {
return require(path)
} catch (e) {
return {}
}
}
const { loadImage } = safeRequire('node-canvas-webgl/lib')
const THREE = require('three')
const path = require('path')
const textureCache = {}
function loadTexture (texture, cb) {
if (textureCache[texture]) {
cb(textureCache[texture])
} else {
loadImage(path.resolve(__dirname, '../../public/' + texture)).then(image => {
textureCache[texture] = new THREE.CanvasTexture(image)
cb(textureCache[texture])
})
}
}
function loadJSON (json, cb) {
cb(require(path.resolve(__dirname, '../../public/' + json)))
}
module.exports = { loadTexture, loadJSON }

View file

@ -0,0 +1,27 @@
/* global XMLHttpRequest */
const THREE = require('three')
const textureCache = {}
function loadTexture (texture, cb) {
if (!textureCache[texture]) {
textureCache[texture] = new THREE.TextureLoader().load(texture)
}
cb(textureCache[texture])
}
function loadJSON (url, callback) {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'json'
xhr.onload = function () {
const status = xhr.status
if (status === 200) {
callback(xhr.response)
} else {
throw new Error(url + ' not found')
}
}
xhr.send()
}
module.exports = { loadTexture, loadJSON }

View file

@ -0,0 +1,30 @@
const supportedVersions = require('../../public/supportedVersions.json')
const lastOfMajor = {}
for (const version of supportedVersions) {
const major = toMajor(version)
if (lastOfMajor[major]) {
if (minor(lastOfMajor[major]) < minor(version)) {
lastOfMajor[major] = version
}
} else {
lastOfMajor[major] = version
}
}
function toMajor (version) {
const [a, b] = (version + '').split('.')
return a + '.' + b
}
function minor (version) {
const [, , c] = (version + '.0').split('.')
return parseInt(c, 10)
}
function getVersion (version) {
if (supportedVersions.indexOf(version) !== -1) return version
return lastOfMajor[toMajor(version)]
}
module.exports = { getVersion }

View file

@ -0,0 +1,108 @@
const THREE = require('three')
const TWEEN = require('@tweenjs/tween.js')
const { WorldRenderer } = require('./worldrenderer')
const { Entities } = require('./entities')
const { Primitives } = require('./primitives')
const { getVersion } = require('./version')
const { Vec3 } = require('vec3')
class Viewer {
constructor (renderer) {
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color('lightblue')
this.ambientLight = new THREE.AmbientLight(0xcccccc)
this.scene.add(this.ambientLight)
this.directionalLight = new THREE.DirectionalLight(0xffffff, 0.5)
this.directionalLight.position.set(1, 1, 0.5).normalize()
this.directionalLight.castShadow = true
this.scene.add(this.directionalLight)
const size = renderer.getSize(new THREE.Vector2())
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
this.world = new WorldRenderer(this.scene)
this.entities = new Entities(this.scene)
this.primitives = new Primitives(this.scene, this.camera)
this.domElement = renderer.domElement
}
setVersion (version) {
version = getVersion(version)
console.log('Using version: ' + version)
this.version = version
this.world.setVersion(version)
this.entities.clear()
this.primitives.clear()
}
addColumn (x, z, chunk) {
this.world.addColumn(x, z, chunk)
}
removeColumn (x, z) {
this.world.removeColumn(x, z)
}
setBlockStateId (pos, stateId) {
this.world.setBlockStateId(pos, stateId)
}
updateEntity (e) {
this.entities.update(e)
}
updatePrimitive (p) {
this.primitives.update(p)
}
setFirstPersonCamera (pos, yaw, pitch) {
if (pos) new TWEEN.Tween(this.camera.position).to({ x: pos.x, y: pos.y + 1.6, z: pos.z }, 50).start()
this.camera.rotation.set(pitch, yaw, 0, 'ZYX')
}
listen (emitter) {
emitter.on('entity', (e) => {
this.updateEntity(e)
})
emitter.on('primitive', (p) => {
this.updatePrimitive(p)
})
emitter.on('loadChunk', ({ x, z, chunk }) => {
this.addColumn(x, z, chunk)
})
emitter.on('unloadChunk', ({ x, z }) => {
this.removeColumn(x, z)
})
emitter.on('blockUpdate', ({ pos, stateId }) => {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
this.domElement.addEventListener('pointerdown', (evt) => {
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
mouse.x = (evt.clientX / this.domElement.clientWidth) * 2 - 1
mouse.y = -(evt.clientY / this.domElement.clientHeight) * 2 + 1
raycaster.setFromCamera(mouse, this.camera)
const ray = raycaster.ray
emitter.emit('mouseClick', { origin: ray.origin, direction: ray.direction, button: evt.button })
})
}
update () {
TWEEN.update()
}
async waitForChunksToRender () {
await this.world.waitForChunksToRender()
}
}
module.exports = { Viewer }

View file

@ -0,0 +1,84 @@
/* global postMessage self */
if (!global.self) {
// If we are in a node environement, we need to fake some env variables
/* eslint-disable no-eval */
const r = eval('require') // yeah I know bad spooky eval, booouh
const { parentPort } = r('worker_threads')
global.self = parentPort
global.postMessage = (value, transferList) => { parentPort.postMessage(value, transferList) }
global.performance = r('perf_hooks').performance
}
const { Vec3 } = require('vec3')
const { World } = require('./world')
const { getSectionGeometry } = require('./models')
let blocksStates = null
let world = null
function sectionKey (x, y, z) {
return `${x},${y},${z}`
}
const dirtySections = {}
function setSectionDirty (pos, value = true) {
const x = Math.floor(pos.x / 16) * 16
const y = Math.floor(pos.y / 16) * 16
const z = Math.floor(pos.z / 16) * 16
const chunk = world.getColumn(x, z)
const key = sectionKey(x, y, z)
if (!value) {
delete dirtySections[key]
postMessage({ type: 'sectionFinished', key })
} else if (chunk && chunk.sections[Math.floor(y / 16)]) {
dirtySections[key] = value
} else {
postMessage({ type: 'sectionFinished', key })
}
}
self.onmessage = ({ data }) => {
if (data.type === 'version') {
world = new World(data.version)
} else if (data.type === 'blockStates') {
blocksStates = data.json
} else if (data.type === 'dirty') {
const loc = new Vec3(data.x, data.y, data.z)
setSectionDirty(loc, data.value)
} else if (data.type === 'chunk') {
world.addColumn(data.x, data.z, data.chunk)
} else if (data.type === 'unloadChunk') {
world.removeColumn(data.x, data.z)
} else if (data.type === 'blockUpdate') {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
world.setBlockStateId(loc, data.stateId)
}
}
setInterval(() => {
if (world === null || blocksStates === null) return
const sections = Object.keys(dirtySections)
if (sections.length === 0) return
// console.log(sections.length + ' dirty sections')
// const start = performance.now()
for (const key of sections) {
let [x, y, z] = key.split(',')
x = parseInt(x, 10)
y = parseInt(y, 10)
z = parseInt(z, 10)
const chunk = world.getColumn(x, z)
if (chunk && chunk.sections[Math.floor(y / 16)]) {
delete dirtySections[key]
const geometry = getSectionGeometry(x, y, z, world, blocksStates)
const transferable = [geometry.positions.buffer, geometry.normals.buffer, geometry.colors.buffer, geometry.uvs.buffer]
postMessage({ type: 'geometry', key, geometry }, transferable)
}
postMessage({ type: 'sectionFinished', key })
}
// const time = performance.now() - start
// console.log(`Processed ${sections.length} sections in ${time} ms (${time / sections.length} ms/section)`)
}, 50)

View file

@ -0,0 +1,79 @@
const Chunks = require('prismarine-chunk')
const mcData = require('minecraft-data')
function columnKey (x, z) {
return `${x},${z}`
}
function posInChunk (pos) {
pos = pos.floored()
pos.x &= 15
pos.z &= 15
return pos
}
function isCube (shapes) {
if (!shapes || shapes.length !== 1) return false
const shape = shapes[0]
return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1
}
class World {
constructor (version) {
this.Chunk = Chunks(version)
this.columns = {}
this.blockCache = {}
this.biomeCache = mcData(version).biomes
}
addColumn (x, z, json) {
const chunk = this.Chunk.fromJson(json)
this.columns[columnKey(x, z)] = chunk
return chunk
}
removeColumn (x, z) {
delete this.columns[columnKey(x, z)]
}
getColumn (x, z) {
return this.columns[columnKey(x, z)]
}
setBlockStateId (pos, stateId) {
const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16)
const column = this.columns[key]
// null column means chunk not loaded
if (!column) return false
column.setBlockStateId(posInChunk(pos.floored()), stateId)
return true
}
getBlock (pos) {
const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16)
const column = this.columns[key]
// null column means chunk not loaded
if (!column) return null
const loc = pos.floored()
const locInChunk = posInChunk(loc)
const stateId = column.getBlockStateId(locInChunk)
if (!this.blockCache[stateId]) {
const b = column.getBlock(locInChunk)
b.isCube = isCube(b.shapes)
this.blockCache[stateId] = b
}
const block = this.blockCache[stateId]
block.position = loc
block.biome = this.biomeCache[column.getBiome(locInChunk)]
return block
}
}
module.exports = { World }

View file

@ -0,0 +1,133 @@
const { spiral, ViewRect, chunkPos } = require('./simpleUtils')
const { Vec3 } = require('vec3')
const EventEmitter = require('events')
class WorldView extends EventEmitter {
constructor (world, viewDistance, position = new Vec3(0, 0, 0), emitter = null) {
super()
this.world = world
this.viewDistance = viewDistance
this.loadedChunks = {}
this.lastPos = new Vec3(0, 0, 0).update(position)
this.emitter = emitter || this
this.listeners = {}
this.emitter.on('mouseClick', async (click) => {
const ori = new Vec3(click.origin.x, click.origin.y, click.origin.z)
const dir = new Vec3(click.direction.x, click.direction.y, click.direction.z)
const block = this.world.raycast(ori, dir, 256)
if (!block) return
this.emit('blockClicked', block, block.face, click.button)
})
}
listenToBot (bot) {
const worldView = this
this.listeners[bot.username] = {
// 'move': botPosition,
entitySpawn: function (e) {
if (e === bot.entity) return
worldView.emitter.emit('entity', { id: e.id, name: e.name, pos: e.position, width: e.width, height: e.height, username: e.username })
},
entityMoved: function (e) {
worldView.emitter.emit('entity', { id: e.id, pos: e.position, pitch: e.pitch, yaw: e.yaw })
},
entityGone: function (e) {
worldView.emitter.emit('entity', { id: e.id, delete: true })
},
chunkColumnLoad: function (pos) {
worldView.loadChunk(pos)
},
blockUpdate: function (oldBlock, newBlock) {
const stateId = newBlock.stateId ? newBlock.stateId : ((newBlock.type << 4) | newBlock.metadata)
worldView.emitter.emit('blockUpdate', { pos: oldBlock.position, stateId })
}
}
for (const [evt, listener] of Object.entries(this.listeners[bot.username])) {
bot.on(evt, listener)
}
for (const id in bot.entities) {
const e = bot.entities[id]
if (e && e !== bot.entity) {
this.emitter.emit('entity', { id: e.id, name: e.name, pos: e.position, width: e.width, height: e.height, username: e.username })
}
}
}
removeListenersFromBot (bot) {
for (const [evt, listener] of Object.entries(this.listeners[bot.username])) {
bot.removeListener(evt, listener)
}
delete this.listeners[bot.username]
}
async init (pos) {
const [botX, botZ] = chunkPos(pos)
const positions = []
spiral(this.viewDistance * 2, this.viewDistance * 2, (x, z) => {
const p = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
positions.push(p)
})
this.lastPos.update(pos)
await this._loadChunks(positions)
}
async _loadChunks (positions, sliceSize = 5, waitTime = 0) {
for (let i = 0; i < positions.length; i += sliceSize) {
await new Promise((resolve) => setTimeout(resolve, waitTime))
await Promise.all(positions.slice(i, i + sliceSize).map(p => this.loadChunk(p)))
}
}
async loadChunk (pos) {
const [botX, botZ] = chunkPos(this.lastPos)
const dx = Math.abs(botX - Math.floor(pos.x / 16))
const dz = Math.abs(botZ - Math.floor(pos.z / 16))
if (dx < this.viewDistance && dz < this.viewDistance) {
const column = await this.world.getColumnAt(pos)
if (column) {
const chunk = column.toJson()
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk })
this.loadedChunks[`${pos.x},${pos.z}`] = true
}
}
}
unloadChunk (pos) {
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
delete this.loadedChunks[`${pos.x},${pos.z}`]
}
async updatePosition (pos) {
const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ) {
const newView = new ViewRect(botX, botZ, this.viewDistance)
for (const coords of Object.keys(this.loadedChunks)) {
const x = parseInt(coords.split(',')[0])
const z = parseInt(coords.split(',')[1])
const p = new Vec3(x, 0, z)
if (!newView.contains(Math.floor(x / 16), Math.floor(z / 16))) {
this.unloadChunk(p)
}
}
const positions = []
spiral(this.viewDistance * 2, this.viewDistance * 2, (x, z) => {
const p = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
if (!this.loadedChunks[`${p.x},${p.z}`]) {
positions.push(p)
}
})
this.lastPos.update(pos)
await this._loadChunks(positions)
} else {
this.lastPos.update(pos)
}
}
}
module.exports = { WorldView }

View file

@ -0,0 +1,160 @@
/* global Worker */
const THREE = require('three')
const Vec3 = require('vec3').Vec3
const { loadTexture, loadJSON } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')
const { EventEmitter } = require('events')
const { dispose3 } = require('./dispose')
function mod (x, n) {
return ((x % n) + n) % n
}
class WorldRenderer {
constructor (scene, numWorkers = 4) {
this.sectionMeshs = {}
this.scene = scene
this.loadedChunks = {}
this.sectionsOutstanding = new Set()
this.renderUpdateEmitter = new EventEmitter()
this.material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
this.workers = []
for (let i = 0; i < numWorkers; i++) {
// Node environement needs an absolute path, but browser needs the url of the file
let src = __dirname
if (typeof window !== 'undefined') src = 'worker.js'
else src += '/worker.js'
const worker = new Worker(src)
worker.onmessage = ({ data }) => {
if (data.type === 'geometry') {
let mesh = this.sectionMeshs[data.key]
if (mesh) {
this.scene.remove(mesh)
dispose3(mesh)
delete this.sectionMeshs[data.key]
}
const chunkCoords = data.key.split(',')
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]]) return
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.setIndex(data.geometry.indices)
mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
this.sectionMeshs[data.key] = mesh
this.scene.add(mesh)
} else if (data.type === 'sectionFinished') {
this.sectionsOutstanding.delete(data.key)
this.renderUpdateEmitter.emit('update')
}
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
setVersion (version) {
for (const mesh of Object.values(this.sectionMeshs)) {
this.scene.remove(mesh)
}
this.sectionMeshs = {}
for (const worker of this.workers) {
worker.postMessage({ type: 'version', version })
}
loadTexture(`textures/${version}.png`, texture => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
})
loadJSON(`blocksStates/${version}.json`, blockStates => {
for (const worker of this.workers) {
worker.postMessage({ type: 'blockStates', json: blockStates })
}
})
}
addColumn (x, z, chunk) {
this.loadedChunks[`${x},${z}`] = true
for (const worker of this.workers) {
worker.postMessage({ type: 'chunk', x, z, chunk })
}
for (let y = 0; y < 256; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
this.setSectionDirty(loc.offset(-16, 0, 0))
this.setSectionDirty(loc.offset(16, 0, 0))
this.setSectionDirty(loc.offset(0, 0, -16))
this.setSectionDirty(loc.offset(0, 0, 16))
}
}
removeColumn (x, z) {
delete this.loadedChunks[`${x},${z}`]
for (const worker of this.workers) {
worker.postMessage({ type: 'unloadChunk', x, z })
}
for (let y = 0; y < 256; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
const key = `${x},${y},${z}`
const mesh = this.sectionMeshs[key]
if (mesh) {
this.scene.remove(mesh)
dispose3(mesh)
}
delete this.sectionMeshs[key]
}
}
setBlockStateId (pos, stateId) {
for (const worker of this.workers) {
worker.postMessage({ type: 'blockUpdate', pos, stateId })
}
this.setSectionDirty(pos)
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0))
if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0))
if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0))
if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0))
if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16))
if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16))
}
setSectionDirty (pos, value = true) {
// Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section
// is always dispatched to the same worker
const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length)
this.workers[hash].postMessage({ type: 'dirty', x: pos.x, y: pos.y, z: pos.z, value })
this.sectionsOutstanding.add(`${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`)
}
// Listen for chunk rendering updates emitted if a worker finished a render and resolve if the number
// of sections not rendered are 0
waitForChunksToRender () {
return new Promise((resolve, reject) => {
if (Array.from(this.sectionsOutstanding).length === 0) {
resolve()
return
}
const updateHandler = () => {
if (this.sectionsOutstanding.size === 0) {
this.renderUpdateEmitter.removeListener('update', updateHandler)
resolve()
}
}
this.renderUpdateEmitter.on('update', updateHandler)
})
}
}
module.exports = { WorldRenderer }

View file

@ -0,0 +1,31 @@
const path = require('path')
const { makeTextureAtlas } = require('./lib/atlas')
const { prepareBlocksStates } = require('./lib/modelsBuilder')
const mcAssets = require('minecraft-assets')
const fs = require('fs-extra')
const texturesPath = path.resolve(__dirname, '../public/textures')
if (!fs.existsSync(texturesPath)) {
fs.mkdirSync(texturesPath)
}
const blockStatesPath = path.resolve(__dirname, '../public/blocksStates')
if (!fs.existsSync(blockStatesPath)) {
fs.mkdirSync(blockStatesPath)
}
for (const version of mcAssets.versions) {
const assets = mcAssets(version)
const atlas = makeTextureAtlas(assets)
const out = fs.createWriteStream(path.resolve(texturesPath, version + '.png'))
const stream = atlas.canvas.pngStream()
stream.on('data', (chunk) => out.write(chunk))
stream.on('end', () => console.log('Generated textures/' + version + '.png'))
const blocksStates = JSON.stringify(prepareBlocksStates(assets, atlas))
fs.writeFileSync(path.resolve(blockStatesPath, version + '.json'), blocksStates)
fs.copySync(assets.directory, path.resolve(texturesPath, version), { overwrite: true })
}
fs.writeFileSync(path.resolve(__dirname, '../public/supportedVersions.json'), '[' + mcAssets.versions.map(v => `"${v}"`).toString() + ']')

View file

@ -0,0 +1,88 @@
// eslint-disable-next-line no-unused-vars
const webpack = require('webpack')
const path = require('path')
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
// Minify the index.js by removing unused minecraft data. Since the worker only needs to do meshing,
// we can remove all the other data unrelated to meshing.
const blockedIndexFiles = ['blocksB2J', 'blocksJ2B', 'blockMappings', 'steve', 'recipes']
const allowedWorkerFiles = ['blocks', 'blockCollisionShapes', 'tints', 'blockStates',
'biomes', 'features', 'version', 'legacy', 'versions', 'version', 'protocolVersions']
const indexConfig = {
entry: './lib/index.js',
mode: 'production',
output: {
path: path.resolve(__dirname, './public'),
filename: './index.js'
},
resolve: {
fallback: {
zlib: false
}
},
plugins: [
// fix "process is not defined" error:
new webpack.ProvidePlugin({
process: 'process/browser'
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer']
}),
new webpack.NormalModuleReplacementPlugin(
// eslint-disable-next-line
/viewer[\/|\\]lib[\/|\\]utils/,
'./utils.web.js'
)
// new BundleAnalyzerPlugin()
],
externals: [
function (req, cb) {
if (req.context.includes('minecraft-data') && req.request.endsWith('.json')) {
const fileName = req.request.split('/').pop().replace('.json', '')
if (blockedIndexFiles.includes(fileName)) {
cb(null, [])
return
}
}
cb()
}
]
}
const workerConfig = {
entry: './viewer/lib/worker.js',
mode: 'production',
output: {
path: path.join(__dirname, '/public'),
filename: './worker.js'
},
resolve: {
fallback: {
zlib: false
}
},
plugins: [
// fix "process is not defined" error:
new webpack.ProvidePlugin({
process: 'process/browser'
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer']
})
],
externals: [
function (req, cb) {
if (req.context.includes('minecraft-data') && req.request.endsWith('.json')) {
const fileName = req.request.split('/').pop().replace('.json', '')
if (!allowedWorkerFiles.includes(fileName)) {
cb(null, [])
return
}
}
cb()
}
]
}
module.exports = [indexConfig, workerConfig]