openvibers/openvibe.games Commits
Scraplandia's NPCs look like people: wardens, townsfolk and Rustjaw wear the players' avatar dressed by archetype and varied by id (they walk, turn and flinch), and hares are small faceted hares, in place of a tinted box with a box head (#116)
Changes
@@@ -27,6 +27,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Added++- **NPCs look like people.** Wardens, townsfolk and Rustjaw thugs wear the players' own low-poly avatar, so they++ walk, turn and flinch like players. Each is dressed by its archetype (wardens in denim or slate, Rustjaw in rust++ with beards and buzz cuts, townsfolk in everyday colours) and varied by its id. Dust hares are small faceted hares.++ Every NPC was a tinted box with a box head.- **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 NPCsreplaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just@@@ -16,6 +16,7 @@ import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'import { quat, vec3, wrapAngle } from '@openvibe/shared'import type { ClientState } from '../state/clientState.js'import { Avatar } from './avatar/avatar.js'++import { buildCritter, npcAppearance } from './npcLooks.js'import { materialFor, meshForShape } from './sceneSetup.js'/**@@@ -50,6 +51,8 @@ interface EntityVisual {bodyStance: number/** Hit-shake time remaining (resources being chopped/mined). */shakeT: number++ /** An NPC avatar's feet below its position (half its height); null for players, whose hull sets it. */++ feetOffset: number | null}const INTERP_DELAY = 0.13@@@ -94,6 +97,7 @@ export class EntityView {let mesh: Mesh | null = nulllet avatar: Avatar | null = null++ let feetOffset: number | null = nulllet bodyId: BodyId | null = nullif (e.kind === 'player') {@@@ -126,22 +130,19 @@ export class EntityView {} else if (e.kind === 'prop') {mesh = CreateBox(`prop:${e.id}`, { size: 0.5 }, this.scene)} else if (e.kind === 'npc') {-- // Placeholder NPC visual: tinted capsule-ish body + head knob until-- // real models exist. A collision mirror makes it aim-able.++ // People wear the players' avatar, dressed by archetype (npcLooks.ts); animals get a small body. A collision++ // mirror makes either aim-able.const arch = this.content.npc(e.def ?? '')const height = arch?.heightM ?? 1.7const radius = arch?.radius ?? 0.35-- mesh = CreateBox(-- `npc:${e.id}`,-- { width: radius * 2, height: height * 0.72, depth: radius * 1.4 },-- this.scene,-- )-- mesh.material = materialFor(this.scene, arch?.color ?? '#888888')-- const head = CreateBox(`npc:${e.id}:head`, { size: radius * 1.1 }, this.scene)-- head.parent = mesh-- head.position.y = height * 0.72 * 0.5 + radius * 0.6-- head.material = mesh.material-- mesh.rotationQuaternion = new Quaternion(e.rot[0], e.rot[1], e.rot[2], e.rot[3])++ const look = npcAppearance(arch?.id ?? '', e.id)++ if (look) {++ avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`)++ feetOffset = height / 2++ } else {++ mesh = buildCritter(this.scene, `npc:${e.id}`, arch?.color ?? '#888888', height / 2)++ mesh.rotationQuaternion = new Quaternion(e.rot[0], e.rot[1], e.rot[2], e.rot[3])++ }bodyId = this.physics.addBody({shape: { type: 'capsule', radius, height },motion: 'static',@@@ -195,6 +196,7 @@ export class EntityView {buffer: [],bodyStance: 0,shakeT: 0,++ feetOffset,})}@@@ -281,7 +283,20 @@ export class EntityView {for (const b of snap.bodies) {const v = this.visuals.get(b.id)if (!v) continue-- pushSample(v.buffer, { t: serverTime, pos: b.pos, rot: b.rot })++ if (v.avatar) {++ // An NPC avatar: facing from its rotation, walking speed from how far it went since the last sample.++ const prev = v.buffer[v.buffer.length - 1]++ const dt = prev ? serverTime - prev.t : 0++ pushSample(v.buffer, {++ t: serverTime,++ pos: b.pos,++ rot: b.rot,++ yaw: 2 * Math.atan2(b.rot[1], b.rot[3]),++ speed:++ prev && dt > 0 ? Math.hypot(b.pos[0] - prev.pos[0], b.pos[2] - prev.pos[2]) / dt : 0,++ grounded: true,++ })++ } else pushSample(v.buffer, { t: serverTime, pos: b.pos, rot: b.rot })v.entity.pos = b.posv.entity.rot = b.rotif (v.bodyId !== null) {@@@ -352,12 +367,13 @@ export class EntityView {// Blend FEET height, not capsule-center height: the center jumps// when the hull shrinks (crouch/prone) and blending centers sinks// the avatar through the floor mid-transition.-- const feetA = a.pos[1] - hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2-- const feetB = b.pos[1] - hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2++ const feetA = a.pos[1] - (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2)++ const feetB = b.pos[1] - (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2)const feetY = feetA + (feetB - feetA) * alpha// Keep the player collision mirror on the smooth pose; swap the hull// when their stance changes so crouched players are shorter to walk on.-- if (v.bodyId !== null) {++ // (An NPC's mirror follows its snapshots, in onSnapshot.)++ if (v.bodyId !== null && v.feetOffset === null) {const stance = latest.stance ?? 0if (stance !== v.bodyStance) {this.entityByBody.delete(v.bodyId)nnew file mode 100644@@@ -0,0 +1,46 @@++import { describe, expect, it } from 'vitest'++import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'++import { Scene } from '@babylonjs/core/scene.js'++import { AppearanceSchema } from '@openvibe/protocol'++import { NPC_ARCHETYPES } from '@openvibe/content'++import { buildCritter, npcAppearance } from './npcLooks.js'++++describe('npcAppearance', () => {++ it('dresses every person archetype in a valid avatar, the same one for the same NPC', () => {++ for (const arch of ['city_warden', 'townsfolk', 'rustjaw_thug']) {++ for (const id of ['e1', 'e2', 'npc-7f3a']) {++ const look = npcAppearance(arch, id)++ expect(AppearanceSchema.safeParse(look).success).toBe(true)++ expect(npcAppearance(arch, id)).toEqual(look)++ }++ }++ })++++ it('keeps wardens in denim or slate and Rustjaw in rust', () => {++ expect([7, 3]).toContain(npcAppearance('city_warden', 'a')!.top)++ expect(npcAppearance('rustjaw_thug', 'b')!.top).toBe(6)++ })++++ it('gives animals no avatar, and covers every archetype the content has', () => {++ expect(npcAppearance('dust_hare', 'h')).toBeNull()++ for (const a of NPC_ARCHETYPES) {++ const person = npcAppearance(a.id, 'x') !== null++ expect(person).toBe(a.id !== 'dust_hare')++ }++ })++})++++describe('buildCritter', () => {++ it('stands on the ground below its centre', () => {++ const engine = new NullEngine()++ const scene = new Scene(engine)++ const hare = buildCritter(scene, 'npc:h', '#9a8a6a', 0.25)++ hare.computeWorldMatrix(true)++ hare.refreshBoundingInfo()++ const { minimum, maximum } = hare.getBoundingInfo().boundingBox++ expect(minimum.y).toBeCloseTo(-0.25, 1)++ expect(maximum.z).toBeGreaterThan(0.2) // the head leads, toward +z++ scene.dispose()++ engine.dispose()++ })++})nnew file mode 100644@@@ -0,0 +1,96 @@++import { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'++import { CreateIcoSphere } from '@babylonjs/core/Meshes/Builders/icoSphereBuilder.js'++import type { Scene } from '@babylonjs/core/scene.js'++import { HAIR_STYLES, type Appearance } from '@openvibe/protocol'++import { materialFor } from './sceneSetup.js'++++/**++ * How NPCs look. People (wardens, townsfolk, Rustjaw) are the players' own++ * low-poly avatar, dressed by their archetype and varied by their id, so they++ * walk, turn and flinch like players; animals get a small faceted body. NPCs++ * used to be a tinted box with a box head.++ */++++/** A stable number in [0, 1) from an entity id and a salt. */++function hash01(id: string, salt: number): number {++ let h = 2166136261 ^ salt++ for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)++ h = Math.imul(h ^ (h >>> 13), 0x5bd1e995)++ return ((h ^ (h >>> 15)) >>> 0) / 4294967296++}++const pick = <T>(id: string, salt: number, options: readonly T[]): T =>++ options[Math.floor(hash01(id, salt) * options.length)]!++++/** Palette indices (render/avatar/palettes.ts) each archetype dresses in. */++const DRESS: Record<++ string,++ { top: number[]; bottom: number[]; shoes: number[]; build: [number, number] }++> = {++ // Wardens: denim or dark slate over charcoal, a little broader.++ city_warden: { top: [7, 3], bottom: [9], shoes: [9], build: [1.05, 1.15] },++ // Townsfolk: everyday colours.++ townsfolk: { top: [0, 1, 4, 5, 8], bottom: [1, 2, 4, 7], shoes: [2, 4, 9], build: [0.9, 1.05] },++ // Rustjaw: rust and charcoal, scrappy.++ rustjaw_thug: { top: [6], bottom: [3, 9], shoes: [9], build: [1, 1.12] },++}++++/** The avatar an NPC of `archetype` wears, or null for an animal. Stable per entity id. */++export function npcAppearance(archetype: string, id: string): Appearance | null {++ const dress = DRESS[archetype]++ if (!dress) return null++ const body = hash01(id, 1) < 0.5 ? 'male' : 'female'++ return {++ body,++ skin: Math.floor(hash01(id, 2) * 8),++ hairStyle:++ archetype === 'rustjaw_thug'++ ? pick(id, 3, ['buzz', 'bald', 'messy'] as const)++ : pick(id, 3, HAIR_STYLES),++ hairColor: Math.floor(hash01(id, 4) * 8),++ facialHair:++ body === 'male'++ ? pick(++ id,++ 5,++ archetype === 'rustjaw_thug'++ ? (['full', 'goatee'] as const)++ : (['none', 'none', 'mustache', 'goatee', 'full'] as const),++ )++ : 'none',++ top: pick(id, 6, dress.top),++ bottom: pick(id, 7, dress.bottom),++ shoes: pick(id, 8, dress.shoes),++ height: 0.95 + hash01(id, 9) * 0.1,++ build: dress.build[0] + hash01(id, 10) * (dress.build[1] - dress.build[0]),++ }++}++++/**++ * A small faceted animal facing +z, its feet at -halfHeight (the entity's++ * position is its centre): body, head and ears in `color`, a pale tail.++ */++export function buildCritter(scene: Scene, name: string, color: string, halfHeight: number): Mesh {++ const floor = -halfHeight++ const body = CreateIcoSphere(`${name}:body`, { radius: 0.5, subdivisions: 1 }, scene)++ body.scaling.set(0.26, 0.26, 0.42)++ body.position.set(0, floor + 0.14, -0.02)++ const head = CreateIcoSphere(`${name}:head`, { radius: 0.5, subdivisions: 1 }, scene)++ head.scaling.set(0.17, 0.16, 0.2)++ head.position.set(0, floor + 0.26, 0.2)++ const ears = [-1, 1].map((side) => {++ const ear = CreateBox(`${name}:ear`, { width: 0.045, height: 0.17, depth: 0.06 }, scene)++ ear.position.set(side * 0.04, floor + 0.4, 0.15)++ ear.rotation.set(-0.35, 0, side * 0.18)++ return ear++ })++ const merged = Mesh.MergeMeshes([body, head, ...ears], true, true) ?? body++ merged.name = name++ merged.convertToFlatShadedMesh()++ merged.material = materialFor(scene, color)++ const tail = CreateIcoSphere(`${name}:tail`, { radius: 0.05, subdivisions: 1 }, scene)++ tail.position.set(0, floor + 0.17, -0.22)++ tail.material = materialFor(scene, '#ece6da')++ tail.parent = merged++ return merged++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/render/npcLooks.test.ts'++// covers: 'apps/client/src/render'++// Generated by node test/run.js --index; do not edit.
@@ -27,6 +27,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -27,6 +27,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### Added
+- **NPCs look like people.** Wardens, townsfolk and Rustjaw thugs wear the players' own low-poly avatar, so they
+ walk, turn and flinch like players. Each is dressed by its archetype (wardens in denim or slate, Rustjaw in rust
+ with beards and buzz cuts, townsfolk in everyday colours) and varied by its id. Dust hares are small faceted hares.
+ Every NPC was a tinted box with a box head.
- **NPCs in the map, and 26 on Scrap Island.** The v2 map document has `npcs` (an archetype and a ground position),
- **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
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
replaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just
@@ -16,6 +16,7 @@ import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'
@@ -16,6 +16,7 @@ import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'
import { quat, vec3, wrapAngle } from '@openvibe/shared'
import { quat, vec3, wrapAngle } from '@openvibe/shared'
import type { ClientState } from '../state/clientState.js'
import type { ClientState } from '../state/clientState.js'
import { Avatar } from './avatar/avatar.js'
import { Avatar } from './avatar/avatar.js'
+import { buildCritter, npcAppearance } from './npcLooks.js'
import { materialFor, meshForShape } from './sceneSetup.js'
import { materialFor, meshForShape } from './sceneSetup.js'
/**
/**
@@ -50,6 +51,8 @@ interface EntityVisual {
@@ -50,6 +51,8 @@ interface EntityVisual {
bodyStance: number
bodyStance: number
/** Hit-shake time remaining (resources being chopped/mined). */
/** Hit-shake time remaining (resources being chopped/mined). */
shakeT: number
shakeT: number
+ /** An NPC avatar's feet below its position (half its height); null for players, whose hull sets it. */
+ feetOffset: number | null
}
}
const INTERP_DELAY = 0.13
const INTERP_DELAY = 0.13
@@ -94,6 +97,7 @@ export class EntityView {
@@ -94,6 +97,7 @@ export class EntityView {
let mesh: Mesh | null = null
let mesh: Mesh | null = null
let avatar: Avatar | null = null
let avatar: Avatar | null = null
+ let feetOffset: number | null = null
let bodyId: BodyId | null = null
let bodyId: BodyId | null = null
if (e.kind === 'player') {
if (e.kind === 'player') {
@@ -126,22 +130,19 @@ export class EntityView {
@@ -126,22 +130,19 @@ export class EntityView {
} else if (e.kind === 'prop') {
} else if (e.kind === 'prop') {
mesh = CreateBox(`prop:${e.id}`, { size: 0.5 }, this.scene)
mesh = CreateBox(`prop:${e.id}`, { size: 0.5 }, this.scene)
} else if (e.kind === 'npc') {
} else if (e.kind === 'npc') {
- // Placeholder NPC visual: tinted capsule-ish body + head knob until
- // real models exist. A collision mirror makes it aim-able.
+ // People wear the players' avatar, dressed by archetype (npcLooks.ts); animals get a small body. A collision
+ // mirror makes either aim-able.
const arch = this.content.npc(e.def ?? '')
const arch = this.content.npc(e.def ?? '')
const height = arch?.heightM ?? 1.7
const height = arch?.heightM ?? 1.7
const radius = arch?.radius ?? 0.35
const radius = arch?.radius ?? 0.35
- mesh = CreateBox(
- `npc:${e.id}`,
- { width: radius * 2, height: height * 0.72, depth: radius * 1.4 },
- this.scene,
- )
- mesh.material = materialFor(this.scene, arch?.color ?? '#888888')
- const head = CreateBox(`npc:${e.id}:head`, { size: radius * 1.1 }, this.scene)
- head.parent = mesh
- head.position.y = height * 0.72 * 0.5 + radius * 0.6
- head.material = mesh.material
- mesh.rotationQuaternion = new Quaternion(e.rot[0], e.rot[1], e.rot[2], e.rot[3])
+ const look = npcAppearance(arch?.id ?? '', e.id)
+ if (look) {
+ avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`)
+ feetOffset = height / 2
+ } else {
+ mesh = buildCritter(this.scene, `npc:${e.id}`, arch?.color ?? '#888888', height / 2)
+ mesh.rotationQuaternion = new Quaternion(e.rot[0], e.rot[1], e.rot[2], e.rot[3])
+ }
bodyId = this.physics.addBody({
bodyId = this.physics.addBody({
shape: { type: 'capsule', radius, height },
shape: { type: 'capsule', radius, height },
motion: 'static',
motion: 'static',
@@ -195,6 +196,7 @@ export class EntityView {
@@ -195,6 +196,7 @@ export class EntityView {
buffer: [],
buffer: [],
bodyStance: 0,
bodyStance: 0,
shakeT: 0,
shakeT: 0,
+ feetOffset,
})
})
}
}
@@ -281,7 +283,20 @@ export class EntityView {
@@ -281,7 +283,20 @@ export class EntityView {
for (const b of snap.bodies) {
for (const b of snap.bodies) {
const v = this.visuals.get(b.id)
const v = this.visuals.get(b.id)
if (!v) continue
if (!v) continue
- pushSample(v.buffer, { t: serverTime, pos: b.pos, rot: b.rot })
+ if (v.avatar) {
+ // An NPC avatar: facing from its rotation, walking speed from how far it went since the last sample.
+ const prev = v.buffer[v.buffer.length - 1]
+ const dt = prev ? serverTime - prev.t : 0
+ pushSample(v.buffer, {
+ t: serverTime,
+ pos: b.pos,
+ rot: b.rot,
+ yaw: 2 * Math.atan2(b.rot[1], b.rot[3]),
+ speed:
+ prev && dt > 0 ? Math.hypot(b.pos[0] - prev.pos[0], b.pos[2] - prev.pos[2]) / dt : 0,
+ grounded: true,
+ })
+ } else pushSample(v.buffer, { t: serverTime, pos: b.pos, rot: b.rot })
v.entity.pos = b.pos
v.entity.pos = b.pos
v.entity.rot = b.rot
v.entity.rot = b.rot
if (v.bodyId !== null) {
if (v.bodyId !== null) {
@@ -352,12 +367,13 @@ export class EntityView {
@@ -352,12 +367,13 @@ export class EntityView {
// Blend FEET height, not capsule-center height: the center jumps
// Blend FEET height, not capsule-center height: the center jumps
// when the hull shrinks (crouch/prone) and blending centers sinks
// when the hull shrinks (crouch/prone) and blending centers sinks
// the avatar through the floor mid-transition.
// the avatar through the floor mid-transition.
- const feetA = a.pos[1] - hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2
- const feetB = b.pos[1] - hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2
+ const feetA = a.pos[1] - (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2)
+ const feetB = b.pos[1] - (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2)
const feetY = feetA + (feetB - feetA) * alpha
const feetY = feetA + (feetB - feetA) * alpha
// Keep the player collision mirror on the smooth pose; swap the hull
// Keep the player collision mirror on the smooth pose; swap the hull
// when their stance changes so crouched players are shorter to walk on.
// when their stance changes so crouched players are shorter to walk on.
- if (v.bodyId !== null) {
+ // (An NPC's mirror follows its snapshots, in onSnapshot.)
+ if (v.bodyId !== null && v.feetOffset === null) {
const stance = latest.stance ?? 0
const stance = latest.stance ?? 0
if (stance !== v.bodyStance) {
if (stance !== v.bodyStance) {
this.entityByBody.delete(v.bodyId)
this.entityByBody.delete(v.bodyId)
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
+import { Scene } from '@babylonjs/core/scene.js'
+import { AppearanceSchema } from '@openvibe/protocol'
+import { NPC_ARCHETYPES } from '@openvibe/content'
+import { buildCritter, npcAppearance } from './npcLooks.js'
+
+describe('npcAppearance', () => {
+ it('dresses every person archetype in a valid avatar, the same one for the same NPC', () => {
+ for (const arch of ['city_warden', 'townsfolk', 'rustjaw_thug']) {
+ for (const id of ['e1', 'e2', 'npc-7f3a']) {
+ const look = npcAppearance(arch, id)
+ expect(AppearanceSchema.safeParse(look).success).toBe(true)
+ expect(npcAppearance(arch, id)).toEqual(look)
+ }
+ }
+ })
+
+ it('keeps wardens in denim or slate and Rustjaw in rust', () => {
+ expect([7, 3]).toContain(npcAppearance('city_warden', 'a')!.top)
+ expect(npcAppearance('rustjaw_thug', 'b')!.top).toBe(6)
+ })
+
+ it('gives animals no avatar, and covers every archetype the content has', () => {
+ expect(npcAppearance('dust_hare', 'h')).toBeNull()
+ for (const a of NPC_ARCHETYPES) {
+ const person = npcAppearance(a.id, 'x') !== null
+ expect(person).toBe(a.id !== 'dust_hare')
+ }
+ })
+})
+
+describe('buildCritter', () => {
+ it('stands on the ground below its centre', () => {
+ const engine = new NullEngine()
+ const scene = new Scene(engine)
+ const hare = buildCritter(scene, 'npc:h', '#9a8a6a', 0.25)
+ hare.computeWorldMatrix(true)
+ hare.refreshBoundingInfo()
+ const { minimum, maximum } = hare.getBoundingInfo().boundingBox
+ expect(minimum.y).toBeCloseTo(-0.25, 1)
+ expect(maximum.z).toBeGreaterThan(0.2) // the head leads, toward +z
+ scene.dispose()
+ engine.dispose()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'
+import { CreateIcoSphere } from '@babylonjs/core/Meshes/Builders/icoSphereBuilder.js'
+import type { Scene } from '@babylonjs/core/scene.js'
+import { HAIR_STYLES, type Appearance } from '@openvibe/protocol'
+import { materialFor } from './sceneSetup.js'
+
+/**
+ * How NPCs look. People (wardens, townsfolk, Rustjaw) are the players' own
+ * low-poly avatar, dressed by their archetype and varied by their id, so they
+ * walk, turn and flinch like players; animals get a small faceted body. NPCs
+ * used to be a tinted box with a box head.
+ */
+
+/** A stable number in [0, 1) from an entity id and a salt. */
+function hash01(id: string, salt: number): number {
+ let h = 2166136261 ^ salt
+ for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
+ h = Math.imul(h ^ (h >>> 13), 0x5bd1e995)
+ return ((h ^ (h >>> 15)) >>> 0) / 4294967296
+}
+const pick = <T>(id: string, salt: number, options: readonly T[]): T =>
+ options[Math.floor(hash01(id, salt) * options.length)]!
+
+/** Palette indices (render/avatar/palettes.ts) each archetype dresses in. */
+const DRESS: Record<
+ string,
+ { top: number[]; bottom: number[]; shoes: number[]; build: [number, number] }
+> = {
+ // Wardens: denim or dark slate over charcoal, a little broader.
+ city_warden: { top: [7, 3], bottom: [9], shoes: [9], build: [1.05, 1.15] },
+ // Townsfolk: everyday colours.
+ townsfolk: { top: [0, 1, 4, 5, 8], bottom: [1, 2, 4, 7], shoes: [2, 4, 9], build: [0.9, 1.05] },
+ // Rustjaw: rust and charcoal, scrappy.
+ rustjaw_thug: { top: [6], bottom: [3, 9], shoes: [9], build: [1, 1.12] },
+}
+
+/** The avatar an NPC of `archetype` wears, or null for an animal. Stable per entity id. */
+export function npcAppearance(archetype: string, id: string): Appearance | null {
+ const dress = DRESS[archetype]
+ if (!dress) return null
+ const body = hash01(id, 1) < 0.5 ? 'male' : 'female'
+ return {
+ body,
+ skin: Math.floor(hash01(id, 2) * 8),
+ hairStyle:
+ archetype === 'rustjaw_thug'
+ ? pick(id, 3, ['buzz', 'bald', 'messy'] as const)
+ : pick(id, 3, HAIR_STYLES),
+ hairColor: Math.floor(hash01(id, 4) * 8),
+ facialHair:
+ body === 'male'
+ ? pick(
+ id,
+ 5,
+ archetype === 'rustjaw_thug'
+ ? (['full', 'goatee'] as const)
+ : (['none', 'none', 'mustache', 'goatee', 'full'] as const),
+ )
+ : 'none',
+ top: pick(id, 6, dress.top),
+ bottom: pick(id, 7, dress.bottom),
+ shoes: pick(id, 8, dress.shoes),
+ height: 0.95 + hash01(id, 9) * 0.1,
+ build: dress.build[0] + hash01(id, 10) * (dress.build[1] - dress.build[0]),
+ }
+}
+
+/**
+ * A small faceted animal facing +z, its feet at -halfHeight (the entity's
+ * position is its centre): body, head and ears in `color`, a pale tail.
+ */
+export function buildCritter(scene: Scene, name: string, color: string, halfHeight: number): Mesh {
+ const floor = -halfHeight
+ const body = CreateIcoSphere(`${name}:body`, { radius: 0.5, subdivisions: 1 }, scene)
+ body.scaling.set(0.26, 0.26, 0.42)
+ body.position.set(0, floor + 0.14, -0.02)
+ const head = CreateIcoSphere(`${name}:head`, { radius: 0.5, subdivisions: 1 }, scene)
+ head.scaling.set(0.17, 0.16, 0.2)
+ head.position.set(0, floor + 0.26, 0.2)
+ const ears = [-1, 1].map((side) => {
+ const ear = CreateBox(`${name}:ear`, { width: 0.045, height: 0.17, depth: 0.06 }, scene)
+ ear.position.set(side * 0.04, floor + 0.4, 0.15)
+ ear.rotation.set(-0.35, 0, side * 0.18)
+ return ear
+ })
+ const merged = Mesh.MergeMeshes([body, head, ...ears], true, true) ?? body
+ merged.name = name
+ merged.convertToFlatShadedMesh()
+ merged.material = materialFor(scene, color)
+ const tail = CreateIcoSphere(`${name}:tail`, { radius: 0.05, subdivisions: 1 }, scene)
+ tail.position.set(0, floor + 0.17, -0.22)
+ tail.material = materialFor(scene, '#ece6da')
+ tail.parent = merged
+ return merged
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/render/npcLooks.test.ts'
+// covers: 'apps/client/src/render'
+// Generated by node test/run.js --index; do not edit.