openvibers/openvibe.games Commits

Scraplandia: something flies off a hit, wood chips off a tree, grit off a rock, sparks off scrap, leaves off a bush (a swing changed nothing on screen until the server moved a count), never toward the camera (#114)

OP OpenVibers committed 24 days ago · 505eb2caa2 · 5 files, +301 −2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d09ff62..f012b01 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -27,6 +27,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **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
++ point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.
- **Sound.** Scraplandia had none. Every sound is synthesised in the browser, so there are no files to load:
- footsteps on grass, stone and water;
- a jump and a landing as hard as the fall;
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex e789267..c048237 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -21,6 +21,7 @@ import { Scene } from '@babylonjs/core/scene.js'
import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
++import { HitChips } from './render/hitChips.js'
import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
@@@ -352,6 +353,7 @@ async function start(): Promise<void> {
// Sounds. Browsers start audio only after a gesture: the first click or key.
const sfx = new Sfx()
++ const chips = new HitChips(scene)
const unlockAudio = (): void => sfx.unlock()
window.addEventListener('pointerdown', unlockAudio)
window.addEventListener('keydown', unlockAudio)
@@@ -368,8 +370,12 @@ async function start(): Promise<void> {
: target.def && content.item(target.def)?.health
? 'wood'
: null
-- // The blow lands mid-swing, after the whoosh starts.
-- if (material) setTimeout(() => sfx.hit(material), 90)
++ // The blow lands mid-swing, after the whoosh starts: its sound, and what flies off.
++ if (material)
++ setTimeout(() => {
++ sfx.hit(material)
++ chips.burst(target.point, target.normal, material, player.eye)
++ }, 90)
}
let packCount = -1
state.events.on('inventory', () => {
@@@ -646,6 +652,7 @@ async function start(): Promise<void> {
}
wasGrounded = grounded
sfx.update(elapsed, environment.daylight)
++ chips.update(elapsed)
// Beams (modular: any weapon whose module declares firesBeam gets this
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
ddiff --git a/apps/client/src/render/hitChips.test.ts b/apps/client/src/render/hitChips.test.ts
nnew file mode 100644
iindex 0000000..0bc1d71
---- /dev/null
++++ b/apps/client/src/render/hitChips.test.ts
@@@ -0,0 +1,72 @@
++/**
++ * Hit particles live in two thin-instanced meshes. What must hold: a hit
++ * throws its burst, the burst falls and is gone within a second, an empty
++ * pool draws nothing (a mesh with no thin instances would draw itself, a
++ * metre cube at the origin), and a hit on a person throws nothing.
++ */
++import { afterEach, beforeEach, describe, expect, it } from 'vitest'
++import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
++import { Scene } from '@babylonjs/core/scene.js'
++import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
++import { HitChips } from './hitChips.js'
++
++let engine: NullEngine
++let scene: Scene
++beforeEach(() => {
++ engine = new NullEngine()
++ scene = new Scene(engine)
++})
++afterEach(() => {
++ scene.dispose()
++ engine.dispose()
++})
++const mesh = (name: string) => scene.getMeshByName(name) as Mesh
++const step = (chips: HitChips, seconds: number) => {
++ for (let t = 0; t < seconds; t += 1 / 60) chips.update(1 / 60)
++}
++
++describe('HitChips', () => {
++ it('draws nothing until something is hit', () => {
++ const chips = new HitChips(scene)
++ chips.update(1 / 60)
++ for (const name of ['fx:chips', 'fx:sparks']) {
++ expect(mesh(name).thinInstanceCount).toBe(0)
++ expect(mesh(name).isVisible).toBe(false)
++ }
++ })
++
++ it('throws wood chips off a tree that fall and are gone within a second', () => {
++ const chips = new HitChips(scene)
++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: -1 }, 'wood')
++ chips.update(1 / 60)
++ const m = mesh('fx:chips')
++ expect(m.thinInstanceCount).toBe(9)
++ expect(m.isVisible).toBe(true)
++ step(chips, 1.2)
++ expect(m.thinInstanceCount).toBe(0)
++ expect(m.isVisible).toBe(false)
++ })
++
++ it('throws sparks, not chips, off scrap', () => {
++ const chips = new HitChips(scene)
++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'metal')
++ chips.update(1 / 60)
++ expect(mesh('fx:sparks').thinInstanceCount).toBe(10)
++ expect(mesh('fx:chips').thinInstanceCount).toBe(0)
++ })
++
++ it('throws nothing off a person', () => {
++ const chips = new HitChips(scene)
++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'flesh')
++ chips.update(1 / 60)
++ expect(mesh('fx:chips').thinInstanceCount).toBe(0)
++ expect(mesh('fx:sparks').thinInstanceCount).toBe(0)
++ })
++
++ it('keeps at most 96 in flight, dropping the oldest', () => {
++ const chips = new HitChips(scene)
++ for (let i = 0; i < 20; i++) chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }, 'wood')
++ chips.update(1 / 60)
++ expect(mesh('fx:chips').thinInstanceCount).toBe(96)
++ })
++})
ddiff --git a/apps/client/src/render/hitChips.ts b/apps/client/src/render/hitChips.ts
nnew file mode 100644
iindex 0000000..7970705
---- /dev/null
++++ b/apps/client/src/render/hitChips.ts
@@@ -0,0 +1,214 @@
++import '@babylonjs/core/Meshes/thinInstanceMesh.js' // thinInstanceSetBuffer() needs its side effect
++import { Color3 } from '@babylonjs/core/Maths/math.color.js'
++import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'
++import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
++import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'
++import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
++import type { Scene } from '@babylonjs/core/scene.js'
++import type { HitMaterial } from '../audio/sfx.js'
++
++/**
++ * What flies off a hit: wood chips off a tree, grit off a rock, sparks off
++ * scrap, leaves off a bush. A hit used to change nothing on screen until the
++ * server's reply moved a count, so a swing felt like it went through.
++ *
++ * Two thin-instanced meshes (lit chips, unlit sparks) hold every particle:
++ * two draw calls however many are flying, nothing allocated per hit.
++ */
++
++const MAX = 96
++const GRAVITY = 12
++
++interface Particle {
++ pos: Vector3
++ vel: Vector3
++ axis: Vector3
++ angle: number
++ spin: number
++ size: number
++ age: number
++ life: number
++ gravity: number
++ color: [number, number, number]
++}
++
++interface Kind {
++ count: number
++ colors: string[]
++ spark?: boolean
++ speed: number
++ size: number
++ life: number
++ gravity: number
++}
++
++const KINDS: Record<Exclude<HitMaterial, 'flesh'>, Kind> = {
++ wood: {
++ count: 9,
++ colors: ['#8a5a2b', '#a87437', '#6d4c2a', '#c49a62'],
++ speed: 2,
++ size: 0.045,
++ life: 0.7,
++ gravity: 1,
++ },
++ stone: {
++ count: 8,
++ colors: ['#8d8d88', '#a3a39e', '#6f6f6a'],
++ speed: 2.3,
++ size: 0.035,
++ life: 0.55,
++ gravity: 1,
++ },
++ metal: {
++ count: 10,
++ colors: ['#ffd27a'],
++ spark: true,
++ speed: 4.5,
++ size: 0.025,
++ life: 0.35,
++ gravity: 0.6,
++ },
++ plant: {
++ count: 6,
++ colors: ['#4a7a3a', '#5d8f3f', '#7d9a45'],
++ speed: 1.2,
++ size: 0.045,
++ life: 0.9,
++ gravity: 0.3,
++ },
++}
++
++export class HitChips {
++ private readonly chips: Pool
++ private readonly sparks: Pool
++
++ constructor(scene: Scene) {
++ const chipMat = new StandardMaterial('fx:chips', scene)
++ chipMat.diffuseColor = Color3.White()
++ chipMat.specularColor = new Color3(0.05, 0.05, 0.05)
++ const sparkMat = new StandardMaterial('fx:sparks', scene)
++ sparkMat.disableLighting = true
++ sparkMat.emissiveColor = new Color3(1, 0.78, 0.35)
++ this.chips = new Pool(scene, 'fx:chips', chipMat, true)
++ this.sparks = new Pool(scene, 'fx:sparks', sparkMat, false)
++ }
++
++ /**
++ * Throws a burst off `point`, out to the sides and up from the surface
++ * `normal`. Nothing flies toward `eye` (the camera), and a hit right in
++ * front of it throws smaller pieces. Hits on people throw nothing.
++ */
++ burst(point: Vector3Like, normal: Vector3Like, material: HitMaterial, eye?: Vector3Like): void {
++ if (material === 'flesh') return
++ const kind = KINDS[material]
++ const pool = kind.spark ? this.sparks : this.chips
++ const n = new Vector3(normal.x, normal.y, normal.z)
++ if (n.lengthSquared() < 1e-6) n.set(0, 1, 0)
++ n.normalize()
++ const toEye = eye ? new Vector3(eye.x - point.x, eye.y - point.y, eye.z - point.z) : null
++ const near = toEye ? Math.min(1, toEye.length() / 1.5) : 1
++ toEye?.normalize()
++ for (let i = 0; i < kind.count; i++) {
++ const hex = kind.colors[Math.floor(Math.random() * kind.colors.length)]!
++ const c = Color3.FromHexString(hex)
++ const spread = new Vector3(
++ Math.random() - 0.5,
++ Math.random() * 0.8,
++ Math.random() - 0.5,
++ ).scaleInPlace(2)
++ const speed = kind.speed * (0.6 + Math.random() * 0.6)
++ const vel = n
++ .scale(0.45)
++ .addInPlace(spread)
++ .normalize()
++ .scaleInPlace(speed)
++ .addInPlace(new Vector3(0, 1.2, 0))
++ if (toEye) {
++ const toward = Vector3.Dot(vel, toEye)
++ if (toward > 0) vel.subtractInPlace(toEye.scale(toward))
++ }
++ pool.add({
++ pos: new Vector3(point.x, point.y, point.z).addInPlace(n.scale(0.05)),
++ vel,
++ axis: new Vector3(
++ Math.random() - 0.5,
++ Math.random() - 0.5,
++ Math.random() - 0.5,
++ ).normalize(),
++ angle: Math.random() * Math.PI,
++ spin: (Math.random() - 0.5) * 18,
++ size: kind.size * (0.6 + Math.random() * 0.8) * Math.max(0.35, near),
++ age: 0,
++ life: kind.life * (0.7 + Math.random() * 0.6),
++ gravity: GRAVITY * kind.gravity,
++ color: [c.r, c.g, c.b],
++ })
++ }
++ }
++
++ update(dt: number): void {
++ this.chips.update(dt)
++ this.sparks.update(dt)
++ }
++}
++
++interface Vector3Like {
++ x: number
++ y: number
++ z: number
++}
++
++/** One thin-instanced cube and the particles it draws, the live ones packed at the front. */
++class Pool {
++ private readonly mesh: Mesh
++ private readonly live: Particle[] = []
++ private readonly matrices = new Float32Array(MAX * 16)
++ private readonly colors: Float32Array | null
++ private readonly m = new Matrix()
++ private readonly q = new Quaternion()
++ private readonly s = new Vector3()
++
++ constructor(scene: Scene, name: string, material: StandardMaterial, colored: boolean) {
++ this.mesh = CreateBox(name, { size: 1 }, scene)
++ this.mesh.material = material
++ this.mesh.isPickable = false
++ this.mesh.alwaysSelectAsActiveMesh = true // particles fly outside the unit box's bounds
++ this.mesh.thinInstanceSetBuffer('matrix', this.matrices, 16, false)
++ this.colors = colored ? new Float32Array(MAX * 4).fill(1) : null
++ if (this.colors) this.mesh.thinInstanceSetBuffer('color', this.colors, 4, false)
++ this.mesh.thinInstanceCount = 0
++ // With no thin instances a mesh draws itself: a metre cube at the origin.
++ this.mesh.isVisible = false
++ }
++
++ add(p: Particle): void {
++ if (this.live.length >= MAX) this.live.shift()
++ this.live.push(p)
++ }
++
++ update(dt: number): void {
++ if (this.live.length === 0 && this.mesh.thinInstanceCount === 0) return
++ let w = 0
++ for (const p of this.live) {
++ p.age += dt
++ if (p.age >= p.life) continue
++ p.vel.y -= p.gravity * dt
++ p.pos.addInPlaceFromFloats(p.vel.x * dt, p.vel.y * dt, p.vel.z * dt)
++ p.angle += p.spin * dt
++ // Shrink away over the last third of the life.
++ const left = 1 - p.age / p.life
++ const size = p.size * Math.min(1, left * 3)
++ Quaternion.RotationAxisToRef(p.axis, p.angle, this.q)
++ this.s.setAll(size)
++ Matrix.ComposeToRef(this.s, this.q, p.pos, this.m)
++ this.m.copyToArray(this.matrices, w * 16)
++ if (this.colors) this.colors.set([p.color[0], p.color[1], p.color[2], 1], w * 4)
++ this.live[w++] = p
++ }
++ this.live.length = w
++ this.mesh.thinInstanceCount = w
++ this.mesh.isVisible = w > 0
++ this.mesh.thinInstanceBufferUpdated('matrix')
++ if (this.colors) this.mesh.thinInstanceBufferUpdated('color')
++ }
++}
ddiff --git a/test/apps__client__src__render__hitChips.test.js b/test/apps__client__src__render__hitChips.test.js
nnew file mode 100644
iindex 0000000..998e5e2
---- /dev/null
++++ b/test/apps__client__src__render__hitChips.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/render/hitChips.test.ts'
++// covers: 'apps/client/src/render'
++// Generated by node test/run.js --index; do not edit.