openvibers/openvibe.games Commits
NPCs in the map: the editor places wardens, townsfolk, hares and Rustjaw thugs, the server follows the map's spawns at boot and on live saves, and Scrap Island gets 26 in place of 7 (#115)
Changes
@@@ -27,6 +27,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Added++- **NPCs in the map, and 26 on Scrap Island.** The v2 map document has `npcs` (an archetype and a ground position),++ and the editor's Entity tool places them: City warden, Townsfolk, Dust hare and Rustjaw thug. A map with NPCs++ replaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just++ outside Scrap City's wall. The server matches the NPCs to the map's spawns at boot and on every live save: a spawn++ placed, moved or deleted in the editor shows up at once, and an NPC no spawn claims goes, saved row and all. Scrap++ Island now has:++ - a warden at each gate and one by the spawn square;++ - six townsfolk on the avenues and the ring road;++ - nine hares at the farm and in the fields;++ - six Rustjaw thugs at the far end of the scrapyard, in the deep forest and on Rust Ridge, all beyond a leash's++ reach of a gate.++++ Creator worlds still run no NPCs, and a creator map with NPC spawns says so.- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off abush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and apoint-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.@@@ -16,12 +16,14 @@ export const newId = (prefix: string): string =>export interface Placeable {name: string-- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch'++ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'shape?: StaticBody['shape']color?: stringtex?: stringdecor?: stringnode?: string++ /** NPC archetype id (kind 'npc'). */++ npc?: stringmodelId?: string}@@@ -58,6 +60,10 @@ export const ENTITY_DEFS: Placeable[] = [{ name: '🏪 Merchant stall (shop)', kind: 'prop', node: 'merchant_stall' },{ name: '📦 Wooden crate (prop)', kind: 'prop', node: 'wooden_crate' },{ name: '🛢 Metal barrel (prop)', kind: 'prop', node: 'metal_barrel' },++ { name: '🛡 City warden (NPC)', kind: 'npc', npc: 'city_warden' },++ { name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },++ { name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },++ { name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },]export const TEXTURES = [@@@ -74,6 +80,20 @@ export const TEXTURES = ['wood_planks',]++/** Stand-ins for NPC spawns: a body-sized column in the faction's colour (hostile red). */++export const NPC_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {++ city_warden: { color: '#3d6fb0', shape: { type: 'cylinder', radius: 0.4, height: 1.9 } },++ townsfolk: { color: '#c9a25a', shape: { type: 'cylinder', radius: 0.35, height: 1.75 } },++ dust_hare: { color: '#a08868', shape: { type: 'box', size: [0.35, 0.3, 0.6] } },++ rustjaw_thug: { color: '#b0413e', shape: { type: 'cylinder', radius: 0.42, height: 1.85 } },++}++const NPC_FALLBACK = {++ color: '#9a7ab0',++ shape: { type: 'cylinder', radius: 0.4, height: 1.8 },++} as const++export const npcLook = (archetype: string): { color: string; shape: StaticBody['shape'] } =>++ NPC_LOOKS[archetype] ?? NPC_FALLBACK++/** Visual stand-ins for resource nodes (matched loosely to the game's). */export const NODE_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {oak_tree: { color: '#4c7a3a', shape: { type: 'cylinder', radius: 1.3, height: 5 } },@@@ -26,6 +26,7 @@ import {type MapTextureEntry,type MapLightV2,type MapNodeV2,++ type MapNpcV2,type MapPropV2,type MapZoneV2,type StaticObjectV2,@@@ -50,6 +51,7 @@ export interface EditorObjectByKind {static: StaticObjectV2node: MapNodeV2prop: MapPropV2++ npc: MapNpcV2light: MapLightV2zone: MapZoneV2spawn: SpawnObjectV2@@@ -64,6 +66,7 @@ const ARRAY_KINDS = [['static', 'statics'],['node', 'nodes'],['prop', 'props'],++ ['npc', 'npcs'],['light', 'lights'],['zone', 'zones'],] as const satisfies readonly (readonly [EditorObjectKind, keyof MapFileV2])[]@@@ -61,6 +61,14 @@ export const KIND_INFO: Record<EditorObjectKind, KindInfo> = {rotatable: true,scalable: false,},++ npc: {++ label: 'NPC',++ group: 'Gameplay',++ icon: '🧍',++ transformable: true,++ rotatable: false,++ scalable: false,++ },light: {label: 'Light',group: 'Lights',@@@ -103,6 +111,8 @@ export function displayName(kind: EditorObjectKind, object: EditorObject): strinreturn (object as { node: string }).nodecase 'prop':return (object as { item: string }).item++ case 'npc':++ return (object as { npc: string }).npccase 'light':return `${(object as MapLightV2).type} light`case 'spawn':@@@ -194,6 +204,7 @@ export function transformOf(doc: EditorDocument, id: string): EditorTransform |)}case 'node':++ case 'npc':return transformFromEuler([...(o as { pos: [number, number, number] }).pos], [0, 0, 0])case 'prop': {const p = o as { pos: [number, number, number]; yaw?: number }@@@ -263,7 +274,8 @@ export function setTransform(doc: EditorDocument, id: string, t: EditorTransform})returncase 'node':-- // Nodes sit ON the ground; only their footprint is authored.++ case 'npc':++ // Nodes and NPC homes sit ON the ground; only their footprint is authored.doc.update(id, { pos: [pos[0], 0, pos[2]] })returncase 'prop':@@@ -12,7 +12,8 @@*//** Everything the editor can select. Face selection is separate. */--export type EditorObjectKind = 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'spawn' | 'light'++export type EditorObjectKind =++ 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'npc' | 'spawn' | 'light'export interface SelectionSnapshot {primaryId: string | null@@@ -21,7 +21,7 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'import type { Scene } from '@babylonjs/core/scene.js'import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'import { blankHeights, type MapModelV2, type StaticObjectV2 } from '@openvibe/content'--import { NODE_LOOKS, newId, type Placeable } from '../catalog.js'++import { NODE_LOOKS, newId, npcLook, type Placeable } from '../catalog.js'import { meshForShape } from '../../render/sceneSetup.js'import type { EditorObjectKind } from '../document/editorDocument.js'@@@ -49,6 +49,7 @@ export function placeableShape(): StaticObjectV2['shape'] {if (def.kind === 'static') return def.shape!if (def.kind === 'node') return NODE_LOOKS[def.node!]?.shape ?? { type: 'sphere', radius: 0.6 }++ if (def.kind === 'npc') return npcLook(def.npc!).shapeif (def.kind === 'model') {const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }@@@ -57,7 +58,7 @@ export function placeableShape(}/** Kinds that always stand upright, whatever they were dropped on. */--const UPRIGHT = new Set(['node', 'spawn', 'prop'])++const UPRIGHT = new Set(['node', 'spawn', 'prop', 'npc'])export class PlacementTool {private ghost: Mesh | null = null@@@ -99,7 +100,9 @@ export class PlacementTool {? (def.color ?? '#8a8d90'): def.kind === 'node'? (NODE_LOOKS[def.node!]?.color ?? '#888888')-- : '#e8b54a'++ : def.kind === 'npc'++ ? npcLook(def.npc!).color++ : '#e8b54a'this.ghost = meshForShape(this.ctx.scene, 'ghost', shape, color)this.ghost.visibility = 0.5this.ghost.isPickable = false@@@ -220,6 +223,12 @@ export function objectForPlacement(kind: 'prop',object: { id: newId('pr'), item: def.node!, pos: [pose.pos.x, 1, pose.pos.z], yaw: e.y },}++ case 'npc':++ // Like a node: a ground position the NPC lives and wanders around.++ return {++ kind: 'npc',++ object: { id: newId('npc'), npc: def.npc!, pos: [pose.pos.x, 0, pose.pos.z] },++ }case 'spawn':return {kind: 'spawn',@@@ -148,6 +148,8 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):return [{ key: 'node', label: 'Resource', type: 'readonly', section: 'Node' }, ...common]case 'prop':return [{ key: 'item', label: 'Item', type: 'readonly', section: 'Prop' }, ...common]++ case 'npc':++ return [{ key: 'npc', label: 'NPC', type: 'readonly', section: 'NPC' }, ...common]case 'spawn':return commoncase 'light': {@@@ -27,13 +27,14 @@ import {type ContentRegistry,type MapLightV2,type MapNodeV2,++ type MapNpcV2,type MapPropV2,type MapZoneV2,type StaticObjectV2,type SurfaceMaterialData,type TerrainObjectV2,} from '@openvibe/content'--import { NODE_LOOKS } from '../../catalog.js'++import { NODE_LOOKS, npcLook } from '../../catalog.js'import { meshForShape } from '../../../render/sceneSetup.js'import { applyStaticStyle, instantiateMapLight } from '../../../render/mapStyle.js'import { LayeredSurfaceMaterial } from '../../../render/layeredSurface.js'@@@ -279,6 +280,53 @@ class NodeView implements EditorView {}}++/** An NPC's home: a body-sized stand-in on the ground (the game's NPC wanders from here). */++class NpcView implements EditorView {++ readonly kind = 'npc' as const++ readonly root: Mesh++++ constructor(++ readonly id: string,++ private npc: MapNpcV2,++ private readonly ctx: ViewContext,++ ) {++ const look = npcLook(npc.npc)++ this.root = meshForShape(ctx.scene, `npc:${id}`, look.shape, look.color)++ this.root.metadata = { height: shapeHeight(look.shape) }++ this.applyPose()++ }++++ private applyPose(): void {++ const n = this.npc++ const h = (this.root.metadata as { height: number }).height++ this.root.position.set(++ n.pos[0],++ n.pos[1] + this.ctx.sampleGround(n.pos[0], n.pos[2]) + h / 2,++ n.pos[2],++ )++ }++++ meshes(): Mesh[] {++ return [this.root]++ }++++ update(object: EditorObject, keys: readonly string[]): boolean {++ this.npc = object as MapNpcV2++ // The look follows the archetype, so changing it needs a new mesh.++ if (keys.includes('npc')) return false++ this.applyPose()++ return true++ }++++ setVisible(on: boolean): void {++ this.root.setEnabled(on)++ }++++ dispose(): void {++ this.root.dispose(false, true)++ }++}++class PropView implements EditorView {readonly kind = 'prop' as constreadonly root: Mesh@@@ -674,6 +722,8 @@ export function createViewFactory(ctx: ViewContext) {return new NodeView(id, object as MapNodeV2, ctx)case 'prop':return new PropView(id, object as MapPropV2, ctx)++ case 'npc':++ return new NpcView(id, object as MapNpcV2, ctx)case 'light':return new LightView(id, object as MapLightV2, ctx)case 'zone':@@@ -4,7 +4,7 @@* disconnect save lands after an earlier checkpoint of the same character; shutdown drains.*/import { describe, expect, it } from 'vitest'--import { createContent } from '@openvibe/content'++import { NPC_SPAWNS, createContent, setMapOverride } from '@openvibe/content'import { openTestStore } from '@openvibe/persistence/testing'import type {ConstraintDto,@@@ -415,6 +415,57 @@ describe('write-behind flusher', () => {npcs.restoreDirty(changed.map((d) => d.id))expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])})++++ it("(i) NPCs follow the map's own spawns: the content's go, saved rows and all, and a restart keeps the map's", async () => {++ const store = await openTestStore()++ const content = createContent()++ const forgotten: string[] = []++ const world = {++ content,++ forgetPersisted: (id: string) => forgotten.push(id),++ } as unknown as GameWorld++ const hooks = { onAttackPlayer: () => {}, soundsThisTick: () => [], hostilesNear: () => [] }++ const first = new NpcManager(world, new RegionTracker(32), hooks, log)++ await first.seedOrRestore(store)++ const contentRows = first.takeDirty(1000)++ expect(contentRows.length).toBe(NPC_SPAWNS.length)++ await store.worldEntities.upsertMany(contentRows)++++ setMapOverride({++ terrains: [],++ npcs: [++ { id: 'w', npc: 'city_warden', pos: [3, 0, -13] },++ { id: 'h', npc: 'dust_hare', pos: [60, 0, 50] },++ { id: 'x', npc: 'no_such_npc', pos: [0, 0, 0] },++ ],++ })++ try {++ first.reconcileSpawns()++ expect(forgotten.sort()).toEqual(contentRows.map((r) => r.id).sort())++ const mapRows = first.takeDirty(1000)++ expect(mapRows.map((r) => [r.defId, r.pos[0], r.pos[2]])).toEqual([++ ['city_warden', 3, -13],++ ['dust_hare', 60, 50],++ ])++ expect(first.counts()).toEqual([0, 2])++ // Reconciling again changes nothing.++ first.reconcileSpawns()++ expect(first.takeDirty(1000)).toEqual([])++ await store.worldEntities.deleteMany(forgotten)++ await store.worldEntities.upsertMany(mapRows)++++ const restarted = new NpcManager(world, new RegionTracker(32), hooks, log)++ await restarted.seedOrRestore(store)++ expect(++ restarted++ .takeDirty(1000)++ .map((r) => r.id)++ .sort(),++ ).toEqual(mapRows.map((r) => r.id).sort())++ } finally {++ setMapOverride(null)++ }++ })})describe('GameWorld dirty snapshot', () => {@@@ -220,6 +220,11 @@ export class GameServer {this.systems.editor.broadcastMapReload()}++ /** A live map save: NPCs follow the map's spawns (not in a sandbox, which runs none). */++ reconcileMapNpcs(): void {++ if (!this.ctx.config.sandbox) this.systems.npcs.reconcileSpawns()++ }++announce(text: string): void {this.ctx.net.broadcastAll({ t: 'announce', text })}@@@ -332,6 +332,11 @@ export class GameWorld {return entity}++ /** Delete a saved row at the next flush: an NPC dropped while abstract has no entity to despawn. */++ forgetPersisted(id: EntityId): void {++ this.deletedIds.add(id)++ }++despawn(id: EntityId): void {const entity = this.entities.remove(id)if (!entity) return@@@ -7,7 +7,7 @@ import {type NpcState,type PerceptionCandidate,} from '@openvibe/gameplay'--import { NPC_SPAWNS, terrainHeight } from '@openvibe/content'++import { npcSpawnsFor, terrainHeight } from '@openvibe/content'import { CollisionLayer, type BodyId } from '@openvibe/physics'import type { PersistenceStore, WorldEntityDto } from '@openvibe/persistence'import {@@@ -63,7 +63,7 @@ export class NpcManager {private readonly log: Logger,) {}-- /** Boot: restore persisted NPCs, then seed content spawns not yet known. */++ /** Boot: restore persisted NPCs, then match them to the world's spawns. */async seedOrRestore(store: PersistenceStore): Promise<void> {const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')for (const row of rows) {@@@ -86,16 +86,44 @@ export class NpcManager {respawnAt: typeof s.respawnAt === 'number' ? s.respawnAt : 0,})}-- // Content spawns missing from the save (fresh world or new content).-- for (const [index, spawn] of NPC_SPAWNS.entries()) {-- const arch = this.world.content.npc(spawn.archetype)-- if (!arch) continue-- const known = [...this.states.values()].some(++ this.reconcileSpawns()++ this.log.info('npcs ready', { count: this.states.size })++ }++++ /**++ * Make the NPCs match the world's spawns (npcSpawnsFor: the map's own, or++ * the content's): an NPC whose home no spawn claims goes, saved row and all,++ * and a spawn with nobody home gets its NPC. Boot runs it, and so does a live++ * map save, so an NPC placed, moved or deleted in the editor shows up at once.++ * An NPC is its spawn's while it has the archetype and a home within 2 m.++ */++ reconcileSpawns(): void {++ const spawns = npcSpawnsFor(this.world.content.world).filter((s) =>++ this.world.content.npc(s.archetype),++ )++ const claimed = new Set<string>()++ const homeOf = (spawn: (typeof spawns)[number]): NpcState | undefined =>++ [...this.states.values()].find((s) =>++ !claimed.has(s.id) &&s.archetype === spawn.archetype &&Math.hypot(s.homeX - spawn.pos[0], s.homeZ - spawn.pos[2]) < 2,)-- if (known) continue++ const missing: [number, (typeof spawns)[number]][] = []++ for (const [index, spawn] of spawns.entries()) {++ const npc = homeOf(spawn)++ if (npc) claimed.add(npc.id)++ else missing.push([index, spawn])++ }++ for (const npc of [...this.states.values()]) {++ if (claimed.has(npc.id)) continue++ if (this.bodies.has(npc.id)) this.dematerialize(npc)++ this.states.delete(npc.id)++ this.dirtyIds.delete(npc.id)++ this.world.forgetPersisted(asEntityId(npc.id))++ }++ for (const [index, spawn] of missing) {++ const arch = this.world.content.npc(spawn.archetype)!const id = newEntityId()this.states.set(id, {id,@@@ -113,7 +141,6 @@ export class NpcManager {})this.dirtyIds.add(id)}-- this.log.info('npcs ready', { count: this.states.size })}/** Counts for metrics: [full-sim, abstract]. */@@@ -66,6 +66,11 @@ export class NpcSystem implements System {await this.npcs.seedOrRestore(this.ctx.store)}++ /** A live map save: NPCs follow the map's spawns. */++ reconcileSpawns(): void {++ this.npcs.reconcileSpawns()++ }++/** One NPC simulation step; the sound buffer is cleared once a second. */tick(ctx: ServerContext, _dt: number): void {this.npcs.step(Date.now(), ctx.clock.tick, ctx.config.tickRate)@@@ -841,6 +841,7 @@ async function main(): Promise<void> {room.world.reconcileMapZones()room.world.reconcileMapNodes()room.world.reconcileMapProps()++ room.game.reconcileMapNpcs()room.game.broadcastMapReload()}editors.broadcastSaved(revision)@@@ -1,7 +1,7 @@import { describe, expect, it } from 'vitest'import {ITEMS,-- NPC_SPAWNS,++ NPC_ARCHETYPES,RESOURCE_NODES,WATER_LEVEL,decodeHeights,@@@ -23,7 +23,11 @@ describe('Scrap Island', () => {const items = new Set(ITEMS.map((i) => i.id))expect(map.nodes.filter((n) => !nodeKinds.has(n.node))).toEqual([])expect(map.props.filter((p) => !items.has(p.item))).toEqual([])-- const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights].map((o) => o.id)++ const archetypes = new Set(NPC_ARCHETYPES.map((a) => a.id))++ expect(map.npcs.filter((n) => !archetypes.has(n.npc))).toEqual([])++ const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights, ...map.npcs].map(++ (o) => o.id,++ )expect(new Set(ids).size).toBe(ids.length)})@@@ -101,12 +105,20 @@ describe('Scrap Island', () => {expect(inside).toEqual([])})-- it("keeps the content's NPC homes on land and outside the walls, except the town's own", () => {++ it('has people and animals of its own: the town inside the walls, the rest out on land, Rustjaw far from the gates', () => {const height = makeHeight(20261019)-- for (const s of NPC_SPAWNS) {-- expect(height(s.pos[0], s.pos[2])).toBeGreaterThan(WATER_LEVEL + 0.5)-- const outside = Math.max(Math.abs(s.pos[0]), Math.abs(s.pos[2])) > ISLAND.wall-- expect(outside).toBe(s.archetype !== 'city_warden' && s.archetype !== 'townsfolk')++ const count = (a: string) => map.npcs.filter((n) => n.npc === a).length++ expect(count('city_warden')).toBeGreaterThanOrEqual(4)++ expect(count('townsfolk')).toBeGreaterThanOrEqual(4)++ expect(count('dust_hare')).toBeGreaterThanOrEqual(6)++ expect(count('rustjaw_thug')).toBeGreaterThanOrEqual(4)++ for (const n of map.npcs) {++ const [x, , z] = n.pos++ expect(height(x, z)).toBeGreaterThan(WATER_LEVEL + 0.5)++ const inside = Math.max(Math.abs(x), Math.abs(z)) < ISLAND.wall++ expect(inside).toBe(n.npc === 'city_warden' || n.npc === 'townsfolk')++ // A hostile thug's leash (35 m) never reaches a city gate.++ if (n.npc === 'rustjaw_thug') expect(Math.hypot(x, z)).toBeGreaterThan(ISLAND.wall + 50)}})})@@@ -943,6 +943,57 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {prop(pick(['wooden_crate', 'supply_crate', 'storage_box']), cx, cz, yaw)}++ // Who lives here. Wardens keep the gates and the square by the spawn; townsfolk walk the avenues and the ring road++ // (lots and the plaza's stalls stay clear); hares graze the farm and the fields; Rustjaw thugs hold the far end of the++ // scrapyard, the deep forest and Rust Ridge, all beyond a leash's reach of a gate.++ const npcs: MapFileV2['npcs'] = []++ const npc = (id: string, archetype: string, x: number, z: number): void => {++ npcs.push({ id, npc: archetype, pos: [x, 0, z] })++ }++ npc('warden-north', 'city_warden', 2.5, 32)++ npc('warden-south', 'city_warden', -2.5, -32)++ npc('warden-east', 'city_warden', 32, -2.5)++ npc('warden-west', 'city_warden', -32, 2.5)++ npc('warden-square', 'city_warden', 7.5, -19)++ for (const [i, [x, z]] of (++ [++ [2.5, 18],++ [-2.5, -19],++ [18.5, -2.5],++ [-18.5, 2.5],++ [26.5, 12],++ [-26.5, -14],++ ] as const++ ).entries())++ npc(`townsfolk-${i + 1}`, 'townsfolk', x, z)++ const [fx, fz] = ISLAND.farm.center++ for (const [i, [x, z]] of (++ [++ [fx - 8, fz + 8],++ [fx + 8, fz - 8],++ [fx + 6, fz + 10],++ [fx - 6, fz - 10],++ [60, 50],++ [70, 40],++ [-30, 80],++ [30, 85],++ [-50, -30],++ ] as const++ ).entries())++ npc(`hare-${i + 1}`, 'dust_hare', x, z)++ const [rx, rz] = ISLAND.ridge.center++ for (const [i, [x, z]] of (++ [++ [ISLAND.scrapyard.max[0] - 8, 14],++ [ISLAND.scrapyard.max[0] - 6, -16],++ [-140, 34],++ [-128, -6],++ [rx - 12, rz + 6],++ [rx + 16, rz - 4],++ ] as const++ ).entries())++ npc(`rustjaw-${i + 1}`, 'rustjaw_thug', x, z)++const terrain: MapFileV2['terrains'][number] = {id: 'island',name: 'Scrap Island',@@@ -970,6 +1021,7 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {statics,nodes,props,++ npcs,lights,zones: [{@@@ -1,4 +1,6 @@import type { FactionDef, NpcArchetype } from '../schema/npc.js'++import type { WorldDef } from '../schema/world.js'++import { getMapOverride } from '../terrain.js'/*** NPC content: factions, archetypes, and world spawn points. Behavior is@@@ -108,8 +110,25 @@ export const NPC_ARCHETYPES: NpcArchetype[] = [},]++export interface NpcSpawn {++ archetype: string++ pos: [number, number, number]++}++++/**++ * Where a world's NPCs live: the map's own NPC spawns when it places any (the++ * editor owns them), else NPC_SPAWNS. Those were laid out for the procedural++ * world, so on a map of its own they stood in odd places — Rustjaw thugs just++ * outside Scrap City's wall, seven NPCs on a whole island.++ */++export function npcSpawnsFor(world?: WorldDef): NpcSpawn[] {++ const mapped = getMapOverride(world)?.npcs ?? []++ if (mapped.length) return mapped.map((n) => ({ archetype: n.npc, pos: n.pos }))++ return NPC_SPAWNS++}++/** World NPC spawn points (positions are terrain-relative, like props). */--export const NPC_SPAWNS: { archetype: string; pos: [number, number, number] }[] = [++export const NPC_SPAWNS: NpcSpawn[] = [// Rustjaw ambushers haunt the scrapyard and the deep forest.{ archetype: 'rustjaw_thug', pos: [43, 0, -40] },{ archetype: 'rustjaw_thug', pos: [40, 0, 48] },@@@ -10,6 +10,8 @@ import {type MapFileV2,type ParsedMap,} from './mapFileV2.js'++import { NPC_SPAWNS, npcSpawnsFor } from './defs/npcs.js'++import { setMapOverride } from './terrain.js'const SUB = 8@@@ -444,3 +446,39 @@ describe('map-authored zones', () => {if (again.ok) expect(again.map.zones[0]).toEqual(r.map.zones[0])})})++++describe('map-authored NPCs', () => {++ it('an older map with no npcs reads as none, and a new one starts with none', () => {++ const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>++ delete raw.npcs++ const parsed = parseMapFile(raw)++ expect(parsed.ok && parsed.map.npcs).toEqual([])++ expect(emptyMapV2().npcs).toEqual([])++ })++++ it('compiles NPC spawns into the override, and npcSpawnsFor prefers them to the content', () => {++ const map: MapFileV2 = {++ ...emptyMapV2(),++ npcs: [{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }],++ }++ expect(validateMapFile(map)).toEqual([])++ const override = compileMapFileV2(map)++ expect(override.npcs).toEqual([{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }])++ setMapOverride(override)++ try {++ expect(npcSpawnsFor()).toEqual([{ archetype: 'dust_hare', pos: [12, 0, -4] }])++ } finally {++ setMapOverride(null)++ }++ expect(npcSpawnsFor()).toBe(NPC_SPAWNS)++ })++++ it('an NPC id may not repeat another object id', () => {++ const map: MapFileV2 = {++ ...emptyMapV2(),++ nodes: [{ id: 'x', node: 'oak_tree', pos: [0, 0, 0] }],++ npcs: [{ id: 'x', npc: 'townsfolk', pos: [1, 0, 1] }],++ }++ expect(validateMapFile(map).some((i) => i.includes('"x"'))).toBe(true)++ })++})@@@ -159,6 +159,11 @@ export const MapFileV2Schema = z.object({).max(20_000).default([]),++ /** NPC spawn points: an archetype id (`npc`) and the ground position it lives around. */++ npcs: z++ .array(z.object({ id: documentId, npc: z.string(), pos: finiteVec3 }))++ .max(2000)++ .default([]),lights: z.array(MapLightSchemaV2).max(256).default([]),/** Map-authored zones; they augment the base world's content zones. */zones: z.array(MapZoneSchemaV2).max(512).default([]),@@@ -188,6 +193,7 @@ export type MapLightV2 = z.infer<typeof MapLightSchemaV2>export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>export type MapNodeV2 = MapFileV2['nodes'][number]export type MapPropV2 = MapFileV2['props'][number]++export type MapNpcV2 = MapFileV2['npcs'][number]export type MapModelV2 = MapFileV2['models'][number]/** The synthetic document id for the spawn point (stored top-level on the wire). */@@@ -201,6 +207,7 @@ export function emptyMapV2(): MapFileV2 {statics: [],nodes: [],props: [],++ npcs: [],lights: [],zones: [],models: [],@@@ -216,6 +223,7 @@ const ID_KINDS = [['props', 'pr'],['lights', 'light'],['zones', 'zone'],++ ['npcs', 'npc'],] as const/**@@@ -344,6 +352,7 @@ export function validateMapFile(map: MapFileV2): string[] {}for (const n of map.nodes) claim(n.id, 'node')for (const p of map.props) claim(p.id, 'prop')++ for (const n of map.npcs) claim(n.id, 'npc')for (const l of map.lights) claim(l.id, 'light')for (const z0 of map.zones) claim(z0.id, 'zone')if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)@@@ -409,6 +418,7 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {...(t.surface?.base.uv ? { uv: t.surface.base.uv as FaceStyle } : {}),})),nodes: map.nodes.map((n) => ({ id: n.id, node: n.node, pos: n.pos })),++ npcs: map.npcs.map((n) => ({ id: n.id, npc: n.npc, pos: n.pos })),props: map.props.map((p) => ({id: p.id,item: p.item,@@@ -71,6 +71,7 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {limit('props', map.props.length)limit('lights', map.lights.length)limit('zones', map.zones.length)++ if (map.npcs.length > 0) issues.push('npcs: creator worlds have no NPCs yet')if (map.models.length > 0)issues.push('models: uploaded models are not available in creator worlds yet')if (map.textures.length > 0)@@@ -121,6 +121,13 @@ export interface MapPropSpawn {yaw?: number}++/** An editor-placed NPC: where an archetype (`npc`) lives and wanders from. */++export interface MapNpcSpawn {++ id: string++ npc: string++ pos: [number, number, number]++}++export interface TerrainPatchData {id: stringorigin: [number, number, number]@@@ -168,6 +175,11 @@ export interface MapOverride {nodes?: MapNodeSpawn[]/** Editor-placed initial props merged into fresh-world seeding. */props?: MapPropSpawn[]++ /**++ * Editor-placed NPCs. A map with any replaces the content's NPC_SPAWNS,++ * which were laid out for the procedural world (see npcSpawnsFor).++ */++ npcs?: MapNpcSpawn[]/** Every terrain object in the map. May be empty. */terrains: TerrainPatchData[]/**
@@ -27,6 +27,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -27,6 +27,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### Added
+- **NPCs in the map, and 26 on Scrap Island.** The v2 map document has `npcs` (an archetype and a ground position),
+ and the editor's Entity tool places them: City warden, Townsfolk, Dust hare and Rustjaw thug. A map with NPCs
+ replaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just
+ outside Scrap City's wall. The server matches the NPCs to the map's spawns at boot and on every live save: a spawn
+ placed, moved or deleted in the editor shows up at once, and an NPC no spawn claims goes, saved row and all. Scrap
+ Island now has:
+ - a warden at each gate and one by the spawn square;
+ - six townsfolk on the avenues and the ring road;
+ - nine hares at the farm and in the fields;
+ - six Rustjaw thugs at the far end of the scrapyard, in the deep forest and on Rust Ridge, all beyond a leash's
+ reach of a gate.
+
+ Creator worlds still run no NPCs, and a creator map with NPC spawns says so.
- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off a
- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off a
bush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and a
bush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and a
point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.
point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.
@@ -16,12 +16,14 @@ export const newId = (prefix: string): string =>
@@ -16,12 +16,14 @@ export const newId = (prefix: string): string =>
export interface Placeable {
export interface Placeable {
name: string
name: string
- kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch'
+ kind: 'static' | 'node' | 'spawn' | 'model' | 'prop' | 'patch' | 'npc'
shape?: StaticBody['shape']
shape?: StaticBody['shape']
color?: string
color?: string
tex?: string
tex?: string
decor?: string
decor?: string
node?: string
node?: string
+ /** NPC archetype id (kind 'npc'). */
+ npc?: string
modelId?: string
modelId?: string
}
}
@@ -58,6 +60,10 @@ export const ENTITY_DEFS: Placeable[] = [
@@ -58,6 +60,10 @@ export const ENTITY_DEFS: Placeable[] = [
{ name: '🏪 Merchant stall (shop)', kind: 'prop', node: 'merchant_stall' },
{ name: '🏪 Merchant stall (shop)', kind: 'prop', node: 'merchant_stall' },
{ name: '📦 Wooden crate (prop)', kind: 'prop', node: 'wooden_crate' },
{ name: '📦 Wooden crate (prop)', kind: 'prop', node: 'wooden_crate' },
{ name: '🛢 Metal barrel (prop)', kind: 'prop', node: 'metal_barrel' },
{ name: '🛢 Metal barrel (prop)', kind: 'prop', node: 'metal_barrel' },
+ { name: '🛡 City warden (NPC)', kind: 'npc', npc: 'city_warden' },
+ { name: '🧑 Townsfolk (NPC)', kind: 'npc', npc: 'townsfolk' },
+ { name: '🐇 Dust hare (NPC)', kind: 'npc', npc: 'dust_hare' },
+ { name: '☠ Rustjaw thug (NPC, hostile)', kind: 'npc', npc: 'rustjaw_thug' },
]
]
export const TEXTURES = [
export const TEXTURES = [
@@ -74,6 +80,20 @@ export const TEXTURES = [
@@ -74,6 +80,20 @@ export const TEXTURES = [
'wood_planks',
'wood_planks',
]
]
+/** Stand-ins for NPC spawns: a body-sized column in the faction's colour (hostile red). */
+export const NPC_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {
+ city_warden: { color: '#3d6fb0', shape: { type: 'cylinder', radius: 0.4, height: 1.9 } },
+ townsfolk: { color: '#c9a25a', shape: { type: 'cylinder', radius: 0.35, height: 1.75 } },
+ dust_hare: { color: '#a08868', shape: { type: 'box', size: [0.35, 0.3, 0.6] } },
+ rustjaw_thug: { color: '#b0413e', shape: { type: 'cylinder', radius: 0.42, height: 1.85 } },
+}
+const NPC_FALLBACK = {
+ color: '#9a7ab0',
+ shape: { type: 'cylinder', radius: 0.4, height: 1.8 },
+} as const
+export const npcLook = (archetype: string): { color: string; shape: StaticBody['shape'] } =>
+ NPC_LOOKS[archetype] ?? NPC_FALLBACK
+
/** Visual stand-ins for resource nodes (matched loosely to the game's). */
/** Visual stand-ins for resource nodes (matched loosely to the game's). */
export const NODE_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {
export const NODE_LOOKS: Record<string, { color: string; shape: StaticBody['shape'] }> = {
oak_tree: { color: '#4c7a3a', shape: { type: 'cylinder', radius: 1.3, height: 5 } },
oak_tree: { color: '#4c7a3a', shape: { type: 'cylinder', radius: 1.3, height: 5 } },
@@ -26,6 +26,7 @@ import {
@@ -26,6 +26,7 @@ import {
type MapTextureEntry,
type MapTextureEntry,
type MapLightV2,
type MapLightV2,
type MapNodeV2,
type MapNodeV2,
+ type MapNpcV2,
type MapPropV2,
type MapPropV2,
type MapZoneV2,
type MapZoneV2,
type StaticObjectV2,
type StaticObjectV2,
@@ -50,6 +51,7 @@ export interface EditorObjectByKind {
@@ -50,6 +51,7 @@ export interface EditorObjectByKind {
static: StaticObjectV2
static: StaticObjectV2
node: MapNodeV2
node: MapNodeV2
prop: MapPropV2
prop: MapPropV2
+ npc: MapNpcV2
light: MapLightV2
light: MapLightV2
zone: MapZoneV2
zone: MapZoneV2
spawn: SpawnObjectV2
spawn: SpawnObjectV2
@@ -64,6 +66,7 @@ const ARRAY_KINDS = [
@@ -64,6 +66,7 @@ const ARRAY_KINDS = [
['static', 'statics'],
['static', 'statics'],
['node', 'nodes'],
['node', 'nodes'],
['prop', 'props'],
['prop', 'props'],
+ ['npc', 'npcs'],
['light', 'lights'],
['light', 'lights'],
['zone', 'zones'],
['zone', 'zones'],
] as const satisfies readonly (readonly [EditorObjectKind, keyof MapFileV2])[]
] as const satisfies readonly (readonly [EditorObjectKind, keyof MapFileV2])[]
@@ -61,6 +61,14 @@ export const KIND_INFO: Record<EditorObjectKind, KindInfo> = {
@@ -61,6 +61,14 @@ export const KIND_INFO: Record<EditorObjectKind, KindInfo> = {
rotatable: true,
rotatable: true,
scalable: false,
scalable: false,
},
},
+ npc: {
+ label: 'NPC',
+ group: 'Gameplay',
+ icon: '🧍',
+ transformable: true,
+ rotatable: false,
+ scalable: false,
+ },
light: {
light: {
label: 'Light',
label: 'Light',
group: 'Lights',
group: 'Lights',
@@ -103,6 +111,8 @@ export function displayName(kind: EditorObjectKind, object: EditorObject): strin
@@ -103,6 +111,8 @@ export function displayName(kind: EditorObjectKind, object: EditorObject): strin
return (object as { node: string }).node
return (object as { node: string }).node
case 'prop':
case 'prop':
return (object as { item: string }).item
return (object as { item: string }).item
+ case 'npc':
+ return (object as { npc: string }).npc
case 'light':
case 'light':
return `${(object as MapLightV2).type} light`
return `${(object as MapLightV2).type} light`
case 'spawn':
case 'spawn':
@@ -194,6 +204,7 @@ export function transformOf(doc: EditorDocument, id: string): EditorTransform |
@@ -194,6 +204,7 @@ export function transformOf(doc: EditorDocument, id: string): EditorTransform |
)
)
}
}
case 'node':
case 'node':
+ case 'npc':
return transformFromEuler([...(o as { pos: [number, number, number] }).pos], [0, 0, 0])
return transformFromEuler([...(o as { pos: [number, number, number] }).pos], [0, 0, 0])
case 'prop': {
case 'prop': {
const p = o as { pos: [number, number, number]; yaw?: number }
const p = o as { pos: [number, number, number]; yaw?: number }
@@ -263,7 +274,8 @@ export function setTransform(doc: EditorDocument, id: string, t: EditorTransform
@@ -263,7 +274,8 @@ export function setTransform(doc: EditorDocument, id: string, t: EditorTransform
})
})
return
return
case 'node':
case 'node':
- // Nodes sit ON the ground; only their footprint is authored.
+ case 'npc':
+ // Nodes and NPC homes sit ON the ground; only their footprint is authored.
doc.update(id, { pos: [pos[0], 0, pos[2]] })
doc.update(id, { pos: [pos[0], 0, pos[2]] })
return
return
case 'prop':
case 'prop':
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
*/
*/
/** Everything the editor can select. Face selection is separate. */
/** Everything the editor can select. Face selection is separate. */
-export type EditorObjectKind = 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'spawn' | 'light'
+export type EditorObjectKind =
+ 'static' | 'model' | 'terrain' | 'node' | 'prop' | 'npc' | 'spawn' | 'light'
export interface SelectionSnapshot {
export interface SelectionSnapshot {
primaryId: string | null
primaryId: string | null
@@ -21,7 +21,7 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
@@ -21,7 +21,7 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
import { blankHeights, type MapModelV2, type StaticObjectV2 } from '@openvibe/content'
import { blankHeights, type MapModelV2, type StaticObjectV2 } from '@openvibe/content'
-import { NODE_LOOKS, newId, type Placeable } from '../catalog.js'
+import { NODE_LOOKS, newId, npcLook, type Placeable } from '../catalog.js'
import { meshForShape } from '../../render/sceneSetup.js'
import { meshForShape } from '../../render/sceneSetup.js'
import type { EditorObjectKind } from '../document/editorDocument.js'
import type { EditorObjectKind } from '../document/editorDocument.js'
@@ -49,6 +49,7 @@ export function placeableShape(
@@ -49,6 +49,7 @@ export function placeableShape(
): StaticObjectV2['shape'] {
): StaticObjectV2['shape'] {
if (def.kind === 'static') return def.shape!
if (def.kind === 'static') return def.shape!
if (def.kind === 'node') return NODE_LOOKS[def.node!]?.shape ?? { type: 'sphere', radius: 0.6 }
if (def.kind === 'node') return NODE_LOOKS[def.node!]?.shape ?? { type: 'sphere', radius: 0.6 }
+ if (def.kind === 'npc') return npcLook(def.npc!).shape
if (def.kind === 'model') {
if (def.kind === 'model') {
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
const b = models.find((m) => m.id === def.modelId)?.bounds ?? [1, 1, 1]
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
return { type: 'box', size: [b[0]!, b[1]!, b[2]!] }
@@ -57,7 +58,7 @@ export function placeableShape(
@@ -57,7 +58,7 @@ export function placeableShape(
}
}
/** Kinds that always stand upright, whatever they were dropped on. */
/** Kinds that always stand upright, whatever they were dropped on. */
-const UPRIGHT = new Set(['node', 'spawn', 'prop'])
+const UPRIGHT = new Set(['node', 'spawn', 'prop', 'npc'])
export class PlacementTool {
export class PlacementTool {
private ghost: Mesh | null = null
private ghost: Mesh | null = null
@@ -99,7 +100,9 @@ export class PlacementTool {
@@ -99,7 +100,9 @@ export class PlacementTool {
? (def.color ?? '#8a8d90')
? (def.color ?? '#8a8d90')
: def.kind === 'node'
: def.kind === 'node'
? (NODE_LOOKS[def.node!]?.color ?? '#888888')
? (NODE_LOOKS[def.node!]?.color ?? '#888888')
- : '#e8b54a'
+ : def.kind === 'npc'
+ ? npcLook(def.npc!).color
+ : '#e8b54a'
this.ghost = meshForShape(this.ctx.scene, 'ghost', shape, color)
this.ghost = meshForShape(this.ctx.scene, 'ghost', shape, color)
this.ghost.visibility = 0.5
this.ghost.visibility = 0.5
this.ghost.isPickable = false
this.ghost.isPickable = false
@@ -220,6 +223,12 @@ export function objectForPlacement(
@@ -220,6 +223,12 @@ export function objectForPlacement(
kind: 'prop',
kind: 'prop',
object: { id: newId('pr'), item: def.node!, pos: [pose.pos.x, 1, pose.pos.z], yaw: e.y },
object: { id: newId('pr'), item: def.node!, pos: [pose.pos.x, 1, pose.pos.z], yaw: e.y },
}
}
+ case 'npc':
+ // Like a node: a ground position the NPC lives and wanders around.
+ return {
+ kind: 'npc',
+ object: { id: newId('npc'), npc: def.npc!, pos: [pose.pos.x, 0, pose.pos.z] },
+ }
case 'spawn':
case 'spawn':
return {
return {
kind: 'spawn',
kind: 'spawn',
@@ -148,6 +148,8 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
@@ -148,6 +148,8 @@ export function fieldsFor(kind: EditorObjectKind, object: EditorObject | null):
return [{ key: 'node', label: 'Resource', type: 'readonly', section: 'Node' }, ...common]
return [{ key: 'node', label: 'Resource', type: 'readonly', section: 'Node' }, ...common]
case 'prop':
case 'prop':
return [{ key: 'item', label: 'Item', type: 'readonly', section: 'Prop' }, ...common]
return [{ key: 'item', label: 'Item', type: 'readonly', section: 'Prop' }, ...common]
+ case 'npc':
+ return [{ key: 'npc', label: 'NPC', type: 'readonly', section: 'NPC' }, ...common]
case 'spawn':
case 'spawn':
return common
return common
case 'light': {
case 'light': {
@@ -27,13 +27,14 @@ import {
@@ -27,13 +27,14 @@ import {
type ContentRegistry,
type ContentRegistry,
type MapLightV2,
type MapLightV2,
type MapNodeV2,
type MapNodeV2,
+ type MapNpcV2,
type MapPropV2,
type MapPropV2,
type MapZoneV2,
type MapZoneV2,
type StaticObjectV2,
type StaticObjectV2,
type SurfaceMaterialData,
type SurfaceMaterialData,
type TerrainObjectV2,
type TerrainObjectV2,
} from '@openvibe/content'
} from '@openvibe/content'
-import { NODE_LOOKS } from '../../catalog.js'
+import { NODE_LOOKS, npcLook } from '../../catalog.js'
import { meshForShape } from '../../../render/sceneSetup.js'
import { meshForShape } from '../../../render/sceneSetup.js'
import { applyStaticStyle, instantiateMapLight } from '../../../render/mapStyle.js'
import { applyStaticStyle, instantiateMapLight } from '../../../render/mapStyle.js'
import { LayeredSurfaceMaterial } from '../../../render/layeredSurface.js'
import { LayeredSurfaceMaterial } from '../../../render/layeredSurface.js'
@@ -279,6 +280,53 @@ class NodeView implements EditorView {
@@ -279,6 +280,53 @@ class NodeView implements EditorView {
}
}
}
}
+/** An NPC's home: a body-sized stand-in on the ground (the game's NPC wanders from here). */
+class NpcView implements EditorView {
+ readonly kind = 'npc' as const
+ readonly root: Mesh
+
+ constructor(
+ readonly id: string,
+ private npc: MapNpcV2,
+ private readonly ctx: ViewContext,
+ ) {
+ const look = npcLook(npc.npc)
+ this.root = meshForShape(ctx.scene, `npc:${id}`, look.shape, look.color)
+ this.root.metadata = { height: shapeHeight(look.shape) }
+ this.applyPose()
+ }
+
+ private applyPose(): void {
+ const n = this.npc
+ const h = (this.root.metadata as { height: number }).height
+ this.root.position.set(
+ n.pos[0],
+ n.pos[1] + this.ctx.sampleGround(n.pos[0], n.pos[2]) + h / 2,
+ n.pos[2],
+ )
+ }
+
+ meshes(): Mesh[] {
+ return [this.root]
+ }
+
+ update(object: EditorObject, keys: readonly string[]): boolean {
+ this.npc = object as MapNpcV2
+ // The look follows the archetype, so changing it needs a new mesh.
+ if (keys.includes('npc')) return false
+ this.applyPose()
+ return true
+ }
+
+ setVisible(on: boolean): void {
+ this.root.setEnabled(on)
+ }
+
+ dispose(): void {
+ this.root.dispose(false, true)
+ }
+}
+
class PropView implements EditorView {
class PropView implements EditorView {
readonly kind = 'prop' as const
readonly kind = 'prop' as const
readonly root: Mesh
readonly root: Mesh
@@ -674,6 +722,8 @@ export function createViewFactory(ctx: ViewContext) {
@@ -674,6 +722,8 @@ export function createViewFactory(ctx: ViewContext) {
return new NodeView(id, object as MapNodeV2, ctx)
return new NodeView(id, object as MapNodeV2, ctx)
case 'prop':
case 'prop':
return new PropView(id, object as MapPropV2, ctx)
return new PropView(id, object as MapPropV2, ctx)
+ case 'npc':
+ return new NpcView(id, object as MapNpcV2, ctx)
case 'light':
case 'light':
return new LightView(id, object as MapLightV2, ctx)
return new LightView(id, object as MapLightV2, ctx)
case 'zone':
case 'zone':
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* disconnect save lands after an earlier checkpoint of the same character; shutdown drains.
* disconnect save lands after an earlier checkpoint of the same character; shutdown drains.
*/
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
-import { createContent } from '@openvibe/content'
+import { NPC_SPAWNS, createContent, setMapOverride } from '@openvibe/content'
import { openTestStore } from '@openvibe/persistence/testing'
import { openTestStore } from '@openvibe/persistence/testing'
import type {
import type {
ConstraintDto,
ConstraintDto,
@@ -415,6 +415,57 @@ describe('write-behind flusher', () => {
@@ -415,6 +415,57 @@ describe('write-behind flusher', () => {
npcs.restoreDirty(changed.map((d) => d.id))
npcs.restoreDirty(changed.map((d) => d.id))
expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])
expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])
})
})
+
+ it("(i) NPCs follow the map's own spawns: the content's go, saved rows and all, and a restart keeps the map's", async () => {
+ const store = await openTestStore()
+ const content = createContent()
+ const forgotten: string[] = []
+ const world = {
+ content,
+ forgetPersisted: (id: string) => forgotten.push(id),
+ } as unknown as GameWorld
+ const hooks = { onAttackPlayer: () => {}, soundsThisTick: () => [], hostilesNear: () => [] }
+ const first = new NpcManager(world, new RegionTracker(32), hooks, log)
+ await first.seedOrRestore(store)
+ const contentRows = first.takeDirty(1000)
+ expect(contentRows.length).toBe(NPC_SPAWNS.length)
+ await store.worldEntities.upsertMany(contentRows)
+
+ setMapOverride({
+ terrains: [],
+ npcs: [
+ { id: 'w', npc: 'city_warden', pos: [3, 0, -13] },
+ { id: 'h', npc: 'dust_hare', pos: [60, 0, 50] },
+ { id: 'x', npc: 'no_such_npc', pos: [0, 0, 0] },
+ ],
+ })
+ try {
+ first.reconcileSpawns()
+ expect(forgotten.sort()).toEqual(contentRows.map((r) => r.id).sort())
+ const mapRows = first.takeDirty(1000)
+ expect(mapRows.map((r) => [r.defId, r.pos[0], r.pos[2]])).toEqual([
+ ['city_warden', 3, -13],
+ ['dust_hare', 60, 50],
+ ])
+ expect(first.counts()).toEqual([0, 2])
+ // Reconciling again changes nothing.
+ first.reconcileSpawns()
+ expect(first.takeDirty(1000)).toEqual([])
+ await store.worldEntities.deleteMany(forgotten)
+ await store.worldEntities.upsertMany(mapRows)
+
+ const restarted = new NpcManager(world, new RegionTracker(32), hooks, log)
+ await restarted.seedOrRestore(store)
+ expect(
+ restarted
+ .takeDirty(1000)
+ .map((r) => r.id)
+ .sort(),
+ ).toEqual(mapRows.map((r) => r.id).sort())
+ } finally {
+ setMapOverride(null)
+ }
+ })
})
})
describe('GameWorld dirty snapshot', () => {
describe('GameWorld dirty snapshot', () => {
@@ -220,6 +220,11 @@ export class GameServer {
@@ -220,6 +220,11 @@ export class GameServer {
this.systems.editor.broadcastMapReload()
this.systems.editor.broadcastMapReload()
}
}
+ /** A live map save: NPCs follow the map's spawns (not in a sandbox, which runs none). */
+ reconcileMapNpcs(): void {
+ if (!this.ctx.config.sandbox) this.systems.npcs.reconcileSpawns()
+ }
+
announce(text: string): void {
announce(text: string): void {
this.ctx.net.broadcastAll({ t: 'announce', text })
this.ctx.net.broadcastAll({ t: 'announce', text })
}
}
@@ -332,6 +332,11 @@ export class GameWorld {
@@ -332,6 +332,11 @@ export class GameWorld {
return entity
return entity
}
}
+ /** Delete a saved row at the next flush: an NPC dropped while abstract has no entity to despawn. */
+ forgetPersisted(id: EntityId): void {
+ this.deletedIds.add(id)
+ }
+
despawn(id: EntityId): void {
despawn(id: EntityId): void {
const entity = this.entities.remove(id)
const entity = this.entities.remove(id)
if (!entity) return
if (!entity) return
@@ -7,7 +7,7 @@ import {
@@ -7,7 +7,7 @@ import {
type NpcState,
type NpcState,
type PerceptionCandidate,
type PerceptionCandidate,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
-import { NPC_SPAWNS, terrainHeight } from '@openvibe/content'
+import { npcSpawnsFor, terrainHeight } from '@openvibe/content'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import type { PersistenceStore, WorldEntityDto } from '@openvibe/persistence'
import type { PersistenceStore, WorldEntityDto } from '@openvibe/persistence'
import {
import {
@@ -63,7 +63,7 @@ export class NpcManager {
@@ -63,7 +63,7 @@ export class NpcManager {
private readonly log: Logger,
private readonly log: Logger,
) {}
) {}
- /** Boot: restore persisted NPCs, then seed content spawns not yet known. */
+ /** Boot: restore persisted NPCs, then match them to the world's spawns. */
async seedOrRestore(store: PersistenceStore): Promise<void> {
async seedOrRestore(store: PersistenceStore): Promise<void> {
const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')
const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')
for (const row of rows) {
for (const row of rows) {
@@ -86,16 +86,44 @@ export class NpcManager {
@@ -86,16 +86,44 @@ export class NpcManager {
respawnAt: typeof s.respawnAt === 'number' ? s.respawnAt : 0,
respawnAt: typeof s.respawnAt === 'number' ? s.respawnAt : 0,
})
})
}
}
- // Content spawns missing from the save (fresh world or new content).
- for (const [index, spawn] of NPC_SPAWNS.entries()) {
- const arch = this.world.content.npc(spawn.archetype)
- if (!arch) continue
- const known = [...this.states.values()].some(
+ this.reconcileSpawns()
+ this.log.info('npcs ready', { count: this.states.size })
+ }
+
+ /**
+ * Make the NPCs match the world's spawns (npcSpawnsFor: the map's own, or
+ * the content's): an NPC whose home no spawn claims goes, saved row and all,
+ * and a spawn with nobody home gets its NPC. Boot runs it, and so does a live
+ * map save, so an NPC placed, moved or deleted in the editor shows up at once.
+ * An NPC is its spawn's while it has the archetype and a home within 2 m.
+ */
+ reconcileSpawns(): void {
+ const spawns = npcSpawnsFor(this.world.content.world).filter((s) =>
+ this.world.content.npc(s.archetype),
+ )
+ const claimed = new Set<string>()
+ const homeOf = (spawn: (typeof spawns)[number]): NpcState | undefined =>
+ [...this.states.values()].find(
(s) =>
(s) =>
+ !claimed.has(s.id) &&
s.archetype === spawn.archetype &&
s.archetype === spawn.archetype &&
Math.hypot(s.homeX - spawn.pos[0], s.homeZ - spawn.pos[2]) < 2,
Math.hypot(s.homeX - spawn.pos[0], s.homeZ - spawn.pos[2]) < 2,
)
)
- if (known) continue
+ const missing: [number, (typeof spawns)[number]][] = []
+ for (const [index, spawn] of spawns.entries()) {
+ const npc = homeOf(spawn)
+ if (npc) claimed.add(npc.id)
+ else missing.push([index, spawn])
+ }
+ for (const npc of [...this.states.values()]) {
+ if (claimed.has(npc.id)) continue
+ if (this.bodies.has(npc.id)) this.dematerialize(npc)
+ this.states.delete(npc.id)
+ this.dirtyIds.delete(npc.id)
+ this.world.forgetPersisted(asEntityId(npc.id))
+ }
+ for (const [index, spawn] of missing) {
+ const arch = this.world.content.npc(spawn.archetype)!
const id = newEntityId()
const id = newEntityId()
this.states.set(id, {
this.states.set(id, {
id,
id,
@@ -113,7 +141,6 @@ export class NpcManager {
@@ -113,7 +141,6 @@ export class NpcManager {
})
})
this.dirtyIds.add(id)
this.dirtyIds.add(id)
}
}
- this.log.info('npcs ready', { count: this.states.size })
}
}
/** Counts for metrics: [full-sim, abstract]. */
/** Counts for metrics: [full-sim, abstract]. */
@@ -66,6 +66,11 @@ export class NpcSystem implements System {
@@ -66,6 +66,11 @@ export class NpcSystem implements System {
await this.npcs.seedOrRestore(this.ctx.store)
await this.npcs.seedOrRestore(this.ctx.store)
}
}
+ /** A live map save: NPCs follow the map's spawns. */
+ reconcileSpawns(): void {
+ this.npcs.reconcileSpawns()
+ }
+
/** One NPC simulation step; the sound buffer is cleared once a second. */
/** One NPC simulation step; the sound buffer is cleared once a second. */
tick(ctx: ServerContext, _dt: number): void {
tick(ctx: ServerContext, _dt: number): void {
this.npcs.step(Date.now(), ctx.clock.tick, ctx.config.tickRate)
this.npcs.step(Date.now(), ctx.clock.tick, ctx.config.tickRate)
@@ -841,6 +841,7 @@ async function main(): Promise<void> {
@@ -841,6 +841,7 @@ async function main(): Promise<void> {
room.world.reconcileMapZones()
room.world.reconcileMapZones()
room.world.reconcileMapNodes()
room.world.reconcileMapNodes()
room.world.reconcileMapProps()
room.world.reconcileMapProps()
+ room.game.reconcileMapNpcs()
room.game.broadcastMapReload()
room.game.broadcastMapReload()
}
}
editors.broadcastSaved(revision)
editors.broadcastSaved(revision)
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
import {
ITEMS,
ITEMS,
- NPC_SPAWNS,
+ NPC_ARCHETYPES,
RESOURCE_NODES,
RESOURCE_NODES,
WATER_LEVEL,
WATER_LEVEL,
decodeHeights,
decodeHeights,
@@ -23,7 +23,11 @@ describe('Scrap Island', () => {
@@ -23,7 +23,11 @@ describe('Scrap Island', () => {
const items = new Set(ITEMS.map((i) => i.id))
const items = new Set(ITEMS.map((i) => i.id))
expect(map.nodes.filter((n) => !nodeKinds.has(n.node))).toEqual([])
expect(map.nodes.filter((n) => !nodeKinds.has(n.node))).toEqual([])
expect(map.props.filter((p) => !items.has(p.item))).toEqual([])
expect(map.props.filter((p) => !items.has(p.item))).toEqual([])
- const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights].map((o) => o.id)
+ const archetypes = new Set(NPC_ARCHETYPES.map((a) => a.id))
+ expect(map.npcs.filter((n) => !archetypes.has(n.npc))).toEqual([])
+ const ids = [...map.statics, ...map.nodes, ...map.props, ...map.lights, ...map.npcs].map(
+ (o) => o.id,
+ )
expect(new Set(ids).size).toBe(ids.length)
expect(new Set(ids).size).toBe(ids.length)
})
})
@@ -101,12 +105,20 @@ describe('Scrap Island', () => {
@@ -101,12 +105,20 @@ describe('Scrap Island', () => {
expect(inside).toEqual([])
expect(inside).toEqual([])
})
})
- it("keeps the content's NPC homes on land and outside the walls, except the town's own", () => {
+ it('has people and animals of its own: the town inside the walls, the rest out on land, Rustjaw far from the gates', () => {
const height = makeHeight(20261019)
const height = makeHeight(20261019)
- for (const s of NPC_SPAWNS) {
- expect(height(s.pos[0], s.pos[2])).toBeGreaterThan(WATER_LEVEL + 0.5)
- const outside = Math.max(Math.abs(s.pos[0]), Math.abs(s.pos[2])) > ISLAND.wall
- expect(outside).toBe(s.archetype !== 'city_warden' && s.archetype !== 'townsfolk')
+ const count = (a: string) => map.npcs.filter((n) => n.npc === a).length
+ expect(count('city_warden')).toBeGreaterThanOrEqual(4)
+ expect(count('townsfolk')).toBeGreaterThanOrEqual(4)
+ expect(count('dust_hare')).toBeGreaterThanOrEqual(6)
+ expect(count('rustjaw_thug')).toBeGreaterThanOrEqual(4)
+ for (const n of map.npcs) {
+ const [x, , z] = n.pos
+ expect(height(x, z)).toBeGreaterThan(WATER_LEVEL + 0.5)
+ const inside = Math.max(Math.abs(x), Math.abs(z)) < ISLAND.wall
+ expect(inside).toBe(n.npc === 'city_warden' || n.npc === 'townsfolk')
+ // A hostile thug's leash (35 m) never reaches a city gate.
+ if (n.npc === 'rustjaw_thug') expect(Math.hypot(x, z)).toBeGreaterThan(ISLAND.wall + 50)
}
}
})
})
})
})
@@ -943,6 +943,57 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
@@ -943,6 +943,57 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
prop(pick(['wooden_crate', 'supply_crate', 'storage_box']), cx, cz, yaw)
prop(pick(['wooden_crate', 'supply_crate', 'storage_box']), cx, cz, yaw)
}
}
+ // Who lives here. Wardens keep the gates and the square by the spawn; townsfolk walk the avenues and the ring road
+ // (lots and the plaza's stalls stay clear); hares graze the farm and the fields; Rustjaw thugs hold the far end of the
+ // scrapyard, the deep forest and Rust Ridge, all beyond a leash's reach of a gate.
+ const npcs: MapFileV2['npcs'] = []
+ const npc = (id: string, archetype: string, x: number, z: number): void => {
+ npcs.push({ id, npc: archetype, pos: [x, 0, z] })
+ }
+ npc('warden-north', 'city_warden', 2.5, 32)
+ npc('warden-south', 'city_warden', -2.5, -32)
+ npc('warden-east', 'city_warden', 32, -2.5)
+ npc('warden-west', 'city_warden', -32, 2.5)
+ npc('warden-square', 'city_warden', 7.5, -19)
+ for (const [i, [x, z]] of (
+ [
+ [2.5, 18],
+ [-2.5, -19],
+ [18.5, -2.5],
+ [-18.5, 2.5],
+ [26.5, 12],
+ [-26.5, -14],
+ ] as const
+ ).entries())
+ npc(`townsfolk-${i + 1}`, 'townsfolk', x, z)
+ const [fx, fz] = ISLAND.farm.center
+ for (const [i, [x, z]] of (
+ [
+ [fx - 8, fz + 8],
+ [fx + 8, fz - 8],
+ [fx + 6, fz + 10],
+ [fx - 6, fz - 10],
+ [60, 50],
+ [70, 40],
+ [-30, 80],
+ [30, 85],
+ [-50, -30],
+ ] as const
+ ).entries())
+ npc(`hare-${i + 1}`, 'dust_hare', x, z)
+ const [rx, rz] = ISLAND.ridge.center
+ for (const [i, [x, z]] of (
+ [
+ [ISLAND.scrapyard.max[0] - 8, 14],
+ [ISLAND.scrapyard.max[0] - 6, -16],
+ [-140, 34],
+ [-128, -6],
+ [rx - 12, rz + 6],
+ [rx + 16, rz - 4],
+ ] as const
+ ).entries())
+ npc(`rustjaw-${i + 1}`, 'rustjaw_thug', x, z)
+
const terrain: MapFileV2['terrains'][number] = {
const terrain: MapFileV2['terrains'][number] = {
id: 'island',
id: 'island',
name: 'Scrap Island',
name: 'Scrap Island',
@@ -970,6 +1021,7 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
@@ -970,6 +1021,7 @@ export function generateScrapIsland(seed = 20261019): MapFileV2 {
statics,
statics,
nodes,
nodes,
props,
props,
+ npcs,
lights,
lights,
zones: [
zones: [
{
{
@@ -1,4 +1,6 @@
@@ -1,4 +1,6 @@
import type { FactionDef, NpcArchetype } from '../schema/npc.js'
import type { FactionDef, NpcArchetype } from '../schema/npc.js'
+import type { WorldDef } from '../schema/world.js'
+import { getMapOverride } from '../terrain.js'
/**
/**
* NPC content: factions, archetypes, and world spawn points. Behavior is
* NPC content: factions, archetypes, and world spawn points. Behavior is
@@ -108,8 +110,25 @@ export const NPC_ARCHETYPES: NpcArchetype[] = [
@@ -108,8 +110,25 @@ export const NPC_ARCHETYPES: NpcArchetype[] = [
},
},
]
]
+export interface NpcSpawn {
+ archetype: string
+ pos: [number, number, number]
+}
+
+/**
+ * Where a world's NPCs live: the map's own NPC spawns when it places any (the
+ * editor owns them), else NPC_SPAWNS. Those were laid out for the procedural
+ * world, so on a map of its own they stood in odd places — Rustjaw thugs just
+ * outside Scrap City's wall, seven NPCs on a whole island.
+ */
+export function npcSpawnsFor(world?: WorldDef): NpcSpawn[] {
+ const mapped = getMapOverride(world)?.npcs ?? []
+ if (mapped.length) return mapped.map((n) => ({ archetype: n.npc, pos: n.pos }))
+ return NPC_SPAWNS
+}
+
/** World NPC spawn points (positions are terrain-relative, like props). */
/** World NPC spawn points (positions are terrain-relative, like props). */
-export const NPC_SPAWNS: { archetype: string; pos: [number, number, number] }[] = [
+export const NPC_SPAWNS: NpcSpawn[] = [
// Rustjaw ambushers haunt the scrapyard and the deep forest.
// Rustjaw ambushers haunt the scrapyard and the deep forest.
{ archetype: 'rustjaw_thug', pos: [43, 0, -40] },
{ archetype: 'rustjaw_thug', pos: [43, 0, -40] },
{ archetype: 'rustjaw_thug', pos: [40, 0, 48] },
{ archetype: 'rustjaw_thug', pos: [40, 0, 48] },
@@ -10,6 +10,8 @@ import {
@@ -10,6 +10,8 @@ import {
type MapFileV2,
type MapFileV2,
type ParsedMap,
type ParsedMap,
} from './mapFileV2.js'
} from './mapFileV2.js'
+import { NPC_SPAWNS, npcSpawnsFor } from './defs/npcs.js'
+import { setMapOverride } from './terrain.js'
const SUB = 8
const SUB = 8
@@ -444,3 +446,39 @@ describe('map-authored zones', () => {
@@ -444,3 +446,39 @@ describe('map-authored zones', () => {
if (again.ok) expect(again.map.zones[0]).toEqual(r.map.zones[0])
if (again.ok) expect(again.map.zones[0]).toEqual(r.map.zones[0])
})
})
})
})
+
+describe('map-authored NPCs', () => {
+ it('an older map with no npcs reads as none, and a new one starts with none', () => {
+ const raw = JSON.parse(JSON.stringify(emptyMapV2())) as Record<string, unknown>
+ delete raw.npcs
+ const parsed = parseMapFile(raw)
+ expect(parsed.ok && parsed.map.npcs).toEqual([])
+ expect(emptyMapV2().npcs).toEqual([])
+ })
+
+ it('compiles NPC spawns into the override, and npcSpawnsFor prefers them to the content', () => {
+ const map: MapFileV2 = {
+ ...emptyMapV2(),
+ npcs: [{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }],
+ }
+ expect(validateMapFile(map)).toEqual([])
+ const override = compileMapFileV2(map)
+ expect(override.npcs).toEqual([{ id: 'npc-1', npc: 'dust_hare', pos: [12, 0, -4] }])
+ setMapOverride(override)
+ try {
+ expect(npcSpawnsFor()).toEqual([{ archetype: 'dust_hare', pos: [12, 0, -4] }])
+ } finally {
+ setMapOverride(null)
+ }
+ expect(npcSpawnsFor()).toBe(NPC_SPAWNS)
+ })
+
+ it('an NPC id may not repeat another object id', () => {
+ const map: MapFileV2 = {
+ ...emptyMapV2(),
+ nodes: [{ id: 'x', node: 'oak_tree', pos: [0, 0, 0] }],
+ npcs: [{ id: 'x', npc: 'townsfolk', pos: [1, 0, 1] }],
+ }
+ expect(validateMapFile(map).some((i) => i.includes('"x"'))).toBe(true)
+ })
+})
@@ -159,6 +159,11 @@ export const MapFileV2Schema = z.object({
@@ -159,6 +159,11 @@ export const MapFileV2Schema = z.object({
)
)
.max(20_000)
.max(20_000)
.default([]),
.default([]),
+ /** NPC spawn points: an archetype id (`npc`) and the ground position it lives around. */
+ npcs: z
+ .array(z.object({ id: documentId, npc: z.string(), pos: finiteVec3 }))
+ .max(2000)
+ .default([]),
lights: z.array(MapLightSchemaV2).max(256).default([]),
lights: z.array(MapLightSchemaV2).max(256).default([]),
/** Map-authored zones; they augment the base world's content zones. */
/** Map-authored zones; they augment the base world's content zones. */
zones: z.array(MapZoneSchemaV2).max(512).default([]),
zones: z.array(MapZoneSchemaV2).max(512).default([]),
@@ -188,6 +193,7 @@ export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
@@ -188,6 +193,7 @@ export type MapLightV2 = z.infer<typeof MapLightSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
export type MapZoneV2 = z.infer<typeof MapZoneSchemaV2>
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapNodeV2 = MapFileV2['nodes'][number]
export type MapPropV2 = MapFileV2['props'][number]
export type MapPropV2 = MapFileV2['props'][number]
+export type MapNpcV2 = MapFileV2['npcs'][number]
export type MapModelV2 = MapFileV2['models'][number]
export type MapModelV2 = MapFileV2['models'][number]
/** The synthetic document id for the spawn point (stored top-level on the wire). */
/** The synthetic document id for the spawn point (stored top-level on the wire). */
@@ -201,6 +207,7 @@ export function emptyMapV2(): MapFileV2 {
@@ -201,6 +207,7 @@ export function emptyMapV2(): MapFileV2 {
statics: [],
statics: [],
nodes: [],
nodes: [],
props: [],
props: [],
+ npcs: [],
lights: [],
lights: [],
zones: [],
zones: [],
models: [],
models: [],
@@ -216,6 +223,7 @@ const ID_KINDS = [
@@ -216,6 +223,7 @@ const ID_KINDS = [
['props', 'pr'],
['props', 'pr'],
['lights', 'light'],
['lights', 'light'],
['zones', 'zone'],
['zones', 'zone'],
+ ['npcs', 'npc'],
] as const
] as const
/**
/**
@@ -344,6 +352,7 @@ export function validateMapFile(map: MapFileV2): string[] {
@@ -344,6 +352,7 @@ export function validateMapFile(map: MapFileV2): string[] {
}
}
for (const n of map.nodes) claim(n.id, 'node')
for (const n of map.nodes) claim(n.id, 'node')
for (const p of map.props) claim(p.id, 'prop')
for (const p of map.props) claim(p.id, 'prop')
+ for (const n of map.npcs) claim(n.id, 'npc')
for (const l of map.lights) claim(l.id, 'light')
for (const l of map.lights) claim(l.id, 'light')
for (const z0 of map.zones) claim(z0.id, 'zone')
for (const z0 of map.zones) claim(z0.id, 'zone')
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
if (seen.has(SPAWN_OBJECT_ID)) issues.push(`"${SPAWN_OBJECT_ID}" is reserved for the spawn point`)
@@ -409,6 +418,7 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
@@ -409,6 +418,7 @@ export function compileMapFileV2(map: MapFileV2): MapOverride {
...(t.surface?.base.uv ? { uv: t.surface.base.uv as FaceStyle } : {}),
...(t.surface?.base.uv ? { uv: t.surface.base.uv as FaceStyle } : {}),
})),
})),
nodes: map.nodes.map((n) => ({ id: n.id, node: n.node, pos: n.pos })),
nodes: map.nodes.map((n) => ({ id: n.id, node: n.node, pos: n.pos })),
+ npcs: map.npcs.map((n) => ({ id: n.id, npc: n.npc, pos: n.pos })),
props: map.props.map((p) => ({
props: map.props.map((p) => ({
id: p.id,
id: p.id,
item: p.item,
item: p.item,
@@ -71,6 +71,7 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
@@ -71,6 +71,7 @@ export function creatorMapIssues(map: MapFileV2, bytes: number): string[] {
limit('props', map.props.length)
limit('props', map.props.length)
limit('lights', map.lights.length)
limit('lights', map.lights.length)
limit('zones', map.zones.length)
limit('zones', map.zones.length)
+ if (map.npcs.length > 0) issues.push('npcs: creator worlds have no NPCs yet')
if (map.models.length > 0)
if (map.models.length > 0)
issues.push('models: uploaded models are not available in creator worlds yet')
issues.push('models: uploaded models are not available in creator worlds yet')
if (map.textures.length > 0)
if (map.textures.length > 0)
@@ -121,6 +121,13 @@ export interface MapPropSpawn {
@@ -121,6 +121,13 @@ export interface MapPropSpawn {
yaw?: number
yaw?: number
}
}
+/** An editor-placed NPC: where an archetype (`npc`) lives and wanders from. */
+export interface MapNpcSpawn {
+ id: string
+ npc: string
+ pos: [number, number, number]
+}
+
export interface TerrainPatchData {
export interface TerrainPatchData {
id: string
id: string
origin: [number, number, number]
origin: [number, number, number]
@@ -168,6 +175,11 @@ export interface MapOverride {
@@ -168,6 +175,11 @@ export interface MapOverride {
nodes?: MapNodeSpawn[]
nodes?: MapNodeSpawn[]
/** Editor-placed initial props merged into fresh-world seeding. */
/** Editor-placed initial props merged into fresh-world seeding. */
props?: MapPropSpawn[]
props?: MapPropSpawn[]
+ /**
+ * Editor-placed NPCs. A map with any replaces the content's NPC_SPAWNS,
+ * which were laid out for the procedural world (see npcSpawnsFor).
+ */
+ npcs?: MapNpcSpawn[]
/** Every terrain object in the map. May be empty. */
/** Every terrain object in the map. May be empty. */
terrains: TerrainPatchData[]
terrains: TerrainPatchData[]
/**
/**