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)

OP OpenVibers committed 24 days ago · 2bcc8738f4 · 5 files, +182 −17

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 53871ef..01a76d4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 NPCs
replaces the content's seven spawns, which were laid out for the procedural world and stood Rustjaw thugs just
ddiff --git a/apps/client/src/render/entityView.ts b/apps/client/src/render/entityView.ts
iindex e8c2581..50ef038 100644
---- a/apps/client/src/render/entityView.ts
++++ b/apps/client/src/render/entityView.ts
@@@ -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 = null
let avatar: Avatar | null = null
++ let feetOffset: number | null = null
let bodyId: BodyId | null = null
if (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.7
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({
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.pos
v.entity.rot = b.rot
if (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 ?? 0
if (stance !== v.bodyStance) {
this.entityByBody.delete(v.bodyId)
ddiff --git a/apps/client/src/render/npcLooks.test.ts b/apps/client/src/render/npcLooks.test.ts
nnew file mode 100644
iindex 0000000..c977e25
---- /dev/null
++++ b/apps/client/src/render/npcLooks.test.ts
@@@ -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()
++ })
++})
ddiff --git a/apps/client/src/render/npcLooks.ts b/apps/client/src/render/npcLooks.ts
nnew file mode 100644
iindex 0000000..b301fed
---- /dev/null
++++ b/apps/client/src/render/npcLooks.ts
@@@ -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
++}
ddiff --git a/test/apps__client__src__render__npcLooks.test.js b/test/apps__client__src__render__npcLooks.test.js
nnew file mode 100644
iindex 0000000..985fb35
---- /dev/null
++++ b/test/apps__client__src__render__npcLooks.test.js
@@@ -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.