diff --git a/.eslintrc.json b/.eslintrc.json index 74bc0a0c0..58df91c97 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,8 @@ { "extends": "zardoy", "ignorePatterns": [ - "!*.js" + "!*.js", + "prismarine-viewer/" ], "rules": { "space-infix-ops": "error", diff --git a/.vscode/launch.json b/.vscode/launch.json index 87e66901e..6bbd4198e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,12 +15,12 @@ "outFiles": [ "${workspaceFolder}/dist/**/*.js", // "!${workspaceFolder}/dist/**/*vendors*", - "!${workspaceFolder}/dist/**/*minecraftData*", + "!${workspaceFolder}/dist/**/*mc-data*", "!**/node_modules/**" ], "skipFiles": [ // "/**/*vendors*" - "/**/*minecraftData*" + "/**/*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": [ // "/**/*vendors*" - "/**/*minecraftData*" + "/**/*mc-data*" ], }, { @@ -54,7 +54,7 @@ "webRoot": "${workspaceFolder}/", "skipFiles": [ // "/**/*vendors*" - "/**/*minecraftData*" + "/**/*mc-data*" ], }, ] diff --git a/package.json b/package.json index 600cb91af..9d610edf7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "web", "client" ], - "bin": "./server.js", "author": "PrismarineJS", "license": "MIT", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba8d9175..bfadb3f89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8fc6b39ce..131aadfec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - "." - "prismarine-viewer" + - "prismarine-viewer/viewer/sign-renderer/" diff --git a/prismarine-viewer/examples/playground.js b/prismarine-viewer/examples/playground.js index 61f6d30bd..629da98f5 100644 --- a/prismarine-viewer/examples/playground.js +++ b/prismarine-viewer/examples/playground.js @@ -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' diff --git a/prismarine-viewer/package.json b/prismarine-viewer/package.json index 341d95395..ad3e601b6 100644 --- a/prismarine-viewer/package.json +++ b/prismarine-viewer/package.json @@ -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", diff --git a/prismarine-viewer/viewer/lib/models.ts b/prismarine-viewer/viewer/lib/models.ts index 605cf2e95..24ee0f8f0 100644 --- a/prismarine-viewer/viewer/lib/models.ts +++ b/prismarine-viewer/viewer/lib/models.ts @@ -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) diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 8a3b35d0d..916cbeab4 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -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() diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index fa4307d3f..4bb1c20c7 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -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 = {}; 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) } diff --git a/prismarine-viewer/viewer/lib/worldrenderer.js b/prismarine-viewer/viewer/lib/worldrenderer.js index 59110f3b0..eeab47568 100644 --- a/prismarine-viewer/viewer/lib/worldrenderer.js +++ b/prismarine-viewer/viewer/lib/worldrenderer.js @@ -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)) { diff --git a/prismarine-viewer/viewer/prepare/atlas.ts b/prismarine-viewer/viewer/prepare/atlas.ts index 23a937079..810eac66b 100644 --- a/prismarine-viewer/viewer/prepare/atlas.ts +++ b/prismarine-viewer/viewer/prepare/atlas.ts @@ -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 } diff --git a/prismarine-viewer/viewer/prepare/generateTextures.ts b/prismarine-viewer/viewer/prepare/generateTextures.ts index 3a0b4388c..0092d992b 100644 --- a/prismarine-viewer/viewer/prepare/generateTextures.ts +++ b/prismarine-viewer/viewer/prepare/generateTextures.ts @@ -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() 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') }) diff --git a/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts b/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts new file mode 100644 index 000000000..c9e4eb8cc --- /dev/null +++ b/prismarine-viewer/viewer/prepare/moreGeneratedBlocks.ts @@ -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 > (sides: T, withUv = false): Promise> => { + 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) => { + 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 }, diff --git a/prismarine-viewer/viewer/sign-renderer/index.html b/prismarine-viewer/viewer/sign-renderer/index.html new file mode 100644 index 000000000..671ad05f2 --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/index.html @@ -0,0 +1,21 @@ + + + + + + + %VITE_NAME% + + + + +
test
+ + + + diff --git a/prismarine-viewer/viewer/sign-renderer/index.ts b/prismarine-viewer/viewer/sign-renderer/index.ts new file mode 100644 index 000000000..164c01a84 --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/index.ts @@ -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 + +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 +} diff --git a/prismarine-viewer/viewer/sign-renderer/noop.js b/prismarine-viewer/viewer/sign-renderer/noop.js new file mode 100644 index 000000000..4ba52ba2c --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/noop.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/prismarine-viewer/viewer/sign-renderer/package.json b/prismarine-viewer/viewer/sign-renderer/package.json new file mode 100644 index 000000000..135782e20 --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/package.json @@ -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" + } +} diff --git a/prismarine-viewer/viewer/sign-renderer/playground.ts b/prismarine-viewer/viewer/sign-renderer/playground.ts new file mode 100644 index 000000000..f8c239b25 --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/playground.ts @@ -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(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) diff --git a/prismarine-viewer/viewer/sign-renderer/vite.config.ts b/prismarine-viewer/viewer/sign-renderer/vite.config.ts new file mode 100644 index 000000000..896ac8651 --- /dev/null +++ b/prismarine-viewer/viewer/sign-renderer/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + resolve: { + alias: { + 'prismarine-registry': "./noop.js", + 'prismarine-nbt': "./noop.js" + }, + }, +}) diff --git a/src/controls.ts b/src/controls.ts index fe49a55dd..def146445 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -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() } } diff --git a/src/createLocalServer.ts b/src/createLocalServer.ts index 0ea0e31c2..80874bbfd 100644 --- a/src/createLocalServer.ts +++ b/src/createLocalServer.ts @@ -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 } diff --git a/src/globals.d.ts b/src/globals.d.ts index 11ad414b1..ee7ff5818 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -2,7 +2,8 @@ declare const THREE: typeof import('three') // todo make optional -declare const bot: import('mineflayer').Bot +declare const bot: Omit & { 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 diff --git a/src/index.ts b/src/index.ts index 017c25b35..ead260014 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = () => { diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts new file mode 100644 index 000000000..2fe00bee0 --- /dev/null +++ b/src/serviceWorker.ts @@ -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() + } + } +}