feat: signs rendering support!

feat: shulker box rendering support
chore: minor improvements
This commit is contained in:
Vitaly 2023-10-07 11:14:48 +03:00
commit 60934372af
25 changed files with 755 additions and 75 deletions

View file

@ -1,7 +1,8 @@
{
"extends": "zardoy",
"ignorePatterns": [
"!*.js"
"!*.js",
"prismarine-viewer/"
],
"rules": {
"space-infix-ops": "error",

10
.vscode/launch.json vendored
View file

@ -15,12 +15,12 @@
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
// "!${workspaceFolder}/dist/**/*vendors*",
"!${workspaceFolder}/dist/**/*minecraftData*",
"!${workspaceFolder}/dist/**/*mc-data*",
"!**/node_modules/**"
],
"skipFiles": [
// "<node_internals>/**/*vendors*"
"<node_internals>/**/*minecraftData*"
"<node_internals>/**/*mc-data*"
],
"port": 9222,
},
@ -36,12 +36,12 @@
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
// "!${workspaceFolder}/dist/**/*vendors*",
"!${workspaceFolder}/dist/**/*minecraftData*",
"!${workspaceFolder}/dist/**/*mc-data*",
"!**/node_modules/**"
],
"skipFiles": [
// "<node_internals>/**/*vendors*"
"<node_internals>/**/*minecraftData*"
"<node_internals>/**/*mc-data*"
],
},
{
@ -54,7 +54,7 @@
"webRoot": "${workspaceFolder}/",
"skipFiles": [
// "<node_internals>/**/*vendors*"
"<node_internals>/**/*minecraftData*"
"<node_internals>/**/*mc-data*"
],
},
]

View file

@ -19,7 +19,6 @@
"web",
"client"
],
"bin": "./server.js",
"author": "PrismarineJS",
"license": "MIT",
"dependencies": {

6
pnpm-lock.yaml generated
View file

@ -62,7 +62,7 @@ importers:
version: 4.18.2
flying-squid:
specifier: github:zardoy/space-squid#everything
version: github.com/zardoy/space-squid/30bfb8d16bf18397e8f3ee927ac33faad60d0e7c
version: github.com/zardoy/space-squid/458eee79e4ff20fccdff8027d3aae16161b9fb1c
fs-extra:
specifier: ^11.1.1
version: 11.1.1
@ -10745,8 +10745,8 @@ packages:
- utf-8-validate
dev: false
github.com/zardoy/space-squid/30bfb8d16bf18397e8f3ee927ac33faad60d0e7c:
resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/30bfb8d16bf18397e8f3ee927ac33faad60d0e7c}
github.com/zardoy/space-squid/458eee79e4ff20fccdff8027d3aae16161b9fb1c:
resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/458eee79e4ff20fccdff8027d3aae16161b9fb1c}
name: flying-squid
version: 1.5.0
engines: {node: '>=8'}

View file

@ -1,3 +1,4 @@
packages:
- "."
- "prismarine-viewer"
- "prismarine-viewer/viewer/sign-renderer/"

View file

