sandstone-npcs
v0.0.2
Published
Mannequin-based NPCs with dialogue trees for Sandstone datapacks
Readme
sandstone-npcs
Mannequin-based NPCs for Sandstone datapacks: skinned, posable mannequins with typewriter-style dialogue trees. Fully self-contained, click detection uses a vanilla advancement trigger, no external dependencies required.
Install
bun add sandstone-npcsQuick start
import { CreateNPC, ProfileProperties } from 'sandstone-npcs'
import { give, MCFunction } from 'sandstone'
const shopkeeper = CreateNPC('shopkeeper', {
name: 'Shopkeeper',
skin: { properties: ProfileProperties('<skin texture hash>') },
lookAt: 'interactor',
dialogue: (d) => d
.node('main', {
advance: 'click',
lines: [
{ text: 'Welcome to the shop!' },
{ text: 'Take a look around.' },
],
// no `next` needed chains straight into 'gift' below
})
.node('gift', {
advance: 'auto',
autoDelay: 30,
lines: [{
text: 'Here, have this on the house.',
onComplete: () => give('@s', 'minecraft:paper', 1),
}],
}),
})
// CreateNPC only defines the NPC so call spawn()
MCFunction('myPack:load', () => {
shopkeeper.spawn([0, 64, 0], [180, 0])
}, { runOnLoad: true })NPC options
interface NPCOptions {
name: string
skin: /* a minecraft:profile component - see Skins below */
lookAt?: 'nearest' | 'interactor' | 'none' // defaults to 'nearest'
lookDistance?: number // blocks - "nearest" search radius, defaults to 5
dismissDistance?: number // blocks - interactor can wander this far before dialogue auto-ends, defaults to 5
pose?: 'standing' | 'crouching' | 'sleeping' | 'sitting' // defaults to 'standing'
mainHand?: string | ItemWithComponents
offHand?: string | ItemWithComponents
dialogue?: DialogueTree | ((d: DialogueBuilder) => void) // takes precedence over onInteract
onInteract?: () => void // runs as the NPC - see asInteractor() below
}Omit both dialogue and onInteract for a purely decorative NPC, no click detection gets set up for it at all.
The NPC instance
CreateNPC returns a handle with methods for controlling that one NPC:
interface NPCInstance {
readonly id: string
// Selector matching this NPC's mannequin
readonly Selector: SelectorClass
//Summons the mannequin (+ hitbox/seat) at the given position
spawn(position: [number, number, number], rotation?: [number, number]): void
// Removes this NPC's entities.
kill(): void
// Simulates a player interacting with this NPC (same effect as a click)
trigger(): void
// Makes the NPC face a player/entity selector or a fixed position, once.
lookAt(target: /* a single-entity selector */ | [number, number, number]): void
// Teleports the NPC. Omit `position` to teleport to wherever the caller's current execution context is
teleport(position?: [number, number, number], rotation?: [number, number]): void
// Changes pose. Switching to/from 'sitting' summons/removes its seat as needed.
pose(pose: 'standing' | 'crouching' | 'sleeping' | 'sitting'): void
// Re-equips the NPC's main/off hand. Omit a side to leave it as-is.
equip(item: { mainHand?: string | ItemWithComponents, offHand?: string | ItemWithComponents }): void
// Changes the NPC's skin.
skin(skin: /* a minecraft:profile component */): void
// Runs a callback as this NPC's mannequin.
asMe(callback: () => void): void
// Runs a callback as whichever player is currently tagged as this NPC's interactor
asInteractor(callback: () => void): void
// Plays the NPC's punch animation
swing(hand?: 'mainhand' | 'offhand'): void
}There's also an exported despawnNpcs() function that kills every NPC ever created with CreateNPC, if you want a single bulk cleanup call.
Dialogue trees
Build a dialogue inline via CreateNPC's dialogue option.
CreateNPC('id', {
// ...
dialogue: (d) => d
// optional: fallback speed/revealMode for every line below that doesn't set its own.
.options({ revealMode: 'word', speed: 4 })
.node('main', {
advance: 'click', // or 'auto' + autoDelay (ticks to hold before continuing)
lines: [
{ text: 'Static line.' },
{ variants: ['Picks one of these at random.', 'Or this one.'] },
{
text: 'Skipped unless the condition holds.',
condition: someCondition,
revealMode: 'instant', // 'character' (default) | 'word' | 'instant'
speed: 2, // ticks per revealed unit (character, or word in 'word' mode)
onShow: () => { /* runs as the player right when this line starts */ },
onComplete: () => { /* runs as the player once fully revealed */ },
},
],
// next defaults to whichever node is added right after this one, only
// needed to branch or jump elsewhere:
// next: { condition: someCondition, then: 'nodeA', else: 'nodeB' },
})
.node('nodeA', { lines: [...] }),
})Text reveals one of three ways, set per-line via revealMode (or tree-wide via .options()):
'character'(default) - classic typewriter, one character at a time'word'- reveals a whole word at a time (speedthen means ticks per word, not per character)'instant'- the line appears all at once;advance/autoDelaystill govern what happens after it's up
start(id) picks a non-default starting node (defaults to the first one added).
Need one dialogue tree shared across several NPCs? Build it separately with the same builder callback via the exported DialogueTree(id, ...) factory, then pass the resulting object as dialogue to each CreateNPC call instead of a callback.
Developing this library
bun run setup # install + link the library into test/
bun dev:build # build the library (outputs to lib/)
bun dev:watch # watch mode - rebuilds library and test project on changes
bun dev:test-build # dry-build the test datapack, verbose output
bun test # run snapshot tests