minecraft-renderer 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,7 @@ import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer'
33
33
  import { FireworksManager } from './fireworks'
34
34
  import { downloadWorldGeometry } from './worldGeometryExport'
35
35
  import { WorldBlockGeometry } from './worldBlockGeometry'
36
- import type { RendererModuleManifest, RegisteredModule } from './rendererModuleSystem'
36
+ import type { RendererModuleManifest, RegisteredModule, RendererModuleController } from './rendererModuleSystem'
37
37
  import { BUILTIN_MODULES } from './modules/index'
38
38
 
39
39
  type SectionKey = string
@@ -73,7 +73,7 @@ export class WorldRendererThree extends WorldRendererCommon {
73
73
  return this.worldBlockGeometry.estimatedMemoryUsage
74
74
  }
75
75
  // Module system
76
- private modules = new Map<string, RegisteredModule>()
76
+ private modules = {} as Record<string, RegisteredModule>
77
77
  sectionsOffsetsAnimations = {} as {
78
78
  [chunkKey: string]: {
79
79
  time: number,
@@ -170,7 +170,7 @@ export class WorldRendererThree extends WorldRendererCommon {
170
170
  * Register a renderer module
171
171
  */
172
172
  registerModule(manifest: RendererModuleManifest): void {
173
- if (this.modules.has(manifest.id)) {
173
+ if (manifest.id in this.modules) {
174
174
  console.warn(`Module ${manifest.id} is already registered`)
175
175
  return
176
176
  }
@@ -181,12 +181,13 @@ export class WorldRendererThree extends WorldRendererCommon {
181
181
  manifest,
182
182
  controller,
183
183
  enabled: false,
184
+ toggle: () => this.toggleModule(manifest.id),
184
185
  }
185
186
 
186
- this.modules.set(manifest.id, registered)
187
+ this.modules[manifest.id] = registered
187
188
 
188
189
  if (manifest.enabledDefault) {
189
- this.enableModule(manifest.id)
190
+ this.toggleModule(manifest.id, true)
190
191
  }
191
192
  }
192
193
 
@@ -195,7 +196,7 @@ export class WorldRendererThree extends WorldRendererCommon {
195
196
  * Enable a module
196
197
  */
197
198
  enableModule(moduleId: string): void {
198
- const module = this.modules.get(moduleId)
199
+ const module = this.modules[moduleId]
199
200
  if (!module) {
200
201
  console.warn(`Module ${moduleId} not found`)
201
202
  return
@@ -216,7 +217,7 @@ export class WorldRendererThree extends WorldRendererCommon {
216
217
  * Disable a module
217
218
  */
218
219
  disableModule(moduleId: string): void {
219
- const module = this.modules.get(moduleId)
220
+ const module = this.modules[moduleId]
220
221
  if (!module) {
221
222
  console.warn(`Module ${moduleId} not found`)
222
223
  return
@@ -241,21 +242,62 @@ export class WorldRendererThree extends WorldRendererCommon {
241
242
  }
242
243
  }
243
244
 
245
+ /**
246
+ * Toggle a module on/off, or force a specific state
247
+ */
248
+ toggleModule(moduleId: string, forceState?: boolean): boolean {
249
+ const module = this.modules[moduleId]
250
+ if (!module) {
251
+ console.warn(`Module ${moduleId} not found`)
252
+ return false
253
+ }
254
+
255
+ const targetState = forceState !== undefined ? forceState : !module.enabled
256
+
257
+ if (targetState === module.enabled) return module.enabled
258
+
259
+ if (!targetState && module.manifest.cannotBeDisabled) {
260
+ console.warn(`Module ${moduleId} cannot be disabled`)
261
+ return true
262
+ }
263
+
264
+ module.enabled = targetState
265
+
266
+ if (targetState) {
267
+ module.controller.enable()
268
+ // Register render callback if provided
269
+ if (module.controller.render && !this.onRender.includes(module.controller.render)) {
270
+ this.onRender.push(module.controller.render)
271
+ }
272
+ } else {
273
+ module.controller.disable()
274
+ // Unregister render callback if provided
275
+ if (module.controller.render) {
276
+ const index = this.onRender.indexOf(module.controller.render)
277
+ if (index > -1) {
278
+ this.onRender.splice(index, 1)
279
+ }
280
+ }
281
+ }
282
+
283
+ return targetState
284
+ }
285
+
244
286
  /**
245
287
  * Dispose all modules
246
288
  */
247
289
  private disposeModules(): void {
248
- for (const module of this.modules.values()) {
290
+ for (const module of Object.values(this.modules)) {
249
291
  module.controller.dispose()
250
292
  }
251
- this.modules.clear()
293
+ this.modules = {}
252
294
  }
253
295
 
254
296
  /**
255
297
  * Initialize all registered modules
256
298
  */
257
299
  private initializeModules(): void {
258
- for (const [id, module] of this.modules.entries()) {
300
+ for (const [id, module] of Object.entries(this.modules)) {
259
301
  if (module.manifest.enabledDefault) {
260
302
  this.enableModule(id)
261
303
  }
@@ -266,7 +308,7 @@ export class WorldRendererThree extends WorldRendererCommon {
266
308
  * Get a module controller by ID
267
309
  */
268
310
  getModule<T = any>(moduleId: string): T | undefined {
269
- return this.modules.get(moduleId)?.controller as T | undefined
311
+ return this.modules[moduleId]?.controller as T | undefined
270
312
  }
271
313
 
272
314
  get cameraObject() {
@@ -486,7 +528,7 @@ export class WorldRendererThree extends WorldRendererCommon {
486
528
  text += `TE: ${formatBigNumber(this.renderer.info.memory.textures)} `
487
529
  text += `F: ${formatBigNumber(this.tilesRendered)} `
488
530
  text += `B: ${formatBigNumber(this.blocksRendered)} `
489
- text += `MEM: ${this.getEstimatedMemoryUsage().readable}`
531
+ text += `MEM: ${this.worldBlockGeometry.getEstimatedMemoryUsage().readable}`
490
532
  pane.updateText(text)
491
533
  this.backendInfoReport = text
492
534
  }
@@ -1144,11 +1186,4 @@ export class WorldRendererThree extends WorldRendererCommon {
1144
1186
  reloadWorld() {
1145
1187
  this.entities.reloadEntities()
1146
1188
  }
1147
-
1148
- /**
1149
- * Get estimated memory usage in a human-readable format
1150
- */
1151
- getEstimatedMemoryUsage(): { bytes: number; readable: string } {
1152
- return this.worldBlockGeometry.getEstimatedMemoryUsage()
1153
- }
1154
1189
  }
@@ -466,187 +466,187 @@ export function renderWasmOutputToGeometry(
466
466
  if (!cachedModel) continue
467
467
 
468
468
  if (false) {
469
- // For now, use first model variant (can be extended later)
470
- const modelVariant = cachedModel.modelVariants[0]
471
- if (!modelVariant) continue
469
+ // For now, use first model variant (can be extended later)
470
+ const modelVariant = cachedModel!.modelVariants[0]
471
+ if (!modelVariant) continue
472
472
 
473
- const { model, globalMatrix, globalShift, elements } = modelVariant
473
+ const { model, globalMatrix, globalShift, elements } = modelVariant
474
474
 
475
- // Get biome for tint calculation if world is provided
476
- let biome: string | undefined
477
- if (world) {
478
- const blockObj = world.getBlock(new Vec3(bx, by, bz))
479
- biome = blockObj?.biome?.name
480
- }
475
+ // Get biome for tint calculation if world is provided
476
+ let biome: string | undefined
477
+ if (world) {
478
+ const blockObj = world!.getBlock(new Vec3(bx, by, bz))
479
+ biome = blockObj?.biome?.name
480
+ }
481
481
 
482
- // Process faces in the same order as TypeScript (iterate through model's faces)
483
- // TypeScript uses: for (const face in element.faces)
484
- // We need to match this order to get the same vertex ordering
482
+ // Process faces in the same order as TypeScript (iterate through model's faces)
483
+ // TypeScript uses: for (const face in element.faces)
484
+ // We need to match this order to get the same vertex ordering
485
485
 
486
- // Find the element that contains faces (use cached element data)
487
- const faceElements = elements.filter(elemData => elemData.element.faces && Object.keys(elemData.element.faces).length > 0)
486
+ // Find the element that contains faces (use cached element data)
487
+ const faceElements = elements.filter(elemData => elemData.element.faces && Object.keys(elemData.element.faces).length > 0)
488
488
 
489
- if (faceElements.length === 0) continue
489
+ if (faceElements.length === 0) continue
490
490
 
491
- // Map face names to their index in WASM output
492
- const faceNameToIndex: Record<string, number> = {
493
- 'up': 0,
494
- 'down': 1,
495
- 'east': 2,
496
- 'west': 3,
497
- 'south': 4,
498
- 'north': 5
499
- }
491
+ // Map face names to their index in WASM output
492
+ const faceNameToIndex: Record<string, number> = {
493
+ 'up': 0,
494
+ 'down': 1,
495
+ 'east': 2,
496
+ 'west': 3,
497
+ 'south': 4,
498
+ 'north': 5
499
+ }
500
500
 
501
- // WASM processes faces in fixed order: [up, down, east, west, south, north]
502
- // Build a mapping from WASM face order to data index
503
- const wasmFaceOrder = ['up', 'down', 'east', 'west', 'south', 'north']
504
- const wasmFaceToDataIndex: Record<string, number> = {}
505
- let dataIndex = 0
506
- for (const faceName of wasmFaceOrder) {
507
- const faceIdx = faceNameToIndex[faceName]
508
- if ((block.visible_faces & (1 << faceIdx)) !== 0) {
509
- wasmFaceToDataIndex[faceName] = dataIndex++
501
+ // WASM processes faces in fixed order: [up, down, east, west, south, north]
502
+ // Build a mapping from WASM face order to data index
503
+ const wasmFaceOrder = ['up', 'down', 'east', 'west', 'south', 'north']
504
+ const wasmFaceToDataIndex: Record<string, number> = {}
505
+ let dataIndex = 0
506
+ for (const faceName of wasmFaceOrder) {
507
+ const faceIdx = faceNameToIndex[faceName]
508
+ if ((block.visible_faces & (1 << faceIdx)) !== 0) {
509
+ wasmFaceToDataIndex[faceName] = dataIndex++
510
+ }
510
511
  }
511
- }
512
512
 
513
- // Process faces in the order they appear in the model (matching TS)
514
- for (const elemData of faceElements) {
515
- const element = elemData.element
516
- const localMatrix = elemData.localMatrix
517
- const localShift = elemData.localShift
513
+ // Process faces in the order they appear in the model (matching TS)
514
+ for (const elemData of faceElements) {
515
+ const element = elemData.element
516
+ const localMatrix = elemData.localMatrix
517
+ const localShift = elemData.localShift
518
518
 
519
- // eslint-disable-next-line guard-for-in
520
- for (const faceName in element.faces) {
521
- const faceIdx = faceNameToIndex[faceName]
522
- if (faceIdx === undefined) continue
519
+ // eslint-disable-next-line guard-for-in
520
+ for (const faceName in element.faces) {
521
+ const faceIdx = faceNameToIndex[faceName]
522
+ if (faceIdx === undefined) continue
523
523
 
524
- // Check if this face is visible in WASM output
525
- if ((block.visible_faces & (1 << faceIdx)) === 0) {
526
- continue
527
- }
524
+ // Check if this face is visible in WASM output
525
+ if ((block.visible_faces & (1 << faceIdx)) === 0) {
526
+ continue
527
+ }
528
528
 
529
- const matchingEFace = element.faces[faceName]
530
- const { dir, corners, mask1, mask2 } = elemFaces[faceName]
529
+ const matchingEFace = element.faces[faceName]
530
+ const { dir, corners, mask1, mask2 } = elemFaces[faceName]
531
531
 
532
- // Get the correct data index for this face based on WASM's processing order
533
- const faceDataIndex = wasmFaceToDataIndex[faceName]
534
- if (faceDataIndex === undefined) continue
532
+ // Get the correct data index for this face based on WASM's processing order
533
+ const faceDataIndex = wasmFaceToDataIndex[faceName]
534
+ if (faceDataIndex === undefined) continue
535
535
 
536
- const aoValues = block.ao_data[faceDataIndex]
537
- const lightValues = block.light_data[faceDataIndex]
536
+ const aoValues = block.ao_data[faceDataIndex]
537
+ const lightValues = block.light_data[faceDataIndex]
538
538
 
539
- log(`[WASM] Face ${faceIdx} (${faceName}): dir=[${dir.join(',')}], ao=[${aoValues.join(',')}], light=[${lightValues.map(l => l.toFixed(3)).join(',')}]`)
539
+ log(`[WASM] Face ${faceIdx} (${faceName}): dir=[${dir.join(',')}], ao=[${aoValues.join(',')}], light=[${lightValues.map(l => l.toFixed(3)).join(',')}]`)
540
540
 
541
- const texture = matchingEFace.texture as any
542
- const u = texture.u || 0
543
- const v = texture.v || 0
544
- const su = texture.su || 1
545
- const sv = texture.sv || 1
541
+ const texture = matchingEFace.texture as any
542
+ const u = texture.u || 0
543
+ const v = texture.v || 0
544
+ const su = texture.su || 1
545
+ const sv = texture.sv || 1
546
546
 
547
- // UV rotation (matching reference implementation)
548
- let r = matchingEFace.rotation || 0
549
- if (faceName === 'down') {
550
- r += 180
551
- }
552
- const uvcs = Math.cos(r * Math.PI / 180)
553
- const uvsn = -Math.sin(r * Math.PI / 180)
554
-
555
- // Get tint (use cached model data and world if available)
556
- const tint = getTint(matchingEFace, cachedModel.blockName, cachedModel.blockProps, biome, world)
557
-
558
- const minx = element.from[0]
559
- const miny = element.from[1]
560
- const minz = element.from[2]
561
- const maxx = element.to[0]
562
- const maxy = element.to[1]
563
- const maxz = element.to[2]
564
-
565
- // Calculate transformed direction
566
- const transformedDir = matmul3(globalMatrix, dir)
567
-
568
- // Add 4 vertices for this face
569
- const baseIndex = currentIndex
570
- for (let cornerIdx = 0; cornerIdx < 4; cornerIdx++) {
571
- const pos = corners[cornerIdx]
572
-
573
- // Calculate vertex position (matching reference)
574
- let vertex = [
575
- (pos[0] ? maxx : minx),
576
- (pos[1] ? maxy : miny),
577
- (pos[2] ? maxz : minz)
578
- ]
579
-
580
- // Apply element rotation
581
- vertex = vecadd3(matmul3(localMatrix, vertex), localShift)
582
- // Apply model rotation
583
- vertex = vecadd3(matmul3(globalMatrix, vertex), globalShift)
584
- // Convert to block coordinates (0-1)
585
- vertex = vertex.map(v => v / 16)
586
-
587
- // World position (relative to section)
588
- const worldPos = [
589
- vertex[0] + (bx & 15) - 8,
590
- vertex[1] + (by & 15) - 8,
591
- vertex[2] + (bz & 15) - 8
592
- ]
593
-
594
- log(`[WASM] Corner ${cornerIdx}: corner=[${pos.join(',')}], vertex=[${vertex.map(v => v.toFixed(3)).join(',')}], worldPos=[${worldPos.map(v => v.toFixed(3)).join(',')}]`)
595
-
596
- positions.push(...worldPos)
597
-
598
- // Normal (transformed direction)
599
- normals.push(transformedDir[0], transformedDir[1], transformedDir[2])
600
-
601
- // Color (with AO and light from WASM) - matching TS formula exactly
602
- const ao = aoValues[cornerIdx]
603
-
604
- // TS calculation:
605
- // baseLight = world.getLight(neighborPos, ...) / 15 (0-1 range)
606
- // cornerLightResult = baseLight * 15 (0-15 range, or interpolated if smooth lighting)
607
- // light = (ao + 1) / 4 * (cornerLightResult / 15)
608
- // finalColor = baseLight * tint * light
609
-
610
- // WASM provides lightValues in 0-1 range (already divided by 15)
611
- // But WASM light calculation seems to return 0.0, so we need to handle that
612
- // In the test case, TypeScript gets baseLight = 1.0 (full brightness)
613
- // So we should use 1.0 as the base light value when WASM returns 0
614
- const baseLight = lightValues[cornerIdx]
615
- const cornerLightResult = baseLight * 15
616
-
617
- const light = (ao + 1) / 4 * (cornerLightResult / 15)
618
-
619
- colors.push(tint[0] * light, tint[1] * light, tint[2] * light)
620
-
621
- // UV calculation (matching reference exactly)
622
- const baseu = (pos[3] - 0.5) * uvcs - (pos[4] - 0.5) * uvsn + 0.5
623
- const basev = (pos[3] - 0.5) * uvsn + (pos[4] - 0.5) * uvcs + 0.5
624
- const finalU = baseu * su + u
625
- const finalV = basev * sv + v
626
- log(`[WASM] UV: cornerUV=[${pos[3]},${pos[4]}], baseUV=[${baseu.toFixed(6)},${basev.toFixed(6)}], finalUV=[${finalU.toFixed(6)},${finalV.toFixed(6)}], texture=[u=${u},v=${v},su=${su},sv=${sv}], rotation=${r}`)
627
- uvs.push(finalU, finalV)
628
-
629
- currentIndex++
630
- }
547
+ // UV rotation (matching reference implementation)
548
+ let r = matchingEFace.rotation || 0
549
+ if (faceName === 'down') {
550
+ r += 180
551
+ }
552
+ const uvcs = Math.cos(r * Math.PI / 180)
553
+ const uvsn = -Math.sin(r * Math.PI / 180)
631
554
 
632
- // Add indices (2 triangles) - matching TS AO-optimized winding
633
- // TS uses: if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) { optimized } else { standard }
634
- let tri1: number[], tri2: number[]
635
- if (aoValues[0] + aoValues[3] >= aoValues[1] + aoValues[2]) {
636
- // AO-optimized winding
637
- tri1 = [baseIndex, baseIndex + 3, baseIndex + 2]
638
- tri2 = [baseIndex, baseIndex + 1, baseIndex + 3]
639
- log(`[WASM] Indices (AO optimized): tri1=[${tri1.join(',')}], tri2=[${tri2.join(',')}], aos=[${aoValues.join(',')}]`)
640
- } else {
641
- // Standard winding
642
- tri1 = [baseIndex, baseIndex + 1, baseIndex + 2]
643
- tri2 = [baseIndex + 2, baseIndex + 1, baseIndex + 3]
644
- log(`[WASM] Indices (standard): tri1=[${tri1.join(',')}], tri2=[${tri2.join(',')}], aos=[${aoValues.join(',')}]`)
645
- }
646
- indices.push(...tri1, ...tri2)
555
+ // Get tint (use cached model data and world if available)
556
+ const tint = getTint(matchingEFace, cachedModel!.blockName, cachedModel!.blockProps, biome, world)
557
+
558
+ const minx = element.from[0]
559
+ const miny = element.from[1]
560
+ const minz = element.from[2]
561
+ const maxx = element.to[0]
562
+ const maxy = element.to[1]
563
+ const maxz = element.to[2]
564
+
565
+ // Calculate transformed direction
566
+ const transformedDir = matmul3(globalMatrix, dir)
567
+
568
+ // Add 4 vertices for this face
569
+ const baseIndex = currentIndex
570
+ for (let cornerIdx = 0; cornerIdx < 4; cornerIdx++) {
571
+ const pos = corners[cornerIdx]
572
+
573
+ // Calculate vertex position (matching reference)
574
+ let vertex = [
575
+ (pos[0] ? maxx : minx),
576
+ (pos[1] ? maxy : miny),
577
+ (pos[2] ? maxz : minz)
578
+ ]
579
+
580
+ // Apply element rotation
581
+ vertex = vecadd3(matmul3(localMatrix, vertex), localShift)
582
+ // Apply model rotation
583
+ vertex = vecadd3(matmul3(globalMatrix, vertex), globalShift)
584
+ // Convert to block coordinates (0-1)
585
+ vertex = vertex.map(v => v / 16)
586
+
587
+ // World position (relative to section)
588
+ const worldPos = [
589
+ vertex[0] + (bx & 15) - 8,
590
+ vertex[1] + (by & 15) - 8,
591
+ vertex[2] + (bz & 15) - 8
592
+ ]
593
+
594
+ log(`[WASM] Corner ${cornerIdx}: corner=[${pos.join(',')}], vertex=[${vertex.map(v => v.toFixed(3)).join(',')}], worldPos=[${worldPos.map(v => v.toFixed(3)).join(',')}]`)
595
+
596
+ positions.push(...worldPos)
597
+
598
+ // Normal (transformed direction)
599
+ normals.push(transformedDir[0], transformedDir[1], transformedDir[2])
600
+
601
+ // Color (with AO and light from WASM) - matching TS formula exactly
602
+ const ao = aoValues[cornerIdx]
603
+
604
+ // TS calculation:
605
+ // baseLight = world.getLight(neighborPos, ...) / 15 (0-1 range)
606
+ // cornerLightResult = baseLight * 15 (0-15 range, or interpolated if smooth lighting)
607
+ // light = (ao + 1) / 4 * (cornerLightResult / 15)
608
+ // finalColor = baseLight * tint * light
609
+
610
+ // WASM provides lightValues in 0-1 range (already divided by 15)
611
+ // But WASM light calculation seems to return 0.0, so we need to handle that
612
+ // In the test case, TypeScript gets baseLight = 1.0 (full brightness)
613
+ // So we should use 1.0 as the base light value when WASM returns 0
614
+ const baseLight = lightValues[cornerIdx]
615
+ const cornerLightResult = baseLight * 15
616
+
617
+ const light = (ao + 1) / 4 * (cornerLightResult / 15)
618
+
619
+ colors.push(tint[0] * light, tint[1] * light, tint[2] * light)
620
+
621
+ // UV calculation (matching reference exactly)
622
+ const baseu = (pos[3] - 0.5) * uvcs - (pos[4] - 0.5) * uvsn + 0.5
623
+ const basev = (pos[3] - 0.5) * uvsn + (pos[4] - 0.5) * uvcs + 0.5
624
+ const finalU = baseu * su + u
625
+ const finalV = basev * sv + v
626
+ log(`[WASM] UV: cornerUV=[${pos[3]},${pos[4]}], baseUV=[${baseu.toFixed(6)},${basev.toFixed(6)}], finalUV=[${finalU.toFixed(6)},${finalV.toFixed(6)}], texture=[u=${u},v=${v},su=${su},sv=${sv}], rotation=${r}`)
627
+ uvs.push(finalU, finalV)
628
+
629
+ currentIndex++
630
+ }
631
+
632
+ // Add indices (2 triangles) - matching TS AO-optimized winding
633
+ // TS uses: if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) { optimized } else { standard }
634
+ let tri1: number[], tri2: number[]
635
+ if (aoValues[0] + aoValues[3] >= aoValues[1] + aoValues[2]) {
636
+ // AO-optimized winding
637
+ tri1 = [baseIndex, baseIndex + 3, baseIndex + 2]
638
+ tri2 = [baseIndex, baseIndex + 1, baseIndex + 3]
639
+ log(`[WASM] Indices (AO optimized): tri1=[${tri1.join(',')}], tri2=[${tri2.join(',')}], aos=[${aoValues.join(',')}]`)
640
+ } else {
641
+ // Standard winding
642
+ tri1 = [baseIndex, baseIndex + 1, baseIndex + 2]
643
+ tri2 = [baseIndex + 2, baseIndex + 1, baseIndex + 3]
644
+ log(`[WASM] Indices (standard): tri1=[${tri1.join(',')}], tri2=[${tri2.join(',')}], aos=[${aoValues.join(',')}]`)
645
+ }
646
+ indices.push(...tri1, ...tri2)
647
+ }
647
648
  }
648
649
  }
649
- }
650
650
 
651
651
  const models = cachedModel.models
652
652
  if (!models || models.length == 0) continue
@@ -363,7 +363,7 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
363
363
 
364
364
  // Unload chunks that are no longer in view
365
365
  const chunksToUnload: Vec3[] = []
366
- const viewDistanceWithBuffer = this.viewDistance + this.keepChunksDistance
366
+ const viewDistanceWithBuffer = force ? this.viewDistance : this.viewDistance + this.keepChunksDistance
367
367
 
368
368
  for (const coords of Object.keys(this.loadedChunks)) {
369
369
  const [x, z] = coords.split(',').map(Number)