@ -1,7 +1,7 @@
//@ts-check
/* global THREE, fetch */
const _ = require('lodash')
const { WorldView, Viewer, MapControls } = require('../viewer')
const { WorldDataEmitter, Viewer, MapControls } = require('../viewer')
const { Vec3 } = require('vec3')
const { Schematic } = require('prismarine-schematic')
const BlockLoader = require('prismarine-block')
@ -10,9 +10,9 @@ const BlockLoader = require('prismarine-block')
const ChunkLoader = require('prismarine-chunk')
/** @type {import('prismarine-world')['default']} */
//@ts-ignore
const WorldLoader = require('prismarine-world');
const WorldLoader = require('prismarine-world')
const THREE = require('three')
const {GUI} = require('lil-gui')
const { GUI } = require('lil-gui')
const { toMajor } = require('../viewer/lib/version')
const { loadScript } = require('../viewer/lib/utils')
globalThis.THREE = THREE
@ -26,8 +26,8 @@ const params = {
skip: '',
version: globalThis.includedVersions.sort((a, b) => {
const s = (x) => {
const parts = x.split('.');
return +parts[0]+(+parts[1])
const parts = x.split('.')
return +parts[0] + (+parts[1])
}
return s(a) - s(b)
}).at(-1),
@ -39,6 +39,7 @@ const params = {
this.entity = ''
},
entityRotate: false,
camera: ''
}
const qs = new URLSearchParams(window.location.search)
@ -59,13 +60,18 @@ const setQs = () => {
async function main () {
const { version } = params
// temporary solution until web worker is here, cache data for faster reloads
if (!window['mcData']['version']) {
const sessionKey = `mcData-${version}`;
const globalMcData = window['mcData'];
if (!globalMcData['version']) {
const major = toMajor(version);
const sessionKey = `mcData-${major}`
if (sessionStorage[sessionKey]) {
window['mcData'][version] = JSON.parse(sessionStorage[sessionKey])
Object.assign(globalMcData, JSON.parse(sessionStorage[sessionKey]))
} else {
await loadScript(`./mc-data/${toMajor(version)}.js`)
sessionStorage[sessionKey] = JSON.stringify(window['mcData'][version])
if (sessionStorage.length > 1) sessionStorage.clear()
await loadScript(`./mc-data/${major}.js`)
try {
sessionStorage[sessionKey] = JSON.stringify(Object.fromEntries(Object.entries(globalMcData).filter(([ver]) => ver.startsWith(major))))
} catch {}
}
}
@ -88,20 +94,30 @@ async function main () {
// const data = await fetch('smallhouse1.schem').then(r => r.arrayBuffer())
// const schem = await Schematic.read(Buffer.from(data), version)
const viewDistance = 1
const viewDistance = 2
const center = new Vec3(0, 90, 0)
const World = WorldLoader(version)
// const diamondSquare = require('diamond-square')({ version, seed: Math.floor(Math.random() * Math.pow(2, 31)) })
const targetBlockPos = center
const targetPos = center
//@ts-ignore
const chunk1 = new Chunk()
//@ts-ignore
const chunk2 = new Chunk()
chunk1.setBlockStateId(center, 34)
chunk2.setBlockStateId(center.offset(1, 0, 0), 34)
const world = new World((chunkX, chunkZ) => {
// if (chunkX === 0 && chunkZ === 0) return chunk1
// if (chunkX === 1 && chunkZ === 0) return chunk2
//@ts-ignore
return new Chunk()
const chunk = new Chunk();
return chunk
})
// await schem.paste(world, new Vec3(0, 60, 0))
const worldView = new WorldDataEmitter(world, viewDistance, center)
// Create three.js context, add to page
@ -113,29 +129,33 @@ async function main () {
// Create viewer
const viewer = new Viewer(renderer)
viewer.setVersion(version)
viewer.listen(worldView)
// Initialize viewer, load chunks
// Load chunks
worldView.init(center)
window['worldView'] = worldView
window['viewer'] = viewer
// const controls = new MapControls(viewer.camera, renderer.domElement)
// controls.update()
//@ts-ignore
const controls = new THREE.OrbitControls(viewer.camera, renderer.domElement)
controls.target.set(center.x + 0.5, center.y + 0.5, center.z + 0.5)
controls.update()
const cameraPos = center.offset(2, 2, 2)
const pitch = THREE.MathUtils.degToRad(-45)
const yaw = THREE.MathUtils.degToRad(45)
viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX')
viewer.camera.lookAt(center.x + 0.5, center.y + 0.5, center.z + 0.5)
viewer.camera.position.set(cameraPos.x + 0.5, cameraPos.y + 0.5, cameraPos.z + 0.5)
let blockProps = {}
const getBlock = () => {
const getBlock = () => {
return mcData.blocksByName[params.block || 'air']
}
const onUpdate = {
block() {
const {states} = mcData.blocksByStateId[getBlock()?.minStateId] ?? {}
block () {
const { states } = mcData.blocksByStateId[getBlock()?.minStateId] ?? {}
folder.destroy()
if (!states) {
return
@ -146,16 +166,16 @@ async function main () {
switch (state.type) {
case 'enum':
defaultValue = state.values[0]
break;
break
case 'bool':
defaultValue = false
break;
break
case 'int':
defaultValue = 0
break;
break
case 'direction':
defaultValue = 'north'
break;
break
default:
continue
@ -173,14 +193,14 @@ async function main () {
viewer.entities.clear()
if (!params.entity) return
worldView.emit('entity', {
id: 'id', name: params.entity, pos: targetBlockPos.offset(0, 1, 0), width: 1, height: 1, username: 'username'
id: 'id', name: params.entity, pos: targetPos.offset(0, 1, 0), width: 1, height: 1, username: 'username'
})
}
}
const applyChanges = (metadataUpdate = false) => {
const blockId = getBlock()?.id;
const blockId = getBlock()?.id
/** @type {BlockLoader.Block} */
let block
if (metadataUpdate) {
@ -197,14 +217,14 @@ async function main () {
block = Block.fromProperties(blockId ?? -1, blockProps, 0)
}
viewer.setBlockStateId(targetBlockPos, block.stateId)
viewer.setBlockStateId(targetPos, block.stateId)
console.log('up', block.stateId)
params.metadata = block.metadata
metadataGui.updateDisplay()
viewer.setBlockStateId(targetBlockPos.offset(0, -1, 0), params.supportBlock ? 1 : 0)
viewer.setBlockStateId(targetPos.offset(0, -1, 0), params.supportBlock ? 1 : 0)
setQs()
}
gui.onChange(({property}) => {
gui.onChange(({ property }) => {
if (property === 'camera') return
onUpdate[property]?.()
applyChanges(property === 'metadata')
@ -233,6 +253,39 @@ async function main () {
})
animate()
// #region camera rotation
if (params.camera) {
const [x, y] = params.camera.split(',')
viewer.camera.rotation.set(parseFloat(x), parseFloat(y), 0, 'ZYX')
controls.update()
console.log(viewer.camera.rotation.x, parseFloat(x))
}
const throttledCamQsUpdate = _.throttle(() => {
const { camera } = viewer
// params.camera = `${camera.rotation.x.toFixed(2)},${camera.rotation.y.toFixed(2)}`
setQs()
}, 200)
controls.addEventListener('change', () => {
throttledCamQsUpdate()
animate()
})
// #endregion
window.onresize = () => {
// const vec3 = new THREE.Vector3()
// vec3.set(-1, -1, -1).unproject(viewer.camera)
// console.log(vec3)
// box.position.set(vec3.x, vec3.y, vec3.z-1)
const { camera } = viewer
viewer.camera.aspect = window.innerWidth / window.innerHeight
viewer.camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
animate()
}
window.dispatchEvent(new Event('resize'))
setTimeout(() => {
// worldView.emit('entity', {
// id: 'id', name: 'player', pos: center.offset(1, -2, 0), width: 1, height: 1, username: 'username'

View file

@ -8,7 +8,8 @@
"pretest": "npm run lint",
"lint": "standard",
"fix": "standard --fix",
"postinstall": "tsx viewer/prepare/generateTextures.ts && node buildWorker.mjs"
"postinstall": "pnpm generate-textures && node buildWorker.mjs",
"generate-textures": "tsx viewer/prepare/generateTextures.ts"
},
"author": "PrismarineJS",
"license": "MIT",

View file

@ -1,6 +1,7 @@
//@ts-nocheck
import { Vec3 } from 'vec3'
import { BlockStatesOutput } from '../prepare/modelsBuilder'
import { World } from './world'
const tints = {}
let blockStates: BlockStatesOutput
@ -367,7 +368,7 @@ function renderElement (world, cursor, element, doAO, attr, globalMatrix, global
}
}
export function getSectionGeometry (sx, sy, sz, world) {
export function getSectionGeometry (sx, sy, sz, world: World) {
const attr = {
sx: sx + 8,
sy: sy + 8,
@ -380,7 +381,9 @@ export function getSectionGeometry (sx, sy, sz, world) {
t_normals: [],
t_colors: [],
t_uvs: [],
indices: []
indices: [],
// todo this can be removed here
signs: {}
}
const cursor = new Vec3(0, 0, 0)
@ -388,6 +391,21 @@ export function getSectionGeometry (sx, sy, sz, world) {
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)
if (block.name.includes('sign')) {
const key = `${cursor.x},${cursor.y},${cursor.z}`
const props = block.getProperties();
const facingRotationMap = {
"north": 2,
"south": 0,
"west": 1,
"east": 3
}
const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('hanging_sign');
attr.signs[key] = {
isWall,
rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
}
}
const biome = block.biome.name
if (block.variant === undefined) {
block.variant = getModelVariants(block)

View file

@ -99,11 +99,13 @@ export class Viewer {
this.updatePrimitive(p)
})
emitter.on('loadChunk', ({ x, z, chunk, blockEntities }) => {
// todo! clean stay in sync instead!
Object.assign(this.world.blockEntities, blockEntities)
emitter.on('loadChunk', ({ x, z, chunk }) => {
this.addColumn(x, z, chunk)
})
// todo remove and use other architecture instead so data flow is clear
emitter.on('blockEntities', (blockEntities) => {
this.world.blockEntities = blockEntities
})
emitter.on('unloadChunk', ({ x, z }) => {
this.removeColumn(x, z)
@ -113,6 +115,8 @@ export class Viewer {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
emitter.emit('listening')
this.domElement.addEventListener('pointerdown', (evt) => {
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()

View file

@ -1,4 +1,6 @@
import { chunkPos } from './simpleUtils'
// todo refactor into its own commons module
import { generateSpiralMatrix, ViewRect } from 'flying-squid/src/utils'
import { Vec3 } from 'vec3'
import { EventEmitter } from 'events'
@ -16,7 +18,7 @@ export class WorldDataEmitter extends EventEmitter {
private eventListeners: Record<string, any> = {};
private emitter: WorldDataEmitter
constructor(public world: import('prismarine-world').world.World, public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
constructor(public world: import('prismarine-world').world.World | typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
super()
this.loadedChunks = {}
this.lastPos = new Vec3(0, 0, 0).update(position)
@ -33,7 +35,7 @@ export class WorldDataEmitter extends EventEmitter {
})
}
listenToBot (bot: import('mineflayer').Bot) {
listenToBot (bot: typeof __type_bot) {
this.eventListeners[bot.username] = {
// 'move': botPosition,
entitySpawn: (e: any) => {
@ -55,6 +57,21 @@ export class WorldDataEmitter extends EventEmitter {
}
}
this.emitter.on('listening', () => {
this.emitter.emit('blockEntities', new Proxy({}, {
get(_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
console.log('get entity', x, y, z)
return bot.world.getBlock(new Vec3(x, y, z)).entity
},
}))
})
// node.js stream data event pattern
if (this.emitter.listenerCount('blockEntities')) {
this.emitter.emit('listening')
}
for (const [evt, listener] of Object.entries(this.eventListeners[bot.username])) {
bot.on(evt as any, listener)
}

View file

@ -1,12 +1,15 @@
//@ts-check
const THREE = require('three')
const Vec3 = require('vec3').Vec3
const { Vec3 } = require('vec3')
const { loadTexture, loadJSON } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')
const { EventEmitter } = require('events')
const { dispose3 } = require('./dispose')
const { dynamicMcDataFiles } = require('../../buildWorkerConfig.mjs')
const mcDataRaw = require('minecraft-data/data.js')
const nbt = require('prismarine-nbt')
const { dynamicMcDataFiles } = require('../../buildWorkerConfig.mjs')
const { dispose3 } = require('./dispose')
const { toMajor } = require('./version.js')
const PrismarineChatLoader = require('prismarine-chat')
const { renderSign } = require('../sign-renderer/')
function mod (x, n) {
return ((x % n) + n) % n
@ -37,9 +40,13 @@ class WorldRenderer {
/** @type {any} */
const worker = new Worker(src)
worker.onmessage = ({ data }) => {
worker.onmessage = async ({ data }) => {
if (!this.active) return
await new Promise(resolve => {
setTimeout(resolve, 0)
})
if (data.type === 'geometry') {
/** @type {THREE.Object3D} */
let mesh = this.sectionMeshs[data.key]
if (mesh) {
this.scene.remove(mesh)
@ -48,7 +55,7 @@ class WorldRenderer {
}
const chunkCoords = data.key.split(',')
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]]) return
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length) return
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
@ -57,8 +64,23 @@ class WorldRenderer {
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)
const _mesh = new THREE.Mesh(geometry, this.material)
_mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
const boxHelper = new THREE.BoxHelper(_mesh, 0xffff00)
// shouldnt it compute once
if (Object.keys(data.geometry.signs).length) {
mesh = new THREE.Group()
mesh.add(_mesh)
mesh.add(boxHelper)
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.signs)) {
const [x, y, z] = posKey.split(',')
const signBlockEntity = this.blockEntities[posKey]
if (!signBlockEntity) continue
mesh.add(this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(signBlockEntity)))
}
} else {
mesh = _mesh
}
this.sectionMeshs[data.key] = mesh
this.scene.add(mesh)
} else if (data.type === 'sectionFinished') {
@ -71,6 +93,36 @@ class WorldRenderer {
}
}
renderSign (/** @type {Vec3} */position, /** @type {number} */rotation, isWall, blockEntity) {
// @ts-ignore
const PrismarineChat = PrismarineChatLoader(this.version)
const canvas = renderSign(blockEntity, PrismarineChat)
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
tex.needsUpdate = true
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true, }))
mesh.renderOrder = 999
// todo @sa2urami shouldnt all this be done in worker?
mesh.scale.set(1, 7 / 16, 1)
if (isWall) {
mesh.position.set(0, 0, -(8 - 1.5) / 16 + 0.001)
} else {
// standing
const faceEnd = 8.75
mesh.position.set(0, 0, (faceEnd - 16 / 2) / 16 + 0.001)
}
const group = new THREE.Group()
const rotateStep = isWall ? 2 : 4
group.rotation.set(0, -(Math.PI / rotateStep) * rotation, 0)
group.add(mesh)
const y = isWall ? 4.5 / 16 + mesh.scale.y / 2 : (1 - (mesh.scale.y / 2))
group.position.set(position.x + 0.5, position.y + y, position.z + 0.5)
return group
}
resetWorld () {
this.active = false
for (const mesh of Object.values(this.sectionMeshs)) {

View file

@ -1,6 +1,7 @@
import fs from 'fs'
import path from 'path'
import { Canvas, Image } from 'canvas'
import { getAdditionalTextures } from './moreGeneratedBlocks'
function nextPowerOfTwo (n) {
if (n === 0) return 1
@ -28,7 +29,10 @@ export function makeTextureAtlas (mcAssets) {
const textureFiles = fs.readdirSync(blocksTexturePath).filter(file => file.endsWith('.png'))
textureFiles.unshift(...localTextures)
const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length)))
const {generated:additionalTextures, twoBlockTextures} = getAdditionalTextures()
textureFiles.push(...Object.keys(additionalTextures))
const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length + twoBlockTextures.length)))
const tileSize = 16
const imgSize = texSize * tileSize
@ -46,13 +50,16 @@ export function makeTextureAtlas (mcAssets) {
const name = textureFiles[i].split('.')[0]
const img = new Image()
img.src = 'data:image/png;base64,' + readTexture(blocksTexturePath, textureFiles[i])
const needsMoreWidth = img.width > tileSize
if (needsMoreWidth) {
console.log('needs more', name, img.width, img.height)
if (additionalTextures[name]) {
img.src = additionalTextures[name]
} else {
img.src = 'data:image/png;base64,' + readTexture(blocksTexturePath, textureFiles[i])
}
const twoTileWidth = twoBlockTextures.includes(name)
if (twoTileWidth) {
offset++
}
const renderWidth = needsMoreWidth ? tileSize * 2 : tileSize
const renderWidth = twoTileWidth ? tileSize * 2 : tileSize
g.drawImage(img, 0, 0, img.width, img.height, x, y, renderWidth, tileSize)
texturesIndex[name] = { u: x / imgSize, v: y / imgSize, su: renderWidth / imgSize, sv: tileSize / imgSize }

View file

@ -3,6 +3,7 @@ import { makeTextureAtlas } from './atlas'
import { McAssets, prepareBlocksStates } from './modelsBuilder'
import mcAssets from 'minecraft-assets'
import fs from 'fs-extra'
import { prepareMoreGeneratedBlocks } from './moreGeneratedBlocks'
const publicPath = path.resolve(__dirname, '../../public')
@ -16,9 +17,17 @@ fs.mkdirSync(texturesPath, { recursive: true })
const blockStatesPath = path.join(publicPath, 'blocksStates')
fs.mkdirSync(blockStatesPath, { recursive: true })
const warnings = new Set<string>()
Promise.resolve().then(async () => {
console.time('generateTextures')
for (const version of mcAssets.versions) {
// for debugging (e.g. when above is overridden)
if (!mcAssets.versions.includes(version)) {
throw new Error(`Version ${version} is not supported by minecraft-assets, skipping...`)
}
const assets = mcAssets(version)
const { warnings: _warnings } = await prepareMoreGeneratedBlocks(assets)
_warnings.forEach(x => warnings.add(x))
// #region texture atlas
const atlas = makeTextureAtlas(assets)
const out = fs.createWriteStream(path.resolve(texturesPath, version + '.png'))
@ -34,4 +43,6 @@ Promise.resolve().then(async () => {
}
fs.writeFileSync(path.join(publicPath, 'supportedVersions.json'), '[' + mcAssets.versions.map(v => `"${v}"`).toString() + ']')
warnings.forEach(x => console.warn(x))
console.timeEnd('generateTextures')
})

View file

@ -0,0 +1,300 @@
import Jimp from 'jimp'
import minecraftData from 'minecraft-data'
import prismarineRegistry from 'prismarine-registry'
import { McAssets } from './modelsBuilder'
// todo refactor
const twoBlockTextures = []
let currentImage: Jimp
let currentBlockName: string
let currentMcAssets: McAssets
let isPreFlattening = false
const postFlatenningRegistry = prismarineRegistry('1.13')
type SidesType = {
"up": string
"north": string
"east": string
"south": string
"west": string
"down": string
}
const getBlockStates = (name: string, postFlatenningName = name) => {
const mcData = isPreFlattening ? postFlatenningRegistry : minecraftData(currentMcAssets.version)
return mcData.blocksByName[isPreFlattening ? postFlatenningName : name]?.states
}
export const addBlockCustomSidesModel = (name: string, sides: SidesType) => {
currentMcAssets.blocksStates[name] = {
"variants": {
"": {
"model": name
}
}
}
currentMcAssets.blocksModels[name] = {
"parent": "block/cube",
"textures": sides
}
}
type TextureMap = [
x: number,
y: number,
width?: number,
height?: number,
]
const justCrop = (x: number, y: number, width = 16, height = 16) => {
return currentImage.clone().crop(x, y, width, height)
}
const combineTextures = (locations: TextureMap[]) => {
const resized: Jimp[] = []
for (const [x, y, height = 16, width = 16] of locations) {
resized.push(justCrop(x, y, width, height))
}
const combinedImage = new Jimp(locations[0]![2] ?? 16, locations[0]![3] ?? 16)
for (const image of resized) {
combinedImage.blit(image, 0, 0)
}
return combinedImage
}
const generatedImageTextures: { [blockName: string]: /* base64 */string } = {}
const getBlockTexturesFromJimp = async <T extends Record<string, Jimp>> (sides: T, withUv = false): Promise<Record<keyof T, any>> => {
const sidesTextures = {} as any
for (const [side, jimp] of Object.entries(sides)) {
const textureName = `${currentBlockName}_${side}`
const sideTexture = withUv ? { uv: [0, 0, jimp.getWidth(), jimp.getHeight()], texture: textureName } : textureName
const base64 = await jimp.getBase64Async(jimp.getMIME())
if (side === 'side') {
sidesTextures['north'] = sideTexture
sidesTextures['east'] = sideTexture
sidesTextures['south'] = sideTexture
sidesTextures['west'] = sideTexture
} else {
sidesTextures[side] = sideTexture
}
generatedImageTextures[textureName] = base64
}
return sidesTextures
}
const addSimpleCubeWithSides = async (sides: Record<string, Jimp>) => {
const sidesTextures = await getBlockTexturesFromJimp(sides)
addBlockCustomSidesModel(currentBlockName, sidesTextures as any)
}
const handleShulkerBox = async (dataBase: string, match: RegExpExecArray) => {
const [, shulkerColor = ''] = match
currentImage = await Jimp.read(dataBase + `entity/shulker/shulker${shulkerColor && `_${shulkerColor}`}.png`)
const shulkerBoxTextures = {
// todo do all sides
side: combineTextures([
[0, 16], // top
[0, 36], // bottom
]),
up: justCrop(16, 0),
down: justCrop(32, 28)
}
await addSimpleCubeWithSides(shulkerBoxTextures)
}
const handleSign = async (dataBase: string, match: RegExpExecArray) => {
const states = getBlockStates(currentBlockName, currentBlockName === 'wall_sign' ? 'wall_sign' : 'sign')
if (!states) return
const [, signMaterial = ''] = match
currentImage = await Jimp.read(`${dataBase}entity/${signMaterial ? `signs/${signMaterial}` : 'sign'}.png`)
// todo cache
const signTextures = {
// todo correct mapping
// todo alg to fit to the side
signboard_side: justCrop(0, 2, 2, 12),
face: justCrop(2, 2, 24, 12),
up: justCrop(2, 0, 24, 2),
support: justCrop(0, 16, 2, 14)
}
const blockTextures = await getBlockTexturesFromJimp(signTextures, true)
const isWall = currentBlockName.includes('wall_')
const isHanging = currentBlockName.includes('hanging_')
const rotationState = states.find(state => state.name === 'rotation')
if (isWall || isHanging) {
// todo isHanging
if (!isHanging) {
const facingState = states.find(state => state.name === 'facing')
const facingMap = {
south: 0,
west: 90,
north: 180,
east: 270
}
currentMcAssets.blocksStates[currentBlockName] = {
"variants": Object.fromEntries(
facingState.values!.map((_val, i) => {
const val = _val as string
return [`facing=${val}`, {
"model": currentBlockName,
y: facingMap[val],
}]
})
)
}
currentMcAssets.blocksModels[currentBlockName] = {
elements: [
{
// signboard
"from": [0, 4.5, 0],
"to": [16, 11.5, 1.5],
faces: {
// north: { texture: blockTextures.face, uv: [0, 0, 16, 16] },
south: { texture: blockTextures.face.texture, uv: [0, 0, 16, 16] },
east: { texture: blockTextures.signboard_side.texture, uv: [0, 0, 16, 16] },
west: { texture: blockTextures.signboard_side.texture, uv: [0, 0, 16, 16] },
up: { texture: blockTextures.up.texture, uv: [0, 0, 16, 16] },
down: { texture: blockTextures.up.texture, uv: [0, 0, 16, 16] },
},
}
],
}
}
} else if (rotationState) {
currentMcAssets.blocksStates[currentBlockName] = {
"variants": Object.fromEntries(
Array.from({ length: 16 }).map((_val, i) => {
return [`rotation=${i}`, {
"model": currentBlockName,
y: i * 45,
}]
})
)
}
const supportTexture = blockTextures.support
// TODO fix models.ts, apply textures for signs correctly!
// const supportTexture = { texture: supportTextureImg, uv: [0, 0, 16, 16] }
currentMcAssets.blocksModels[currentBlockName] = {
elements: [
{
// support post
"from": [7.5, 0, 7.5],
"to": [8.5, 9, 8.5],
faces: {
// todo 14
north: supportTexture,
east: supportTexture,
south: supportTexture,
west: supportTexture,
}
},
{
// signboard
"from": [0, 9, 7.25],
"to": [16, 16, 8.75],
faces: {
north: { texture: blockTextures.face.texture, uv: [0, 0, 16, 16] },
south: { texture: blockTextures.face.texture, uv: [0, 0, 16, 16] },
east: { texture: blockTextures.signboard_side.texture, uv: [0, 0, 16, 16] },
west: { texture: blockTextures.signboard_side.texture, uv: [0, 0, 16, 16] },
up: { texture: blockTextures.up.texture, uv: [0, 0, 16, 16] },
down: { texture: blockTextures.up.texture, uv: [0, 0, 16, 16] },
},
}
],
}
}
twoBlockTextures.push(blockTextures.face.texture)
}
const handlers = [
[/(.+)_shulker_box$/, handleShulkerBox],
[/^shulker_box$/, handleShulkerBox],
[/^sign$/, handleSign],
[/^standing_sign$/, handleSign],
[/^wall_sign$/, handleSign],
[/(.+)_wall_sign$/, handleSign],
[/(.+)_sign$/, handleSign],
// no-op just suppress warning
[/(^light|^moving_piston$)/, true],
] as const
export const tryHandleBlockEntity = async (dataBase, blockName) => {
currentBlockName = blockName
for (const [regex, handler] of handlers) {
const match = regex.exec(blockName)
if (!match) continue
if (handler !== true) {
await handler(dataBase, match)
}
return true
}
}
export const prepareMoreGeneratedBlocks = async (mcAssets: McAssets) => {
const mcData = minecraftData(mcAssets.version)
//@ts-expect-error
isPreFlattening = !mcData.supportFeature('blockStateId')
const allTheBlocks = mcData.blocksArray.map(x => x.name)
currentMcAssets = mcAssets
const handledBlocks = ['water', 'lava', 'barrier']
// todo
const ignoredBlocks = ['skull', 'structure_void', 'banner', 'bed', 'end_portal']
for (const theBlock of allTheBlocks) {
try {
if (await tryHandleBlockEntity(mcAssets.directory, theBlock)) {
handledBlocks.push(theBlock)
}
} catch (err) {
// todo remove when all warnings are resolved
console.warn(`[${mcAssets.version}] failed to generate block ${theBlock}`)
}
}
const warnings = []
for (const [name, model] of Object.entries(mcAssets.blocksModels)) {
if (Object.keys(model).length === 1 && model.textures) {
const keys = Object.keys(model.textures)
if (keys.length === 1 && keys[0] === 'particle') {
if (handledBlocks.includes(name) || ignoredBlocks.includes(name)) continue
warnings.push(`unhandled block ${name}`)
}
}
}
return { warnings }
}
export const getAdditionalTextures = () => {
return { generated: generatedImageTextures, twoBlockTextures }
}
// test below
// const dataBase = '...'
// const blockName = 'light_blue_shulker_box'
// currentMcAssets = {
// blocksModels: {},
// blocksStates: {}
// } as any
// tryHandleBlockEntity(dataBase, blockName).then(() => {
// for (const [key, value] of Object.entries(generatedImageTextures)) {
// console.log(key, value)
// }
// console.log(currentMcAssets)
// })
// { name: 'chest_top', x: 0, y: 0, width: 16, height: 15 },
// { name: 'chest_side_top', x: 0, y: 15, width: 16, height: 5 },
// { name: 'chest_side_bottom', x: 0, y: 34, width: 16, height: 9 },
// { name: 'chest_front', x: 0, y: 43, width: 16, height: 14 },

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover" />
<title>%VITE_NAME%</title>
<style>
@font-face {
font-family: mojangles;
src: url(../../../assets/mojangles.ttf);
}
</style>
</head>
<body>
<div id="root" style="font-family: mojangles;">test</div>
<script src="./playground.ts" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,124 @@
import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component'
import type { ChatMessage } from 'prismarine-chat'
type SignBlockEntity = {
Color?: string
GlowingText?: 0 | 1
Text1?: string
Text2?: string
Text3?: string
Text4?: string
} | {
// todo
is_waxed: 0 | 1
front_text: {
// todo
// has_glowing_text: 0 | 1
color: string
messages: string[]
}
// todo
// back_text: {}
}
type JsonEncodedType = string | null | Record<string, any>
const parseSafe = (text: string, task: string) => {
try {
return JSON.parse(text)
} catch (e) {
console.warn(`Failed to parse ${task}`, e)
return null
}
}
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
const canvas = document.createElement('canvas')
const factor = 50
const signboardY = [16, 9]
const heightOffset = signboardY[0] - signboardY[1]
const heightScalar = heightOffset / 16
canvas.width = 16 * factor
canvas.height = heightOffset * factor
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctxHook(ctx)
const texts = 'is_waxed' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
blockEntity.Text1,
blockEntity.Text2,
blockEntity.Text3,
blockEntity.Text4
]
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
for (let [lineNum, text] of texts.slice(0, 4).entries()) {
// todo test mojangson parsing
const parsed = parseSafe(text ?? '""', 'sign text')
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
// todo fix type
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never
const patchExtra = ({ extra }: TextComponent) => {
if (!extra) return
for (const child of extra) {
if (child.color) {
child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase()
}
patchExtra(child)
}
}
patchExtra(message)
const rendered = render(message)
const toRenderCanvas: {
fontStyle: string
fillStyle: string
underlineStyle: boolean
strikeStyle: boolean
text: string
}[] = []
let plainText = ''
const MAX_LENGTH = 15 // avoid abusing the signboard
const renderText = (node: RenderNode) => {
const { component } = node
let { text } = component
if (plainText.length + text.length > MAX_LENGTH) {
text = text.slice(0, MAX_LENGTH - plainText.length)
if (!text) return false
}
plainText += text
toRenderCanvas.push({
fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`,
fillStyle: node.style['color'] || defaultColor,
underlineStyle: component.underlined ?? false,
strikeStyle: component.strikethrough ?? false,
text
})
for (const child of node.children) {
const stop = renderText(child) === false
if (stop) return false
}
}
renderText(rendered)
const fontSize = 1.6 * factor;
ctx.font = `${fontSize}px mojangles`
const textWidth = ctx.measureText(plainText).width
let renderedWidth = 0
for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) {
// todo strikeStyle, underlineStyle
ctx.fillStyle = fillStyle
ctx.font = `${fontStyle} ${fontSize}px mojangles`
ctx.fillText(text, (canvas.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1))
renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace?
}
}
// ctx.fillStyle = 'red'
// ctx.fillRect(0, 0, canvas.width, canvas.height)
return canvas
}

View file

@ -0,0 +1 @@
module.exports = {}

View file

@ -0,0 +1,14 @@
{
"name": "sign-renderer",
"version": "0.0.0",
"private": true,
"license": "MIT",
"main": "index.ts",
"scripts": {
"start": "vite"
},
"dependencies": {
"@xmcl/text-component": "^2.1.2",
"vite": "^4.4.9"
}
}

View file

@ -0,0 +1,26 @@
import { renderSign } from '.'
import PrismarineChatLoader from 'prismarine-chat'
const PrismarineChat = PrismarineChatLoader({ language: {} } as any)
const img = new Image()
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAMCAYAAAB4MH11AAABbElEQVR4AY3BQY6cMBBA0Q+yQZZVi+ndcJVcKGfMgegdvShKVtuokzGSWwwiUd7rfv388Vst0UgMXCobmgsSA5VaQmKgUks0EgNHji8SA9W8GJCQwVNpLhzJ4KFs4B1HEgPVvBiQkMFTaS44tYTEQDXdIkfiHbuyobmguaDPFzIWGrWExEA13SJH4h1uzS/WbPyvroM1v6jWbFRrNv7GfX5EdmXjzTvUEjJ4zjQXjiQGdmXjzTvUEjJ4HF/UEt/kQqW5UEkMzIshY08jg6dRS3yTC5XmgpsXY7pFztQSEgPNJCNv3lGpJVSfTLfImVpCYsB1HdwfxpU1G9eeNF0H94dxZc2G+/yI7MoG3vEv82LI2NNIDLyVDbzjzFE2mnkxZOy5IoNnkpFGc2FXNpp5MWTsOXJ4h1qikrGnkhjYlY1m1icy9lQSA+TCzjvUEpWMPZXEwK5suPvDOFuzcdZ1sOYX1ZqNas3GlTUbzR+jQbEAcs8ZQAAAAABJRU5ErkJggg=='
await new Promise<void>(resolve => {
img.onload = () => resolve()
})
const blockEntity = {
"GlowingText": 0,
"Color": "black",
"Text4": "{\"text\":\"\"}",
"Text3": "{\"text\":\"\"}",
"Text2": "{\"text\":\"\"}",
"Text1": "{\"extra\":[{\"color\":\"dark_green\",\"text\":\"Minecraft \"},{\"text\":\"Tools\"}],\"text\":\"\"}"
} as const
const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
})
document.body.appendChild(canvas)

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: {
'prismarine-registry': "./noop.js",
'prismarine-nbt': "./noop.js"
},
},
})

View file

@ -228,6 +228,9 @@ document.addEventListener('keydown', (e) => {
for (const [x, z] of loadedChunks) {
worldView.unloadChunk({ x, z })
}
if (localServer) {
localServer.players[0].world.columns = {}
}
reloadChunks()
}
}

View file

@ -8,6 +8,7 @@ export const startLocalServer = () => {
const server = mcServer.createMCServer(passOptions)
server.formatMessage = (message) => `[server] ${message}`
server.options = passOptions
server.looseProtocolMode = true
return server
}

3
src/globals.d.ts vendored
View file

@ -2,7 +2,8 @@
declare const THREE: typeof import('three')
// todo make optional
declare const bot: import('mineflayer').Bot
declare const bot: Omit<import('mineflayer').Bot, 'world'> & { world: import('prismarine-world').world.WorldSync }
declare const __type_bot: typeof bot
declare const viewer: import('prismarine-viewer/viewer/lib/viewer').Viewer | undefined
declare const worldView: import('prismarine-viewer/viewer/lib/worldDataEmitter').WorldDataEmitter | undefined
declare const localServer: any

View file

@ -85,22 +85,15 @@ import { connectToPeer } from './localServerMultiplayer'
import CustomChannelClient from './customClient'
import debug from 'debug'
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
import { registerServiceWorker } from './serviceWorker'
window.debug = debug
window.THREE = THREE
if ('serviceWorker' in navigator && !isCypress() && process.env.NODE_ENV !== 'development') {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js').then(registration => {
console.log('SW registered:', registration)
}).catch(registrationError => {
console.log('SW registration failed:', registrationError)
})
})
}
// ACTUAL CODE
void registerServiceWorker()
// Create three.js context, add to page
const renderer = new THREE.WebGLRenderer({
powerPreference: options.highPerformanceGpu ? 'high-performance' : 'default',
@ -183,7 +176,6 @@ function onCameraMove(e) {
x: e.movementX * mouseSensX * 0.0001,
y: e.movementY * mouseSensY * 0.0001
})
// todo do it also on every block update within radius 5
updateCursor()
}
window.addEventListener('mousemove', onCameraMove, { capture: true })
@ -263,7 +255,7 @@ async function connect(connectOptions: {
setLoadingScreenStatus('Logging in')
let ended = false
let bot: mineflayer.Bot
let bot: typeof __type_bot
const destroyAll = () => {
if (ended) return
ended = true
@ -404,7 +396,7 @@ async function connect(connectOptions: {
await downloadMcData(client.version)
setLoadingScreenStatus('Connecting to server')
}
})
}) as unknown as typeof __type_bot
window.bot = bot
if (singeplayer || p2pMultiplayer) {
// p2pMultiplayer still uses the same flying-squid server
@ -495,7 +487,7 @@ async function connect(connectOptions: {
const center = bot.entity.position
window.worldView = new WorldDataEmitter(bot.world, singeplayer ? renderDistance : Math.min(renderDistance, maxMultiplayerRenderDistance), center)
const worldView = window.worldView = new WorldDataEmitter(bot.world, singeplayer ? renderDistance : Math.min(renderDistance, maxMultiplayerRenderDistance), center)
setRenderDistance()
const updateFov = () => {

23
src/serviceWorker.ts Normal file
View file

@ -0,0 +1,23 @@
import { isCypress } from './utils'
export const registerServiceWorker = async () => {
if (!('serviceWorker' in navigator)) return
if (!isCypress() && process.env.NODE_ENV !== 'development') {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js').then(registration => {
console.log('SW registered:', registration)
}).catch(registrationError => {
console.log('SW registration failed:', registrationError)
})
})
} else {
// force unregister service worker in development mode
const registrations = await navigator.serviceWorker.getRegistrations()
for (const registration of registrations) {
await registration.unregister() // eslint-disable-line no-await-in-loop
}
if (registrations.length) {
location.reload()
}
}
}