Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ Vec3 instance.

Array of bounding boxes representing the block shape. Each bounding box is an array of the form `[xmin, ymin, zmin, xmax, ymax, zmax]`. Depends on the type and state of the block.

#### block.interactionShapes

Selection boxes for cursor targeting, in the same local-coordinate format as `block.shapes`.
This does not modify physical collision geometry. Java selection data must be available in the registry's `blockSelectionShapes` dataset; otherwise this returns `block.shapes`, including for Bedrock.

Position-dependent model offsets use `block.position` (or the origin when no position is set). Legacy neighbor-dependent shapes require the corresponding computed properties; ambiguous states fall back to collision geometry. These are default-context shapes, not every held-item-dependent selection context.

#### block.entity

If this block is a block entity, this contains the NBT data for the entity.
Expand Down
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export declare class Block {
*/
shapes: Shape[]

/**
* Selection geometry, separate from physical collision shapes. Uses position
* for model offsets; falls back to shapes when selection data is unavailable.
*/
readonly interactionShapes: Shape[]

hardness: number;

/**
Expand Down
5 changes: 5 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function loader (registryOrVersion) {

function provider (registry, { Biome, version }) {
const blockMethods = require('./blockEntity')(registry)
const interactionShapes = require('./interactionShapes')(registry)
const usesBlockStates = (version.type === 'pc' && registry.supportFeature('blockStateId')) || (version.type === 'bedrock')
const shapes = registry.blockCollisionShapes
if (shapes) {
Expand Down Expand Up @@ -276,6 +277,10 @@ function provider (registry, { Biome, version }) {
return this.entity ? nbt.simplify(this.entity) : undefined
}

get interactionShapes () {
return interactionShapes(this)
}

getProperties () {
return Object.assign(this._properties, this.computedStates)
}
Expand Down
56 changes: 56 additions & 0 deletions interactionShapes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module.exports = function (registry) {
const data = registry.version.type === 'pc' ? registry.blockSelectionShapes : null
if (!data) return block => block.shapes

const namesById = data.blockIds && Object.fromEntries(Object.entries(data.blockIds).map(([name, id]) => [id, name]))
const metadataIndices = {}
for (const [name, metadata] of Object.entries(data.stateMetadata || {})) {
const indices = {}
for (let index = 0; index < metadata.length; index++) (indices[metadata[index]] ??= []).push(index)
metadataIndices[name] = indices
}

return block => {
if (!block.name) return block.shapes
const name = namesById ? namesById[block.type] : block.name
const reference = data.blocks[name]
if (reference === undefined) return block.shapes
let index = block.metadata
if (data.stateMetadata) {
// Legacy base properties are shared; merge computed context into a copy.
const properties = { ...block._properties, ...block.computedStates }
const candidates = (metadataIndices[name]?.[block.metadata] || []).filter(index => {
const native = data.stateProperties[name][index]
return Object.entries(properties).every(([key, value]) => !Object.hasOwn(native, key) || native[key] === String(value))
})
if (!candidates.length) return block.shapes
index = candidates[0]
const shapeAt = index => Array.isArray(reference) ? reference[index] : reference
// Different neighbor-derived states may share metadata. Without enough
// properties to distinguish their shapes, retain collision behavior.
if (candidates.some(candidate => shapeAt(candidate) !== shapeAt(index) || JSON.stringify(data.offsets?.[name]?.[candidate]) !== JSON.stringify(data.offsets?.[name]?.[index]))) return block.shapes
}
const shapeId = Array.isArray(reference) ? reference[index] : reference
if (shapeId === undefined) return block.shapes
const boxes = data.shapes[shapeId]
if (!boxes) return block.shapes
const offset = data.offsets?.[name]?.[index]
if (!offset) return boxes
if (!['model', 'legacyModel'].includes(offset.type)) return block.shapes
const shift = modelOffset(block.position, offset)
return boxes.map(box => box.map((value, axis) => value + shift[axis % 3]))
}
}

function modelOffset (position, descriptor) {
const x = Math.floor(position?.x ?? 0)
const z = Math.floor(position?.z ?? 0)
let seed = BigInt(Math.imul(x, 3129871)) ^ (BigInt(z) * 116129781n)
seed = BigInt.asIntN(64, seed * seed * 42317861n + seed * 11n) >> 16n
const fraction = shift => Math.fround(Number((seed >> shift) & 15n) / 15)
const clamp = value => Math.max(-descriptor.maxHorizontal, Math.min(descriptor.maxHorizontal, value))
// Older Java versions multiply the vertical offset as a double, newer ones
// use float arithmetic. Keep both overflow and rounding identical to Java.
const y = descriptor.type === 'legacyModel' ? (fraction(4n) - 1) * descriptor.verticalMultiplier : Math.fround(Math.fround(fraction(4n) - 1) * descriptor.verticalMultiplier)
return [clamp((fraction(0n) - 0.5) * 0.5), y, clamp((fraction(8n) - 0.5) * 0.5)]
}
104 changes: 104 additions & 0 deletions test/interactionShapes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/* eslint-env mocha */

const assert = require('assert')
const loader = require('..')
const createRegistry = require('prismarine-registry')

describe('interaction shapes', () => {
it('keeps collision shapes as the fallback for existing registries', () => {
for (const version of loader.testedVersions) {
const registry = createRegistry(version)
delete registry.blockSelectionShapes
const Block = loader(registry)
const block = Block.fromStateId(registry.blocksByName.stone.defaultState, 0)
assert.strictEqual(block.interactionShapes, block.shapes)
}
})

it('reads state-dependent selection geometry without changing collision shapes', () => {
const registry = createRegistry('1.20.1')
const heights = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]
registry.blockSelectionShapes = {
blocks: { wheat: heights.map((_, index) => index) },
shapes: Object.fromEntries(heights.map((height, index) => [index, [[0, 0, 0, 1, height, 1]]]))
}
const Block = loader(registry)
for (let age = 0; age < heights.length; age++) {
const block = Block.fromProperties('wheat', { age }, 0)
assert.deepStrictEqual(block.shapes, [])
assert.deepStrictEqual(block.interactionShapes, [[0, 0, 0, 1, heights[age], 1]])
assert.deepStrictEqual(block.shapes, [])
}
})

it('updates position-dependent outlines without modifying the data', () => {
const registry = createRegistry('1.20.1')
registry.blockSelectionShapes = {
blocks: { dandelion: 0 },
shapes: { 0: [[0.3125, 0, 0.3125, 0.6875, 0.625, 0.6875]] },
offsets: { dandelion: [{ type: 'model', maxHorizontal: 0.25, verticalMultiplier: 0 }] }
}
const snapshot = JSON.stringify(registry.blockSelectionShapes)
const Block = loader(registry)
const block = Block.fromProperties('dandelion', {}, 0)
assert.deepStrictEqual(block.interactionShapes, [[0.0625, 0, 0.0625, 0.4375, 0.625, 0.4375]])
block.position = { x: 17, y: 100, z: -13 }
assert.deepStrictEqual(block.interactionShapes, [[0.26250000298023224, 0, 0.16250000149011612, 0.6375000029802322, 0.625, 0.5375000014901161]])
assert.strictEqual(JSON.stringify(registry.blockSelectionShapes), snapshot)
assert.deepStrictEqual(block.shapes, [])
})

it('uses native IDs for renamed legacy blocks and explicit metadata indices', () => {
const registry = createRegistry('1.12.2')
const id = registry.blocksByName.light_gray_shulker_box.id
registry.blockSelectionShapes = {
blockIds: { silver_shulker_box: id },
blocks: { silver_shulker_box: [0] },
shapes: { 0: [[0, 0, 0, 1, 1, 1]] },
stateMetadata: { silver_shulker_box: [2] },
stateProperties: { silver_shulker_box: [{ facing: 'north' }] }
}
const Block = loader(registry)
const block = new Block(id, 0, 2)
assert.strictEqual(block.interactionShapes, registry.blockSelectionShapes.shapes[0])
})

it('does not guess between different shapes with unresolved computed properties', () => {
const registry = createRegistry('1.12.2')
registry.blockSelectionShapes = {
blocks: { stone: [0, 1] },
shapes: { 0: [[0, 0, 0, 1, 0.5, 1]], 1: [[0, 0.5, 0, 1, 1, 1]] },
stateMetadata: { stone: [0, 0] },
stateProperties: { stone: [{ context: 'a' }, { context: 'b' }] }
}
const Block = loader(registry)
const block = new Block(registry.blocksByName.stone.id, 0, 0)
assert.strictEqual(block.interactionShapes, block.shapes)
block.computedStates.context = 'b'
assert.strictEqual(block.interactionShapes, registry.blockSelectionShapes.shapes[1])
})

it('preserves explicitly empty selection shapes', () => {
const registry = createRegistry('1.20.1')
registry.blockSelectionShapes = { blocks: { water: 0 }, shapes: { 0: [] } }
const Block = loader(registry)
const block = Block.fromProperties('water', { level: 0 }, 0)
assert.strictEqual(block.interactionShapes, registry.blockSelectionShapes.shapes[0])
})

it('does not leak computed properties into another legacy block', () => {
const registry = createRegistry('1.12.2')
registry.blockSelectionShapes = {
blocks: { stone: [0, 1] },
shapes: { 0: [[0, 0, 0, 1, 0.5, 1]], 1: [[0, 0.5, 0, 1, 1, 1]] },
stateMetadata: { stone: [0, 0] },
stateProperties: { stone: [{ neighbor: 'a' }, { neighbor: 'b' }] }
}
const Block = loader(registry)
const first = new Block(registry.blocksByName.stone.id, 0, 0)
const second = new Block(registry.blocksByName.stone.id, 0, 0)
first.computedStates.neighbor = 'b'
assert.strictEqual(first.interactionShapes, registry.blockSelectionShapes.shapes[1])
assert.strictEqual(second.interactionShapes, second.shapes)
})
})
Loading