Problem: the first-person viewmodel shares the world camera and near plane, its origin is module-level state, and there are no decals, textured particles, or hitmarkers. Verified on main: packages/shell/src/camera/GameFirstPersonCamera.tsx:16-21 (VIEWMODEL_ORIGIN, MUZZLE_TIP_LOCAL, module muzzleWorld/muzzleTracked); no DecalGeometry in packages/; packages/shell/src/vfx/ParticleField.tsx draws untextured gl_Points. Related: #1661 (ADS FOV).
Tasks, one PR each, in order. Every task needs before/after shots from bun run shoot the-robots --mode play (it has a viewmodel and a gun).
-
Viewmodel config and layer. Add viewmodel?: { fov?: number; near?: number; far?: number; origin?: [x, y, z]; muzzle?: [x, y, z]; sway?: number; bob?: number } to FirstPersonCameraConfig in packages/core/src/game/cameraConfig.ts. In GameFirstPersonCamera.tsx: put the viewmodel on its own THREE.Layers bit, render it in a second pass with its own PerspectiveCamera (fov/near/far from config, cleared depth) via an onAfterRender/useFrame at post priority that plays with PostProcessing.tsx (render the viewmodel after the post chain's render pass but before SMAA: add a small extraPasses hook in PostProcessing.tsx rather than restructuring it). Replace the module-level origin, muzzle, and tracked flag with a per-rig object exposed through ctx.camera.viewmodel() ({ muzzleWorld(): [x,y,z] }). Shots: the gun against a wall, no clipping.
-
Decals (core + shell). New packages/core/src/vfx/decals.ts: DecalSpec { position; normal; size; url; ttl; atlasFrame?; rotation? }, createDecalPool({ capacity }) with add(spec, nowMs), active(nowMs), snapshot/restore/retune; expose ctx.decals. New packages/shell/src/vfx/DecalProjector.tsx: one InstancedMesh of quads oriented to the normal with a small offset and polygonOffset, textured from the URL (atlas frame UVs), fading over ttl. Tests for the pool. Shots: bullet holes on a shack wall.
-
Textured particles. Extend EmitterConfig in packages/core/src/vfx/particles.ts with sprite?: { url; columns; rows; fps }, stretch?: number, softDepth?: number, rotation?: { min; max; speed }. In ParticleField.tsx switch to an instanced quad ShaderMaterial when sprite is set (billboard, velocity stretch, sheet animation by particle age, soft depth using the depth texture the post chain already has). Shots: a textured muzzle flash and smoke.
-
Impact feedback. In packages/core/src/combat/ add impactPresets entries for muzzleFlash and bulletImpact composed from tasks 2 and 3 and fired from the existing weaponFire/hit events; Hitmarker block in packages/react/src/hitmarker.tsx keyed on hit and kill events with a HudTheme-styled cross; aim-punch and a recoil pattern (recoil?: { pattern: [x, y][]; recoverMs }) on the camera shake channel in cameraConfig.ts. Clip via bun run drive the-robots with a scripted fire sequence (declare a capture.views entry for it). Closes #1681.
Rules: wait for bun run agent:bootstrap --check; branch claude/<slug> off origin/main; claim comment first; one task per PR with Refs #1681; bun run gen after export changes (JSDoc on every export; create* factories need snapshot/restore, no module-level mutable state); CHANGELOG bullet; check-types and test on core, shell, react as touched plus check-stateful-ratchet, check-doc-symbols, check-orphan-ratchet; merge origin/main before pushing; shots via bun run games:clone, bun run shoot daemon start, bun run shoot the-robots --mode play --size half --out shots/x.png, before shot from stashed origin/main shell code, bun run pr-shots --dir <branch> shots/*.png, clips via bun run pr-video; squash auto-merge; fix CI on the same branch.
Problem: the first-person viewmodel shares the world camera and near plane, its origin is module-level state, and there are no decals, textured particles, or hitmarkers. Verified on main:
packages/shell/src/camera/GameFirstPersonCamera.tsx:16-21(VIEWMODEL_ORIGIN,MUZZLE_TIP_LOCAL, modulemuzzleWorld/muzzleTracked); noDecalGeometryinpackages/;packages/shell/src/vfx/ParticleField.tsxdraws untexturedgl_Points. Related: #1661 (ADS FOV).Tasks, one PR each, in order. Every task needs before/after shots from
bun run shoot the-robots --mode play(it has a viewmodel and a gun).Viewmodel config and layer. Add
viewmodel?: { fov?: number; near?: number; far?: number; origin?: [x, y, z]; muzzle?: [x, y, z]; sway?: number; bob?: number }toFirstPersonCameraConfiginpackages/core/src/game/cameraConfig.ts. InGameFirstPersonCamera.tsx: put the viewmodel on its ownTHREE.Layersbit, render it in a second pass with its ownPerspectiveCamera(fov/near/far from config, cleared depth) via anonAfterRender/useFrameat post priority that plays withPostProcessing.tsx(render the viewmodel after the post chain's render pass but before SMAA: add a smallextraPasseshook inPostProcessing.tsxrather than restructuring it). Replace the module-level origin, muzzle, and tracked flag with a per-rig object exposed throughctx.camera.viewmodel()({ muzzleWorld(): [x,y,z] }). Shots: the gun against a wall, no clipping.Decals (core + shell). New
packages/core/src/vfx/decals.ts:DecalSpec { position; normal; size; url; ttl; atlasFrame?; rotation? },createDecalPool({ capacity })withadd(spec, nowMs),active(nowMs), snapshot/restore/retune; exposectx.decals. Newpackages/shell/src/vfx/DecalProjector.tsx: oneInstancedMeshof quads oriented to the normal with a small offset andpolygonOffset, textured from the URL (atlas frame UVs), fading overttl. Tests for the pool. Shots: bullet holes on a shack wall.Textured particles. Extend
EmitterConfiginpackages/core/src/vfx/particles.tswithsprite?: { url; columns; rows; fps },stretch?: number,softDepth?: number,rotation?: { min; max; speed }. InParticleField.tsxswitch to an instanced quadShaderMaterialwhenspriteis set (billboard, velocity stretch, sheet animation by particle age, soft depth using the depth texture the post chain already has). Shots: a textured muzzle flash and smoke.Impact feedback. In
packages/core/src/combat/addimpactPresetsentries formuzzleFlashandbulletImpactcomposed from tasks 2 and 3 and fired from the existingweaponFire/hit events;Hitmarkerblock inpackages/react/src/hitmarker.tsxkeyed on hit and kill events with aHudTheme-styled cross; aim-punch and a recoil pattern (recoil?: { pattern: [x, y][]; recoverMs }) on the camera shake channel incameraConfig.ts. Clip viabun run drive the-robotswith a scripted fire sequence (declare acapture.viewsentry for it).Closes #1681.Rules: wait for
bun run agent:bootstrap --check; branchclaude/<slug>offorigin/main; claim comment first; one task per PR withRefs #1681;bun run genafter export changes (JSDoc on every export;create*factories need snapshot/restore, no module-level mutable state); CHANGELOG bullet;check-typesandteston core, shell, react as touched pluscheck-stateful-ratchet,check-doc-symbols,check-orphan-ratchet; mergeorigin/mainbefore pushing; shots viabun run games:clone,bun run shoot daemon start,bun run shoot the-robots --mode play --size half --out shots/x.png, before shot from stashedorigin/mainshell code,bun run pr-shots --dir <branch> shots/*.png, clips viabun run pr-video; squash auto-merge; fix CI on the same branch.