Lane B (Paint): splat pipeline, coverage compute, buff/threshold framework

The core invention: paint lands on the blob body where projectiles hit, live
per-colour coverage % is computed from a quantized UV mask, and coverage drives
buffs per GDD §6 MVP subset (RED speed+embers, GREEN grip, BLUE waterproof,
total→mass, 20% activate / 70% super+glow).

- src/paint/skin.ts       PaintSkin: Canvas-2D mask on mesh UVs → CanvasTexture;
                          splatAtPoint raycasts impact→face UV; dual soft-canvas
                          + hard quantized-index mask so coverage is exact
                          integer bucketing (never getImageData); 250ms cache;
                          scrub-hole cleanse; U-seam wrap.
- src/paint/coverage-math.ts  pure, unit-tested: countBuckets, coverage report,
                          buffStrength, computeModifiers.
- src/paint/cannon.ts     PaintCannon: gravity-arc glob projectiles (manual
                          swept sphere test), target-leading ballistic solve,
                          fires on interval AND machine:signal; emits paint:splatted.
- src/paint/buffs.ts      BuffSystem: coverage→modifiers each step + ember trail
                          (additive Points) + super-state emissive pulse.
- src/paint/hud.ts        PaintHUD: per-colour bars + active buff kit (throttled).
- src/paint/index.ts      exports + installPaint() wiring the inbound Lane C
                          protocol (request-splat proximity-gated / request-cleanse).
- src/demo/lane-b.ts      stub ball orbiting a RED and GREEN cannon; HUD live;
                          debug keys for fire/signal/flood/cleanse/pause.
- src/paint/coverage-math.test.ts  32 assertions (node --experimental-strip-types).

build: tsc strict + vite pass. No edits outside owned paths; contracts frozen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:13:30 +10:00
parent 969f602dd4
commit eed2eb2646
22 changed files with 5609 additions and 3832 deletions

1
dist/assets/lane-b-Cm8JW7Qo.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import"./modulepreload-polyfill-B5Qt9EMX.js";console.log("lane-b demo: pending — lane agent replaces this file");

1
dist/assets/main-DezSXIyG.js vendored Normal file
View File

@ -0,0 +1 @@
import"./modulepreload-polyfill-B5Qt9EMX.js";import{d as p,k as m,M as h,c as f,j as u}from"./world-KhDGgEOv.js";function x(s){const{scene:y,physics:i,rapier:d}=s,c=new p(new m(60,1,60),new h({color:"#e8d9b8"}));c.position.y=-.5,c.receiveShadow=!0,y.add(c),i.createCollider(d.ColliderDesc.cuboid(30,.5,30).setTranslation(0,-.5,0));const o=new p(new f(.6,32,24),new h({color:"#F5F5F7",roughness:.35}));o.castShadow=!0,y.add(o);const a=i.createRigidBody(d.RigidBodyDesc.dynamic().setTranslation(0,3,0));i.createCollider(d.ColliderDesc.ball(.6).setRestitution(.4).setFriction(1),a);const e=new Set;addEventListener("keydown",n=>e.add(n.code)),addEventListener("keyup",n=>e.delete(n.code)),s.addSystem({update(){const t={x:0,z:0};(e.has("KeyW")||e.has("ArrowUp"))&&(t.z-=18),(e.has("KeyS")||e.has("ArrowDown"))&&(t.z+=18),(e.has("KeyA")||e.has("ArrowLeft"))&&(t.x-=18),(e.has("KeyD")||e.has("ArrowRight"))&&(t.x+=18),a.applyImpulse({x:t.x/60,y:0,z:t.z/60},!0);const l=a.translation();o.position.set(l.x,l.y,l.z);const r=a.rotation();o.quaternion.set(r.x,r.y,r.z,r.w)}}),s.onFrame(()=>{s.camera.lookAt(o.position)})}const w=await u(document.getElementById("app"));x(w);w.start();

File diff suppressed because one or more lines are too long

3828
dist/assets/world-KhDGgEOv.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,9 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>BLOBBO lane b demo</title>
<style>html,body{margin:0;height:100%;overflow:hidden}#app{width:100%;height:100%}</style> <script type="module" crossorigin src="/assets/lane-b-CtI0E73t.js"></script>
<style>html,body{margin:0;height:100%;overflow:hidden}#app{width:100%;height:100%}</style> <script type="module" crossorigin src="/assets/lane-b-Cm8JW7Qo.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/world-KhDGgEOv.js">
</head>
<body><div id="app"></div></body>
</html>

3
dist/index.html vendored
View File

@ -8,8 +8,9 @@
html, body { margin: 0; height: 100%; overflow: hidden; }
#app { width: 100%; height: 100%; }
</style>
<script type="module" crossorigin src="/assets/main-Dt_d16Sj.js"></script>
<script type="module" crossorigin src="/assets/main-DezSXIyG.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/world-KhDGgEOv.js">
</head>
<body>
<div id="app"></div>

15
node_modules/.package-lock.json generated vendored
View File

@ -158,6 +158,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",

22
node_modules/fsevents/LICENSE generated vendored Normal file
View File

@ -0,0 +1,22 @@
MIT License
-----------
Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

89
node_modules/fsevents/README.md generated vendored Normal file
View File

@ -0,0 +1,89 @@
# fsevents
Native access to MacOS FSEvents in [Node.js](https://nodejs.org/)
The FSEvents API in MacOS allows applications to register for notifications of
changes to a given directory tree. It is a very fast and lightweight alternative
to kqueue.
This is a low-level library. For a cross-platform file watching module that
uses fsevents, check out [Chokidar](https://github.com/paulmillr/chokidar).
## Usage
```sh
npm install fsevents
```
Supports only **Node.js v8.16 and higher**.
```js
const fsevents = require('fsevents');
// To start observation
const stop = fsevents.watch(__dirname, (path, flags, id) => {
const info = fsevents.getInfo(path, flags);
});
// To end observation
stop();
```
> **Important note:** The API behaviour is slightly different from typical JS APIs. The `stop` function **must** be
> retrieved and stored somewhere, even if you don't plan to stop the watcher. If you forget it, the garbage collector
> will eventually kick in, the watcher will be unregistered, and your callbacks won't be called anymore.
The callback passed as the second parameter to `.watch` get's called whenever the operating system detects a
a change in the file system. It takes three arguments:
###### `fsevents.watch(dirname: string, (path: string, flags: number, id: string) => void): () => Promise<undefined>`
* `path: string` - the item in the filesystem that have been changed
* `flags: number` - a numeric value describing what the change was
* `id: string` - an unique-id identifying this specific event
Returns closer callback which when called returns a Promise resolving when the watcher process has been shut down.
###### `fsevents.getInfo(path: string, flags: number, id: string): FsEventInfo`
The `getInfo` function takes the `path`, `flags` and `id` arguments and converts those parameters into a structure
that is easier to digest to determine what the change was.
The `FsEventsInfo` has the following shape:
```js
/**
* @typedef {'created'|'modified'|'deleted'|'moved'|'root-changed'|'cloned'|'unknown'} FsEventsEvent
* @typedef {'file'|'directory'|'symlink'} FsEventsType
*/
{
"event": "created", // {FsEventsEvent}
"path": "file.txt",
"type": "file", // {FsEventsType}
"changes": {
"inode": true, // Had iNode Meta-Information changed
"finder": false, // Had Finder Meta-Data changed
"access": false, // Had access permissions changed
"xattrs": false // Had xAttributes changed
},
"flags": 0x100000000
}
```
## Changelog
- v2.3 supports Apple Silicon ARM CPUs
- v2 supports node 8.16+ and reduces package size massively
- v1.2.8 supports node 6+
- v1.2.7 supports node 4+
## Troubleshooting
- I'm getting `EBADPLATFORM` `Unsupported platform for fsevents` error.
- It's fine, nothing is broken. fsevents is macos-only. Other platforms are skipped. If you want to hide this warning, report a bug to NPM bugtracker asking them to hide ebadplatform warnings by default.
## License
The MIT License Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller — see LICENSE file.
Visit our [GitHub page](https://github.com/fsevents/fsevents) and [NPM Page](https://npmjs.org/package/fsevents)

46
node_modules/fsevents/fsevents.d.ts generated vendored Normal file
View File

@ -0,0 +1,46 @@
declare type Event = "created" | "cloned" | "modified" | "deleted" | "moved" | "root-changed" | "unknown";
declare type Type = "file" | "directory" | "symlink";
declare type FileChanges = {
inode: boolean;
finder: boolean;
access: boolean;
xattrs: boolean;
};
declare type Info = {
event: Event;
path: string;
type: Type;
changes: FileChanges;
flags: number;
};
declare type WatchHandler = (path: string, flags: number, id: string) => void;
export declare function watch(path: string, handler: WatchHandler): () => Promise<void>;
export declare function watch(path: string, since: number, handler: WatchHandler): () => Promise<void>;
export declare function getInfo(path: string, flags: number): Info;
export declare const constants: {
None: 0x00000000;
MustScanSubDirs: 0x00000001;
UserDropped: 0x00000002;
KernelDropped: 0x00000004;
EventIdsWrapped: 0x00000008;
HistoryDone: 0x00000010;
RootChanged: 0x00000020;
Mount: 0x00000040;
Unmount: 0x00000080;
ItemCreated: 0x00000100;
ItemRemoved: 0x00000200;
ItemInodeMetaMod: 0x00000400;
ItemRenamed: 0x00000800;
ItemModified: 0x00001000;
ItemFinderInfoMod: 0x00002000;
ItemChangeOwner: 0x00004000;
ItemXattrMod: 0x00008000;
ItemIsFile: 0x00010000;
ItemIsDir: 0x00020000;
ItemIsSymlink: 0x00040000;
ItemIsHardlink: 0x00100000;
ItemIsLastHardlink: 0x00200000;
OwnEvent: 0x00080000;
ItemCloned: 0x00400000;
};
export {};

83
node_modules/fsevents/fsevents.js generated vendored Normal file
View File

@ -0,0 +1,83 @@
/*
** © 2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
** Licensed under MIT License.
*/
/* jshint node:true */
"use strict";
if (process.platform !== "darwin") {
throw new Error(`Module 'fsevents' is not compatible with platform '${process.platform}'`);
}
const Native = require("./fsevents.node");
const events = Native.constants;
function watch(path, since, handler) {
if (typeof path !== "string") {
throw new TypeError(`fsevents argument 1 must be a string and not a ${typeof path}`);
}
if ("function" === typeof since && "undefined" === typeof handler) {
handler = since;
since = Native.flags.SinceNow;
}
if (typeof since !== "number") {
throw new TypeError(`fsevents argument 2 must be a number and not a ${typeof since}`);
}
if (typeof handler !== "function") {
throw new TypeError(`fsevents argument 3 must be a function and not a ${typeof handler}`);
}
let instance = Native.start(Native.global, path, since, handler);
if (!instance) throw new Error(`could not watch: ${path}`);
return () => {
const result = instance ? Promise.resolve(instance).then(Native.stop) : Promise.resolve(undefined);
instance = undefined;
return result;
};
}
function getInfo(path, flags) {
return {
path,
flags,
event: getEventType(flags),
type: getFileType(flags),
changes: getFileChanges(flags),
};
}
function getFileType(flags) {
if (events.ItemIsFile & flags) return "file";
if (events.ItemIsDir & flags) return "directory";
if (events.MustScanSubDirs & flags) return "directory";
if (events.ItemIsSymlink & flags) return "symlink";
}
function anyIsTrue(obj) {
for (let key in obj) {
if (obj[key]) return true;
}
return false;
}
function getEventType(flags) {
if (events.ItemRemoved & flags) return "deleted";
if (events.ItemRenamed & flags) return "moved";
if (events.ItemCreated & flags) return "created";
if (events.ItemModified & flags) return "modified";
if (events.RootChanged & flags) return "root-changed";
if (events.ItemCloned & flags) return "cloned";
if (anyIsTrue(flags)) return "modified";
return "unknown";
}
function getFileChanges(flags) {
return {
inode: !!(events.ItemInodeMetaMod & flags),
finder: !!(events.ItemFinderInfoMod & flags),
access: !!(events.ItemChangeOwner & flags),
xattrs: !!(events.ItemXattrMod & flags),
};
}
exports.watch = watch;
exports.getInfo = getInfo;
exports.constants = events;

BIN
node_modules/fsevents/fsevents.node generated vendored Executable file

Binary file not shown.

62
node_modules/fsevents/package.json generated vendored Normal file
View File

@ -0,0 +1,62 @@
{
"name": "fsevents",
"version": "2.3.3",
"description": "Native Access to MacOS FSEvents",
"main": "fsevents.js",
"types": "fsevents.d.ts",
"os": [
"darwin"
],
"files": [
"fsevents.d.ts",
"fsevents.js",
"fsevents.node"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
},
"scripts": {
"clean": "node-gyp clean && rm -f fsevents.node",
"build": "node-gyp clean && rm -f fsevents.node && node-gyp rebuild && node-gyp clean",
"test": "/bin/bash ./test.sh 2>/dev/null",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "https://github.com/fsevents/fsevents.git"
},
"keywords": [
"fsevents",
"mac"
],
"contributors": [
{
"name": "Philipp Dunkel",
"email": "pip@pipobscure.com"
},
{
"name": "Ben Noordhuis",
"email": "info@bnoordhuis.nl"
},
{
"name": "Elan Shankar",
"email": "elan.shanker@gmail.com"
},
{
"name": "Miroslav Bajtoš",
"email": "mbajtoss@gmail.com"
},
{
"name": "Paul Miller",
"url": "https://paulmillr.com"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/fsevents/fsevents/issues"
},
"homepage": "https://github.com/fsevents/fsevents",
"devDependencies": {
"node-gyp": "^9.4.0"
}
}

View File

@ -1 +1,262 @@
console.log("lane-b demo: pending — lane agent replaces this file")
/**
* Lane B demo the paint core, standalone. A stub white test ball (NOT Lane A's
* blob) orbits between a RED and a GREEN cannon. Paint lands where each glob
* hits, coverage drives buffs (speed/embers/grip/waterproof/mass/glow), and the
* HUD reads it live. Cleanse scrubs it off.
*
* Everything Lane B ships is exercised here: PaintSkin, PaintCannon, BuffSystem,
* PaintHUD, and the inbound event protocol via installPaint().
*
* DEBUG KEYS
* 1 / 2 fire the RED / GREEN cannon at the ball right now
* B / N emit machine:signal for the RED / GREEN cannon's trigger id
* 9 / 0 flood ~80% RED / GREEN (fast path to the super state)
* G emit paint:request-splat on the ball (simulates a Lane C bucket dump)
* K emit paint:request-cleanse {0.5} (simulates a Lane C bubble arch)
* C cleanse 50% (scrub holes) X cleanse 100% (full clean)
* P pause / resume the ball's orbit (watch the paint sit)
* H toggle the HUD
* A state line prints to the console every second: coverage %s, buffs, splats/s.
*/
import * as THREE from 'three'
import { createWorld } from '../world'
import { defaultModifiers, PALETTE } from '../contracts'
import type { PaintColor } from '../contracts'
import {
BuffSystem,
PaintCannon,
PaintHUD,
PaintSkin,
installPaint,
COLOR_ORDER,
} from '../paint'
const RED_SIGNAL = 'sig-red'
const GREEN_SIGNAL = 'sig-green'
const ORBIT_R = 3.6
const BALL_R = 0.6
const world = await createWorld(document.getElementById('app')!)
const { scene, camera, physics, rapier, events } = world
camera.position.set(0, 7.5, 12)
// ---- floor ----------------------------------------------------------------
const floor = new THREE.Mesh(
new THREE.BoxGeometry(40, 1, 40),
new THREE.MeshStandardMaterial({ color: '#e8d9b8' }),
)
floor.position.y = -0.5
floor.receiveShadow = true
scene.add(floor)
physics.createCollider(
rapier.ColliderDesc.cuboid(20, 0.5, 20).setTranslation(0, -0.5, 0).setFriction(1.2),
)
// ---- stub test ball (our own — NOT Lane A's blob) -------------------------
const ballMesh = new THREE.Mesh(
new THREE.SphereGeometry(BALL_R, 48, 32),
new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.35, metalness: 0 }),
)
ballMesh.castShadow = true
scene.add(ballMesh)
const ballBody = physics.createRigidBody(
rapier.RigidBodyDesc.dynamic().setTranslation(ORBIT_R, BALL_R + 0.2, 0).setAngularDamping(0.4),
)
physics.createCollider(
rapier.ColliderDesc.ball(BALL_R).setRestitution(0.2).setFriction(1.4).setDensity(2),
ballBody,
)
const baseMass = ballBody.mass()
// the blob-like target the paint systems operate on
const blob = {
mesh: ballMesh,
body: ballBody,
modifiers: defaultModifiers(),
paint: new PaintSkin(ballMesh, { size: 256 }),
}
// ---- paint systems --------------------------------------------------------
const hud = new PaintHUD()
const redCannon = new PaintCannon({
world,
position: new THREE.Vector3(-7, 0, -1.5),
color: 'red',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: RED_SIGNAL,
interval: 0.9,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const greenCannon = new PaintCannon({
world,
position: new THREE.Vector3(7, 0, 1.5),
color: 'green',
target: ballMesh,
paint: blob.paint,
targetRadius: BALL_R,
triggerId: GREEN_SIGNAL,
interval: 1.0,
muzzleSpeed: 15,
splatRadius: 0.3,
})
const buffs = new BuffSystem(world, blob)
// inbound Lane C protocol → this blob (proximity-gated splats + cleanse)
installPaint(world, { mesh: ballMesh, paint: blob.paint })
world.addSystem(redCannon)
world.addSystem(greenCannon)
world.addSystem(buffs)
// ---- ball orbit controller (reads modifiers so buffs visibly change it) ---
let paused = false
world.addSystem({
update(dt: number) {
// paint = weight: reflect massMul in the physics body
ballBody.setAdditionalMass(Math.max(0, baseMass * (blob.modifiers.massMul - 1)), true)
const p = ballBody.translation()
if (!paused) {
// chase a point a little ahead on the ring; force-based so it rolls
const cur = Math.atan2(p.z, p.x)
const targetAngle = cur + 0.6
const tx = Math.cos(targetAngle) * ORBIT_R
const tz = Math.sin(targetAngle) * ORBIT_R
const dx = tx - p.x
const dz = tz - p.z
const len = Math.hypot(dx, dz) || 1
const push = 9 * blob.modifiers.speedMul // BURN makes it faster
ballBody.applyImpulse({ x: (dx / len) * push * dt, y: 0, z: (dz / len) * push * dt }, true)
// cap horizontal speed (scaled by speed buff)
const v = ballBody.linvel()
const sp = Math.hypot(v.x, v.z)
const maxSp = 4.2 * blob.modifiers.speedMul
if (sp > maxSp) {
const k = maxSp / sp
ballBody.setLinvel({ x: v.x * k, y: v.y, z: v.z * k }, true)
}
}
// sync mesh to body (rotation included → paint rides the spinning body)
ballMesh.position.set(p.x, p.y, p.z)
const q = ballBody.rotation()
ballMesh.quaternion.set(q.x, q.y, q.z, q.w)
},
})
// ---- camera ---------------------------------------------------------------
world.onFrame(() => {
camera.lookAt(ballMesh.position.x * 0.3, 0.5, ballMesh.position.z * 0.3)
})
// ---- debug keys -----------------------------------------------------------
function floodColor(color: PaintColor, splats = 130) {
for (let i = 0; i < splats; i++) {
blob.paint.applySplat(
new THREE.Vector2(Math.random(), 0.1 + Math.random() * 0.8),
color,
0.55,
)
}
console.log(`[flood] ${color}${splats} splats`)
}
addEventListener('keydown', (e) => {
switch (e.code) {
case 'Digit1':
redCannon.fire()
break
case 'Digit2':
greenCannon.fire()
break
case 'KeyB':
events.emit('machine:signal', { id: RED_SIGNAL })
console.log('[signal] machine:signal', RED_SIGNAL)
break
case 'KeyN':
events.emit('machine:signal', { id: GREEN_SIGNAL })
console.log('[signal] machine:signal', GREEN_SIGNAL)
break
case 'Digit9':
floodColor('red')
break
case 'Digit0':
floodColor('green')
break
case 'KeyG': {
// simulate a Lane C bucket dump (BLUE) landing on the ball
const pos = ballMesh.position.clone().add(new THREE.Vector3(0, BALL_R, 0))
events.emit('paint:request-splat', { point: pos, color: 'blue' as PaintColor, radius: 0.35 })
console.log('[request-splat] blue on ball')
break
}
case 'KeyK':
events.emit('paint:request-cleanse', { fraction: 0.5 })
console.log('[request-cleanse] 0.5')
break
case 'KeyC':
blob.paint.cleanse(0.5)
console.log('[cleanse] 0.5 (scrub)')
break
case 'KeyX':
blob.paint.cleanse(1)
console.log('[cleanse] full')
break
case 'KeyP':
paused = !paused
console.log(`[orbit] ${paused ? 'paused' : 'running'}`)
break
case 'KeyH':
hud.root.style.display = hud.root.style.display === 'none' ? '' : 'none'
break
}
})
// ---- HUD + console state line ---------------------------------------------
let splatCount = 0
events.on('paint:splatted', (p: { color: PaintColor; target: string }) => {
if (p.target === 'blob') splatCount++
})
let lastLog = performance.now()
world.onFrame(() => {
const cov = blob.paint.coverage()
hud.update(cov, blob.modifiers)
const now = performance.now()
if (now - lastLog >= 1000) {
lastLog = now
const pcts = COLOR_ORDER.filter((c) => cov.byColor[c] > 0.005)
.map((c) => `${c}:${(cov.byColor[c] * 100).toFixed(0)}%`)
.join(' ')
const m = blob.modifiers
const buffStr = [
m.speedMul > 1.001 ? `speed×${m.speedMul.toFixed(2)}` : '',
m.grip > 0.001 ? `grip${m.grip.toFixed(2)}` : '',
m.waterproof ? 'waterproof' : '',
`mass×${m.massMul.toFixed(2)}`,
m.glow > 0 ? 'SUPER' : '',
].filter(Boolean).join(' ')
console.log(
`[paint] total ${(cov.total * 100).toFixed(0)}% | ${pcts || '(clean)'} | ${buffStr} | +${splatCount} splats/s`,
)
splatCount = 0
}
})
// friendly banner
console.log(
'%cBLOBBO Lane B — Paint demo',
`color:${PALETTE.red};font-weight:700`,
)
console.log('keys: 1/2 fire B/N signal 9/0 flood G request-splat K request-cleanse C/X cleanse P pause H hud')
world.start()

159
src/paint/buffs.ts Normal file
View File

@ -0,0 +1,159 @@
/**
* BuffSystem every fixed step, reads the blob's coverage() and writes
* blob.modifiers per GDD §6 (MVP subset), then drives the two cheap visual
* side-effects that make the buff READ on the body: an ember trail while the RED
* BURN buff is active, and an emissive glow pulse in the super state.
*
* The number-crunching lives in computeModifiers() (coverage-math.ts, pure &
* unit-tested); this System just applies it and paints the juice.
*/
import * as THREE from 'three'
import type { BlobModifiers, System, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
import {
ACTIVATE,
buffStrength,
computeModifiers,
dominantColor,
superColor,
} from './coverage-math'
export interface BuffTarget {
mesh: THREE.Mesh
modifiers: BlobModifiers
paint?: PaintSkin
}
const EMBER_COUNT = 90
export class BuffSystem implements System {
private readonly scene: THREE.Scene
private readonly blob: BuffTarget
// ember pool
private readonly embers: THREE.Points
private readonly emberPos: Float32Array
private readonly emberCol: Float32Array
private readonly emberVel: Float32Array
private readonly emberLife: Float32Array
private emberCursor = 0
private readonly emberHex = new THREE.Color()
private readonly _spawnAt = new THREE.Vector3()
private clock = 0
constructor(world: { scene: THREE.Scene }, blob: BuffTarget) {
this.scene = world.scene
this.blob = blob
// ember tint: red→orange blend, computed once
this.emberHex.set(PALETTE.red).lerp(new THREE.Color(PALETTE.orange), 0.5)
this.emberPos = new Float32Array(EMBER_COUNT * 3)
this.emberCol = new Float32Array(EMBER_COUNT * 3)
this.emberVel = new Float32Array(EMBER_COUNT * 3)
this.emberLife = new Float32Array(EMBER_COUNT) // 0 = dead
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(this.emberPos, 3))
geo.setAttribute('color', new THREE.BufferAttribute(this.emberCol, 3))
const mat = new THREE.PointsMaterial({
size: 0.16,
vertexColors: true,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
})
this.embers = new THREE.Points(geo, mat)
this.embers.frustumCulled = false
this.scene.add(this.embers)
}
update(dt: number): void {
this.clock += dt
const paint = this.blob.paint
if (!paint) return
const cov = paint.coverage() // 250ms-cached; no per-frame readback
const mods = computeModifiers(cov)
// copy into the shared modifiers object (readers hold this reference)
const m = this.blob.modifiers
m.speedMul = mods.speedMul
m.jumpMul = mods.jumpMul
m.massMul = mods.massMul
m.grip = mods.grip
m.size = mods.size
m.waterproof = mods.waterproof
m.glow = mods.glow
this.updateEmbers(dt, cov.byColor.red)
this.updateGlow(mods.glow, superColor(cov) ?? dominantColor(cov))
}
dispose(): void {
this.scene.remove(this.embers)
this.embers.geometry.dispose()
;(this.embers.material as THREE.Material).dispose()
}
// ---- ember trail (RED BURN) ---------------------------------------------
private updateEmbers(dt: number, red: number): void {
const strength = buffStrength(red) // 0 below 20%
if (red >= ACTIVATE) {
// spawn rate scales with how hot the blob is
const spawn = strength * 3
let n = Math.floor(spawn) + (Math.random() < spawn % 1 ? 1 : 0)
this.blob.mesh.getWorldPosition(this._spawnAt)
while (n-- > 0) this.spawnEmber(this._spawnAt)
}
const emberHex = this.emberHex
for (let i = 0; i < EMBER_COUNT; i++) {
if (this.emberLife[i] <= 0) continue
this.emberLife[i] -= dt
const j = i * 3
if (this.emberLife[i] <= 0) {
this.emberCol[j] = this.emberCol[j + 1] = this.emberCol[j + 2] = 0 // invisible
continue
}
this.emberVel[j + 1] += 2.2 * dt // buoyant rise
this.emberPos[j] += this.emberVel[j] * dt
this.emberPos[j + 1] += this.emberVel[j + 1] * dt
this.emberPos[j + 2] += this.emberVel[j + 2] * dt
const fade = Math.min(1, this.emberLife[i] / 0.5)
this.emberCol[j] = emberHex.r * fade
this.emberCol[j + 1] = emberHex.g * fade
this.emberCol[j + 2] = emberHex.b * fade
}
this.embers.geometry.attributes.position.needsUpdate = true
this.embers.geometry.attributes.color.needsUpdate = true
}
private spawnEmber(center: THREE.Vector3): void {
const i = this.emberCursor
this.emberCursor = (this.emberCursor + 1) % EMBER_COUNT
const j = i * 3
this.emberPos[j] = center.x + (Math.random() - 0.5) * 0.5
this.emberPos[j + 1] = center.y + (Math.random() - 0.3) * 0.4
this.emberPos[j + 2] = center.z + (Math.random() - 0.5) * 0.5
this.emberVel[j] = (Math.random() - 0.5) * 0.8
this.emberVel[j + 1] = 0.6 + Math.random() * 0.8
this.emberVel[j + 2] = (Math.random() - 0.5) * 0.8
this.emberLife[i] = 0.5 + Math.random() * 0.4
}
// ---- glow (super state) --------------------------------------------------
private updateGlow(glow: number, color: PaintColor | null): void {
const mat = this.blob.mesh.material as THREE.MeshStandardMaterial
if (!('emissive' in mat)) return
if (glow > 0 && color) {
const pulse = 0.55 + 0.45 * Math.sin(this.clock * 8)
mat.emissive.set(PALETTE[color])
mat.emissiveIntensity = glow * pulse * 1.4
} else {
mat.emissiveIntensity = 0
}
}
}

305
src/paint/cannon.ts Normal file
View File

@ -0,0 +1,305 @@
/**
* PaintCannon a placed emitter that lobs paint-glob projectiles on a gravity
* arc. On hitting the blob it stamps a splat exactly where it struck (the magic
* moment) and emits `paint:splatted`. Fires on an interval AND on
* `machine:signal {id}` matching its trigger id (Lane C wires the signals).
*
* Projectiles are simulated by a manual sweep (no Rapier collider churn): each
* step advances position under gravity and does a swept sphere-vs-sphere test
* against the target so fast shots can't tunnel through.
*/
import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
export interface PaintCannonConfig {
world: World
position: THREE.Vector3
color: PaintColor
/** Aim target (usually the blob mesh) — sampled at fire time for a lead shot. */
target: THREE.Object3D
/** Skin to stamp on a blob hit. */
paint: PaintSkin
/** Target bounding radius (world units) for the hit test. */
targetRadius: number
/** Fire on `machine:signal {id}` when id === triggerId. */
triggerId?: string
/** Seconds between auto-fires. 0/undefined disables the auto cadence. */
interval?: number
muzzleSpeed?: number
projRadius?: number
/** World-space splat radius handed to the skin on hit. */
splatRadius?: number
/** Gravity magnitude for the arc (default 14, matching world.ts). */
gravity?: number
/** y below which a projectile is considered to have hit the ground. */
groundY?: number
/** Lead a moving target using its estimated velocity (default true). */
lead?: boolean
}
interface Projectile {
mesh: THREE.Mesh
vel: THREE.Vector3
prev: THREE.Vector3
life: number
}
const MAX_LIFE = 6 // seconds before a stray glob is culled
export class PaintCannon implements System {
readonly color: PaintColor
private readonly cfg: Required<
Omit<PaintCannonConfig, 'triggerId'>
> & { triggerId?: string }
private readonly scene: THREE.Scene
private readonly projectiles: Projectile[] = []
private readonly projGeo: THREE.SphereGeometry
private readonly projMat: THREE.MeshStandardMaterial
private readonly barrel: THREE.Group
private acc = 0
private readonly unsub: () => void
// target velocity estimate (for leading a moving blob)
private readonly _lastTarget = new THREE.Vector3()
private readonly _targetVel = new THREE.Vector3()
private hasLast = false
// scratch
private readonly _tpos = new THREE.Vector3()
private readonly _aim = new THREE.Vector3()
private readonly _hit = new THREE.Vector3()
private readonly _vtmp = new THREE.Vector3()
constructor(config: PaintCannonConfig) {
this.color = config.color
this.cfg = {
interval: 0,
muzzleSpeed: 16,
projRadius: 0.12,
splatRadius: 0.28,
gravity: 14,
groundY: 0,
lead: true,
...config,
}
this.scene = config.world.scene
const hex = PALETTE[config.color]
this.projGeo = new THREE.SphereGeometry(this.cfg.projRadius, 12, 10)
this.projMat = new THREE.MeshStandardMaterial({
color: hex,
emissive: hex,
emissiveIntensity: 0.35,
roughness: 0.4,
})
this.barrel = this.buildBarrel(hex)
this.barrel.position.copy(config.position)
this.scene.add(this.barrel)
this.unsub = config.world.events.on('machine:signal', (p: { id: string }) => {
if (this.cfg.triggerId && p?.id === this.cfg.triggerId) this.fire()
})
}
/** Fire one glob now, leading the target with a ballistic solve. */
fire(): void {
this.target(this._tpos)
const from = this.muzzleWorld()
// Lead: aim where the target will be after the glob's ~time-of-flight.
this._aim.copy(this._tpos)
if (this.cfg.lead && this.hasLast) {
const flight = from.distanceTo(this._tpos) / this.cfg.muzzleSpeed
this._aim.addScaledVector(this._targetVel, flight)
}
const vel =
solveBallisticVelocity(from, this._aim, this.cfg.muzzleSpeed, this.cfg.gravity) ??
this._aim.clone().sub(from).normalize().multiplyScalar(this.cfg.muzzleSpeed)
// Point the barrel along the launch direction.
this.aimBarrel(vel)
const mesh = new THREE.Mesh(this.projGeo, this.projMat)
mesh.castShadow = true
mesh.position.copy(from)
this.scene.add(mesh)
this.projectiles.push({
mesh,
vel: vel.clone(),
prev: from.clone(),
life: 0,
})
}
update(dt: number): void {
// maintain a smoothed target-velocity estimate for leading
if (dt > 0) {
this.target(this._tpos)
if (this.hasLast) {
const inv = 1 / dt
this._vtmp.copy(this._tpos).sub(this._lastTarget).multiplyScalar(inv)
this._targetVel.lerp(this._vtmp, 0.4)
}
this._lastTarget.copy(this._tpos)
this.hasLast = true
}
// auto cadence
if (this.cfg.interval > 0) {
this.acc += dt
while (this.acc >= this.cfg.interval) {
this.acc -= this.cfg.interval
this.fire()
}
}
if (this.projectiles.length === 0) return
this.target(this._tpos)
const hitDist = this.cfg.targetRadius + this.cfg.projRadius
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i]
p.prev.copy(p.mesh.position)
p.vel.y -= this.cfg.gravity * dt
p.mesh.position.addScaledVector(p.vel, dt)
p.life += dt
// swept hit test against the target sphere (prev -> current segment)
const d = closestDistToSegment(this._tpos, p.prev, p.mesh.position, this._hit)
if (d <= hitDist) {
this.cfg.paint.splatAtPoint(this._hit, this.color, this.cfg.splatRadius)
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'blob',
})
this.despawn(i)
continue
}
if (p.mesh.position.y <= this.cfg.groundY || p.life > MAX_LIFE) {
// Ground/world decals are V1; just despawn (protocol-complete signal).
this.cfg.world.events.emit('paint:splatted', {
color: this.color,
target: 'world',
})
this.despawn(i)
}
}
}
dispose(): void {
this.unsub()
for (let i = this.projectiles.length - 1; i >= 0; i--) this.despawn(i)
this.scene.remove(this.barrel)
this.projGeo.dispose()
this.projMat.dispose()
}
// ---- internals ----------------------------------------------------------
private target(out: THREE.Vector3): THREE.Vector3 {
return this.cfg.target.getWorldPosition(out)
}
private muzzleWorld(): THREE.Vector3 {
// muzzle a little in front of / above the barrel base
return this.cfg.position.clone().add(new THREE.Vector3(0, 0.55, 0))
}
private despawn(i: number): void {
const p = this.projectiles[i]
this.scene.remove(p.mesh)
this.projectiles.splice(i, 1)
}
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
)
base.castShadow = true
g.add(base)
const tube = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.28, 1.1, 16),
new THREE.MeshStandardMaterial({ color: hex, roughness: 0.5 }),
)
tube.castShadow = true
// pivot the tube from the base and tilt it up; child group lets us aim it
const pivot = new THREE.Group()
pivot.position.y = 0.45
tube.position.y = 0.4
tube.rotation.x = Math.PI / 2 // point +Z by default
pivot.add(tube)
pivot.name = 'pivot'
g.add(pivot)
return g
}
private aimBarrel(vel: THREE.Vector3): void {
const pivot = this.barrel.getObjectByName('pivot')
if (!pivot) return
const dir = vel.clone().normalize()
const m = new THREE.Matrix4().lookAt(
new THREE.Vector3(0, 0, 0),
dir,
new THREE.Vector3(0, 1, 0),
)
pivot.quaternion.setFromRotationMatrix(m)
}
}
/**
* Ballistic launch velocity to hit `to` from `from` at fixed `speed` under
* gravity magnitude `g` (down -y). Returns the LOW-arc solution, or null if the
* target is out of range. Exported for potential reuse/testing.
*/
export function solveBallisticVelocity(
from: THREE.Vector3,
to: THREE.Vector3,
speed: number,
g: number,
): THREE.Vector3 | null {
const dx = to.x - from.x
const dz = to.z - from.z
const dy = to.y - from.y
const x = Math.hypot(dx, dz)
if (x < 1e-3) {
return new THREE.Vector3(0, Math.sign(dy || 1) * speed, 0)
}
const s2 = speed * speed
const disc = s2 * s2 - g * (g * x * x + 2 * dy * s2)
if (disc < 0) return null
const root = Math.sqrt(disc)
const tanTheta = (s2 - root) / (g * x) // low arc
const theta = Math.atan(tanTheta)
const hx = dx / x
const hz = dz / x
const vh = speed * Math.cos(theta)
const vy = speed * Math.sin(theta)
return new THREE.Vector3(hx * vh, vy, hz * vh)
}
/** Closest distance from point `p` to segment `a`->`b`; writes the point to `out`. */
function closestDistToSegment(
p: THREE.Vector3,
a: THREE.Vector3,
b: THREE.Vector3,
out: THREE.Vector3,
): number {
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const len2 = abx * abx + aby * aby + abz * abz
let t = 0
if (len2 > 1e-12) {
t = ((p.x - a.x) * abx + (p.y - a.y) * aby + (p.z - a.z) * abz) / len2
t = t < 0 ? 0 : t > 1 ? 1 : t
}
out.set(a.x + abx * t, a.y + aby * t, a.z + abz * t)
return out.distanceTo(p)
}

View File

@ -0,0 +1,117 @@
/**
* Unit checks for the pure paint math. No test runner / no deps run directly:
* node --experimental-strip-types src/paint/coverage-math.test.ts
* (Node 22; on Node 23 the flag is unnecessary.) Uses only console + throw so
* it also type-checks cleanly under the project's DOM tsconfig and is never
* bundled (no demo entry imports it).
*/
import type { CoverageReport, PaintColor } from '../contracts'
import {
ACTIVATE,
SUPER,
buffStrength,
computeModifiers,
countBuckets,
coverageReportFromCounts,
colorIndex,
} from './coverage-math'
let passed = 0
function ok(cond: boolean, msg: string): void {
if (!cond) throw new Error('FAIL: ' + msg)
passed++
}
function near(a: number, b: number, msg: string, eps = 1e-9): void {
ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}
// helper: build a CoverageReport from a partial byColor map
function cov(part: Partial<Record<PaintColor, number>>): CoverageReport {
const byColor = {
red: 0, orange: 0, yellow: 0, green: 0, blue: 0, purple: 0, pink: 0,
...part,
}
const total = Object.values(byColor).reduce((a, b) => a + b, 0)
return { total, byColor }
}
// ---- colorIndex ----------------------------------------------------------
ok(colorIndex('red') === 1, 'red index is 1')
ok(colorIndex('pink') === 7, 'pink index is 7')
// ---- countBuckets --------------------------------------------------------
{
const mask = new Uint8Array(100) // all base (0)
for (let i = 0; i < 30; i++) mask[i] = 1 // red
for (let i = 30; i < 50; i++) mask[i] = 4 // green
const c = countBuckets(mask, 7)
ok(c[0] === 50, 'base count 50')
ok(c[1] === 30, 'red count 30')
ok(c[4] === 20, 'green count 20')
ok(c[7] === 0, 'pink count 0')
ok(c.length === 8, 'counts length 8 (base + 7)')
}
// ---- coverageReportFromCounts --------------------------------------------
{
// counts: [base, red, orange, yellow, green, blue, purple, pink]
const counts = [50, 30, 0, 0, 20, 0, 0, 0]
const r = coverageReportFromCounts(counts, 100)
near(r.byColor.red, 0.3, 'red fraction')
near(r.byColor.green, 0.2, 'green fraction')
near(r.total, 0.5, 'total painted fraction')
// empty texture → all zeros, no NaN
const z = coverageReportFromCounts([0, 0, 0, 0, 0, 0, 0, 0], 0)
near(z.total, 0, 'empty total 0')
near(z.byColor.blue, 0, 'empty blue 0')
}
// ---- buffStrength --------------------------------------------------------
near(buffStrength(0.0), 0, 'strength below threshold is 0')
near(buffStrength(0.199), 0, 'strength just below 20% is 0')
near(buffStrength(ACTIVATE), 0.15, 'strength at 20% is the floor')
near(buffStrength(SUPER), 1, 'strength at 70% is 1')
near(buffStrength(0.9), 1, 'strength above 70% pinned at 1')
near(buffStrength(0.45), 0.15 + 0.85 * 0.5, 'strength ramps linearly at midpoint')
// ---- computeModifiers ----------------------------------------------------
{
const clean = computeModifiers(cov({}))
near(clean.speedMul, 1, 'clean speedMul 1')
near(clean.grip, 0, 'clean grip 0')
ok(clean.waterproof === false, 'clean not waterproof')
near(clean.massMul, 1, 'clean massMul 1')
near(clean.glow, 0, 'clean no glow')
near(clean.size, 1, 'clean size 1 (no grow/shrink in MVP)')
}
{
// RED just under activation: no buff yet
const m = computeModifiers(cov({ red: 0.19 }))
near(m.speedMul, 1, 'red 19% → no speed buff')
}
{
// RED at 45% → speedMul = 1 + 0.6 * buffStrength(0.45)
const s = 0.15 + 0.85 * 0.5
const m = computeModifiers(cov({ red: 0.45 }))
near(m.speedMul, 1 + 0.6 * s, 'red 45% speedMul')
near(m.massMul, 1 + 0.8 * 0.45, 'massMul tracks total 45%')
near(m.glow, 0, 'red 45% not super')
}
{
// GREEN super (70%) → grip maxed + glow
const m = computeModifiers(cov({ green: 0.7 }))
near(m.grip, 1, 'green 70% grip maxed')
near(m.glow, 1, 'green 70% is super (glow)')
}
{
// BLUE ≥20% → waterproof
const m = computeModifiers(cov({ blue: 0.25 }))
ok(m.waterproof === true, 'blue 25% waterproof')
}
{
// massMul clamps at 1.8 when fully painted
const m = computeModifiers(cov({ red: 0.5, blue: 0.5 }))
near(m.massMul, 1.8, 'full coverage massMul 1.8')
}
console.log(`coverage-math.test: ${passed} assertions passed ✓`)

127
src/paint/coverage-math.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* Pure paint math no THREE / Rapier / DOM imports, so it is unit-testable in a
* bare node runtime (see coverage-math.test.ts). Everything that touches only
* numbers lives here: the exact pixel-bucket coverage count and the
* coverage -> BlobModifiers buff mapping (GDD §6 MVP subset).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
/**
* Canonical colour ordering. Index 0 in the mask is the unpainted base; palette
* colours occupy indices 1..7 in this order (matches the PALETTE key order in
* contracts.ts). Keep this list and the mask encoding in lockstep.
*/
export const COLOR_ORDER: readonly PaintColor[] = [
'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink',
]
/** Mask palette index for a colour (1..7). Base/unpainted is 0. */
export function colorIndex(color: PaintColor): number {
return COLOR_ORDER.indexOf(color) + 1
}
/**
* Exact integer bucketing of a quantized mask. `mask[i]` is a palette index
* (0 = base, 1..n = colour). Returns counts of length n+1 where counts[0] is the
* unpainted pixel count. This is the whole point of quantizing on stamp: coverage
* is an exact histogram, never a fuzzy nearest-colour match.
*/
export function countBuckets(mask: Uint8Array, numColors: number): number[] {
const counts = new Array<number>(numColors + 1).fill(0)
for (let i = 0; i < mask.length; i++) counts[mask[i]]++
return counts
}
/**
* Turn raw bucket counts into a CoverageReport (fractions of TOTAL surface;
* unpainted counts in the denominator, per contracts).
*/
export function coverageReportFromCounts(
counts: number[],
totalPixels: number,
): CoverageReport {
const byColor = {} as Record<PaintColor, number>
for (let i = 0; i < COLOR_ORDER.length; i++) {
byColor[COLOR_ORDER[i]] = totalPixels > 0 ? counts[i + 1] / totalPixels : 0
}
const painted = totalPixels > 0 ? (totalPixels - counts[0]) / totalPixels : 0
return { total: painted, byColor }
}
// ---- Buff thresholds (GDD §5.1 / §6 MVP subset) ---------------------------
export const ACTIVATE = 0.20 // buff activates at ≥20% of that colour
export const SUPER = 0.70 // ≥70% one colour = super state
/** Strength floor at the activation threshold so the buff visibly "kicks in". */
const ACTIVATE_FLOOR = 0.15
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
/**
* Buff strength 0..1 for a single colour's coverage fraction.
* 0 below the 20% threshold; ramps ACTIVATE_FLOOR..1 across 20%70%; pinned at 1
* once super. This is the "strength scales linearly between thresholds" curve.
*/
export function buffStrength(coverage: number): number {
if (coverage < ACTIVATE) return 0
if (coverage >= SUPER) return 1
const t = (coverage - ACTIVATE) / (SUPER - ACTIVATE)
return ACTIVATE_FLOOR + (1 - ACTIVATE_FLOOR) * t
}
/** Is any single colour at/above the super threshold? */
export function superColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = SUPER
for (const c of COLOR_ORDER) {
if (cov.byColor[c] >= bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
/** Dominant (highest-coverage) colour, or null if nothing is painted. */
export function dominantColor(cov: CoverageReport): PaintColor | null {
let best: PaintColor | null = null
let bestV = 0
for (const c of COLOR_ORDER) {
if (cov.byColor[c] > bestV) {
bestV = cov.byColor[c]
best = c
}
}
return best
}
/**
* The core mapping: coverage -> modifiers (GDD §6 MVP subset).
* RED -> speedMul up to 1.6 (BURN)
* GREEN -> grip up to 1.0 (GRIP)
* BLUE -> waterproof (SLICK)
* total coverage -> massMul 1.0..1.8 (paint = weight, §5.2)
* any colour 70% -> super: that buff pinned at max + glow
* Pure and deterministic; the visual side-effects (ember trail, emissive pulse)
* live in BuffSystem and read `glow` / dominant colour off this result + cov.
*/
export function computeModifiers(cov: CoverageReport): BlobModifiers {
const red = cov.byColor.red
const green = cov.byColor.green
const blue = cov.byColor.blue
const speedMul = 1 + 0.6 * buffStrength(red)
const grip = buffStrength(green) // 0..1
const waterproof = blue >= ACTIVATE
const massMul = 1 + 0.8 * clamp01(cov.total)
const glow = superColor(cov) ? 1 : 0
return {
speedMul,
jumpMul: 1,
massMul,
grip,
size: 1, // purple/pink grow-shrink is V1 (§5.3) — untouched in MVP
waterproof,
glow,
}
}

106
src/paint/hud.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* PaintHUD a tiny DOM overlay: per-colour coverage bars + the active buff kit.
* No framework, no per-frame layout thrash (throttled to ~10Hz and only rewrites
* text/width, never rebuilds nodes).
*/
import type { BlobModifiers, CoverageReport, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { COLOR_ORDER, ACTIVATE, SUPER } from './coverage-math'
export class PaintHUD {
readonly root: HTMLDivElement
private readonly bars = new Map<PaintColor, { fill: HTMLDivElement; pct: HTMLSpanElement }>()
private readonly totalPct: HTMLSpanElement
private readonly buffLine: HTMLDivElement
private lastUpdate = -1e9
constructor(container: HTMLElement = document.body) {
const root = document.createElement('div')
root.style.cssText = [
'position:fixed', 'top:12px', 'left:12px', 'z-index:10',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace',
'color:#fff', 'background:rgba(18,18,24,0.72)', 'padding:10px 12px',
'border-radius:10px', 'min-width:210px', 'user-select:none',
'box-shadow:0 4px 18px rgba(0,0,0,0.35)', 'backdrop-filter:blur(4px)',
].join(';')
const title = document.createElement('div')
title.textContent = 'PAINT COVERAGE'
title.style.cssText = 'font-weight:700;letter-spacing:0.08em;opacity:0.85;margin-bottom:8px'
root.appendChild(title)
for (const color of COLOR_ORDER) {
const row = document.createElement('div')
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0'
const swatch = document.createElement('div')
swatch.style.cssText = `width:10px;height:10px;border-radius:3px;background:${PALETTE[color]};flex:0 0 auto`
row.appendChild(swatch)
const track = document.createElement('div')
track.style.cssText = 'flex:1;height:8px;background:rgba(255,255,255,0.12);border-radius:4px;overflow:hidden'
const fill = document.createElement('div')
fill.style.cssText = `height:100%;width:0%;background:${PALETTE[color]};transition:width 0.12s linear`
track.appendChild(fill)
row.appendChild(track)
const pct = document.createElement('span')
pct.textContent = '0%'
pct.style.cssText = 'width:34px;text-align:right;opacity:0.9'
row.appendChild(pct)
root.appendChild(row)
this.bars.set(color, { fill, pct })
}
const totalRow = document.createElement('div')
totalRow.style.cssText = 'margin-top:8px;border-top:1px solid rgba(255,255,255,0.15);padding-top:6px'
totalRow.innerHTML = 'total painted: '
this.totalPct = document.createElement('span')
this.totalPct.textContent = '0%'
this.totalPct.style.fontWeight = '700'
totalRow.appendChild(this.totalPct)
root.appendChild(totalRow)
this.buffLine = document.createElement('div')
this.buffLine.style.cssText = 'margin-top:6px;opacity:0.95;min-height:16px'
root.appendChild(this.buffLine)
container.appendChild(root)
this.root = root
}
/** Call each frame; internally throttled to ~10Hz. */
update(cov: CoverageReport, mods: BlobModifiers): void {
const now = performance.now()
if (now - this.lastUpdate < 100) return
this.lastUpdate = now
for (const color of COLOR_ORDER) {
const b = this.bars.get(color)!
const v = cov.byColor[color]
b.fill.style.width = `${Math.min(100, v * 100).toFixed(0)}%`
b.pct.textContent = `${(v * 100).toFixed(0)}%`
const active = v >= ACTIVATE
b.pct.style.opacity = active ? '1' : '0.5'
b.fill.style.boxShadow = v >= SUPER ? `0 0 8px ${PALETTE[color]}` : 'none'
}
this.totalPct.textContent = `${(cov.total * 100).toFixed(0)}%`
this.buffLine.innerHTML = this.buffText(cov, mods)
}
private buffText(cov: CoverageReport, mods: BlobModifiers): string {
const parts: string[] = []
if (cov.byColor.red >= ACTIVATE) parts.push(`<b style="color:${PALETTE.red}">BURN</b> ×${mods.speedMul.toFixed(2)}`)
if (cov.byColor.green >= ACTIVATE) parts.push(`<b style="color:${PALETTE.green}">GRIP</b> ${mods.grip.toFixed(2)}`)
if (cov.byColor.blue >= ACTIVATE) parts.push(`<b style="color:${PALETTE.blue}">SLICK</b> waterproof`)
parts.push(`mass ×${mods.massMul.toFixed(2)}`)
if (mods.glow > 0) parts.push('<b style="color:#fff;text-shadow:0 0 6px #fff">★ SUPER</b>')
return parts.length ? parts.join(' &nbsp;·&nbsp; ') : '<span style="opacity:0.5">no buffs</span>'
}
dispose(): void {
this.root.remove()
}
}

74
src/paint/index.ts Normal file
View File

@ -0,0 +1,74 @@
/**
* Lane B (Paint) public surface. Integration (game.ts) imports from here.
*/
import * as THREE from 'three'
import type { PaintColor, World } from '../contracts'
import { PaintSkin } from './skin'
export { PaintSkin } from './skin'
export type { PaintSkinOptions } from './skin'
export { PaintCannon, solveBallisticVelocity } from './cannon'
export type { PaintCannonConfig } from './cannon'
export { BuffSystem } from './buffs'
export type { BuffTarget } from './buffs'
export { PaintHUD } from './hud'
export {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
computeModifiers,
buffStrength,
superColor,
dominantColor,
ACTIVATE,
SUPER,
} from './coverage-math'
export interface PaintEventTarget {
/** The paintable body — its world position is used for proximity gating. */
mesh: THREE.Object3D
paint: PaintSkin
}
/**
* Wire the inbound paint protocol from Lane C onto a blob's skin:
* 'paint:request-splat' {point, color, radius} bucket dumps, sprays
* 'paint:request-cleanse' {fraction} bubble arches, rinses
* request-splat is proximity-gated to the blob (ground decals are V1), and a
* matched splat re-emits 'paint:splatted' {target:'blob'} so downstream listeners
* (VFX, audio, netcode) see a single canonical splat event. Returns an unsub fn.
*/
export function installPaint(world: World, target: PaintEventTarget): () => void {
const center = new THREE.Vector3()
const scale = new THREE.Vector3()
const worldRadius = (): number => {
target.mesh.getWorldScale(scale)
return target.paint.boundingRadius * Math.max(scale.x, scale.y, scale.z)
}
const offSplat = world.events.on(
'paint:request-splat',
(p: { point: THREE.Vector3; color: PaintColor; radius: number }) => {
if (!p?.point) return
target.mesh.getWorldPosition(center)
if (center.distanceTo(p.point) <= worldRadius() + p.radius) {
target.paint.splatAtPoint(p.point, p.color, p.radius)
world.events.emit('paint:splatted', { color: p.color, target: 'blob' })
}
},
)
const offCleanse = world.events.on(
'paint:request-cleanse',
(p: { fraction: number }) => {
target.paint.cleanse(p?.fraction ?? 1)
},
)
return () => {
offSplat()
offCleanse()
}
}

308
src/paint/skin.ts Normal file
View File

@ -0,0 +1,308 @@
/**
* PaintSkin the core invention. A Canvas-2D colour mask painted onto a mesh's
* UVs, wrapped in a THREE.CanvasTexture that becomes the body's material.map over
* the white base. Splats are stamped WHERE the projectile hit (raycast face UV),
* and per-colour coverage is an exact integer histogram of a quantized mask.
*
* Two coupled representations, generated from the SAME set of sub-circles per
* splat so they always agree spatially:
* - `canvas` : soft-edged, irregular, pretty this is what you SEE.
* - `mask` : a Uint8Array of palette indices (0 = base, 1..7 = colour),
* hard-edged this is what we COUNT. Quantized on stamp, so
* coverage() is exact bucketing, never a fuzzy colour match, and
* we NEVER call getImageData (the real per-frame perf killer).
*/
import * as THREE from 'three'
import type { CoverageReport, PaintColor, PaintSkin as IPaintSkin } from '../contracts'
import { BASE_WHITE, PALETTE } from '../contracts'
import {
COLOR_ORDER,
colorIndex,
countBuckets,
coverageReportFromCounts,
} from './coverage-math'
export interface PaintSkinOptions {
/** Mask/texture resolution (square). MVP default 256. */
size?: number
/**
* How many canvas pixels one world-unit of splat radius maps to, expressed as
* a fraction of the texture width per (radius / boundingRadius). Tune for splat
* size on screen; exact value is cosmetic (coverage is area-based regardless).
*/
splatScale?: number
}
interface SubCircle {
x: number
y: number
r: number
}
export class PaintSkin implements IPaintSkin {
readonly canvas: HTMLCanvasElement
readonly texture: THREE.CanvasTexture
readonly boundingRadius: number
private readonly mesh: THREE.Mesh
private readonly ctx: CanvasRenderingContext2D
private readonly size: number
private readonly splatScale: number
private readonly mask: Uint8Array
private readonly raycaster = new THREE.Raycaster()
// 250ms coverage cache (never recount more often than that).
private cache: CoverageReport | null = null
private cacheTime = -1e9
// scratch vectors (avoid per-splat allocation)
private readonly _center = new THREE.Vector3()
private readonly _dir = new THREE.Vector3()
private readonly _origin = new THREE.Vector3()
constructor(mesh: THREE.Mesh, opts: PaintSkinOptions = {}) {
this.mesh = mesh
this.size = opts.size ?? 256
this.splatScale = opts.splatScale ?? 0.16
mesh.geometry.computeBoundingSphere()
this.boundingRadius = mesh.geometry.boundingSphere?.radius ?? 1
const canvas = document.createElement('canvas')
canvas.width = canvas.height = this.size
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('PaintSkin: 2D canvas context unavailable')
ctx.fillStyle = BASE_WHITE
ctx.fillRect(0, 0, this.size, this.size)
this.canvas = canvas
this.ctx = ctx
this.mask = new Uint8Array(this.size * this.size) // 0 everywhere = all base
const tex = new THREE.CanvasTexture(canvas)
tex.colorSpace = THREE.SRGBColorSpace
tex.wrapS = THREE.RepeatWrapping // U seam wraps (sphere longitude)
tex.wrapT = THREE.ClampToEdgeWrapping
tex.anisotropy = 4
this.texture = tex
// Attach as the base colour map. Keep material colour white so the map isn't
// tinted; the canvas already carries the white base.
const mat = mesh.material as THREE.MeshStandardMaterial
mat.map = tex
if (mat.color) mat.color.set('#ffffff')
mat.needsUpdate = true
}
// ---- public API (contracts.PaintSkin) -----------------------------------
applySplat(uv: THREE.Vector2, color: PaintColor, radius: number): void {
const px = uv.x * this.size
// CanvasTexture default flipY=true: uv v=0 samples canvas bottom row.
const py = (1 - uv.y) * this.size
const pr = this.pixelRadius(radius)
const circles = this.makeSplatCircles(px, py, pr)
this.stampMask(circles, colorIndex(color))
this.stampCanvas(circles, PALETTE[color])
this.texture.needsUpdate = true
}
splatAtPoint(worldPoint: THREE.Vector3, color: PaintColor, radius: number): void {
const uv = this.uvAtWorldPoint(worldPoint)
if (uv) this.applySplat(uv, color, radius)
}
coverage(): CoverageReport {
const now = performance.now()
if (this.cache && now - this.cacheTime < 250) return this.cache
const counts = countBuckets(this.mask, COLOR_ORDER.length)
this.cache = coverageReportFromCounts(counts, this.mask.length)
this.cacheTime = now
return this.cache
}
cleanse(fraction: number): void {
const f = Math.max(0, Math.min(1, fraction))
if (f <= 0) return
if (f >= 1) {
// Full clean: reset everything to base.
this.mask.fill(0)
this.ctx.fillStyle = BASE_WHITE
this.ctx.fillRect(0, 0, this.size, this.size)
this.invalidate()
return
}
// Partial: punch random circular "scrub" holes until ~fraction of the
// currently-painted pixels are erased. Looks scrubbed rather than uniform.
let painted = 0
for (let i = 0; i < this.mask.length; i++) if (this.mask[i] !== 0) painted++
if (painted === 0) return
let target = Math.floor(painted * f)
this.ctx.fillStyle = BASE_WHITE
let guard = 0
while (target > 0 && guard++ < 20000) {
const cx = Math.random() * this.size
const cy = Math.random() * this.size
const r = 6 + Math.random() * 12
target -= this.eraseCircle(cx, cy, r)
}
this.invalidate()
}
// ---- geometry: world point -> UV ----------------------------------------
private uvAtWorldPoint(worldPoint: THREE.Vector3): THREE.Vector2 | null {
this.mesh.updateWorldMatrix(true, false)
this.mesh.getWorldPosition(this._center)
this._dir.copy(worldPoint).sub(this._center)
if (this._dir.lengthSq() < 1e-12) return null
this._dir.normalize()
// Cast from just outside the bounding sphere, straight through the centre.
// The near-surface hit is the point closest to the impact; THREE gives us the
// barycentric-interpolated UV for free.
const worldRadius = this.worldBoundingRadius()
this._origin.copy(this._center).addScaledVector(this._dir, worldRadius * 2.2)
this.raycaster.set(this._origin, this._dir.clone().negate())
this.raycaster.near = 0
this.raycaster.far = worldRadius * 4.4
const hits = this.raycaster.intersectObject(this.mesh, false)
const hit = hits.find((h) => h.uv)
return hit?.uv ? hit.uv.clone() : null
}
private worldBoundingRadius(): number {
const s = this.mesh.getWorldScale(this._origin) // reuse scratch
return this.boundingRadius * Math.max(s.x, s.y, s.z)
}
private pixelRadius(radius: number): number {
const px = (radius / this.boundingRadius) * this.size * this.splatScale
return Math.max(4, Math.min(this.size * 0.4, px))
}
// ---- splat rasterization (mask + canvas share the same circles) ----------
private makeSplatCircles(px: number, py: number, pr: number): SubCircle[] {
const circles: SubCircle[] = [{ x: px, y: py, r: pr * 0.95 }]
// irregular lobes
const lobes = 3 + Math.floor(Math.random() * 3)
for (let i = 0; i < lobes; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (0.35 + Math.random() * 0.55)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.3 + Math.random() * 0.35),
})
}
// stray droplets
const drops = 2 + Math.floor(Math.random() * 3)
for (let i = 0; i < drops; i++) {
const a = Math.random() * Math.PI * 2
const d = pr * (1.0 + Math.random() * 0.7)
circles.push({
x: px + Math.cos(a) * d,
y: py + Math.sin(a) * d,
r: pr * (0.08 + Math.random() * 0.14),
})
}
return circles
}
/** Hard-edged union of circles into the quantized mask, with U-seam wrap. */
private stampMask(circles: SubCircle[], idx: number): void {
const S = this.size
for (const c of circles) {
// Wrap in X (longitude seam). Draw the circle and, if near an edge, its
// wrapped twin so a stamp straddling u=0/1 lands on both sides.
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const r2 = c.r * c.r
const x0 = Math.max(0, Math.floor(cx - c.r))
const x1 = Math.min(S - 1, Math.ceil(cx + c.r))
const y0 = Math.max(0, Math.floor(c.y - c.r))
const y1 = Math.min(S - 1, Math.ceil(c.y + c.r))
for (let y = y0; y <= y1; y++) {
const dy = y - c.y
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) this.mask[y * S + x] = idx
}
}
}
}
}
/** Soft, alpha-blended gradient blobs into the visible canvas, with wrap. */
private stampCanvas(circles: SubCircle[], hex: string): void {
const S = this.size
const ctx = this.ctx
const rgb = hexToRgb(hex)
for (const c of circles) {
const offsets = this.wrapOffsets(c.x, c.r, S)
for (const ox of offsets) {
const cx = c.x + ox
const g = ctx.createRadialGradient(cx, c.y, c.r * 0.15, cx, c.y, c.r)
g.addColorStop(0, `rgba(${rgb},1)`)
g.addColorStop(0.7, `rgba(${rgb},0.95)`)
g.addColorStop(1, `rgba(${rgb},0)`)
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(cx, c.y, c.r, 0, Math.PI * 2)
ctx.fill()
}
}
}
/** Erase a scrub hole from both mask and canvas; returns pixels erased. */
private eraseCircle(cx: number, cy: number, r: number): number {
const S = this.size
let erased = 0
const r2 = r * r
const x0 = Math.max(0, Math.floor(cx - r))
const x1 = Math.min(S - 1, Math.ceil(cx + r))
const y0 = Math.max(0, Math.floor(cy - r))
const y1 = Math.min(S - 1, Math.ceil(cy + r))
for (let y = y0; y <= y1; y++) {
const dy = y - cy
for (let x = x0; x <= x1; x++) {
const dx = x - cx
if (dx * dx + dy * dy <= r2) {
const i = y * S + x
if (this.mask[i] !== 0) {
this.mask[i] = 0
erased++
}
}
}
}
this.ctx.beginPath()
this.ctx.arc(cx, cy, r, 0, Math.PI * 2)
this.ctx.fill() // fillStyle already BASE_WHITE at call sites
return erased
}
private wrapOffsets(x: number, r: number, S: number): number[] {
if (x - r < 0) return [0, S]
if (x + r > S) return [0, -S]
return [0]
}
private invalidate(): void {
this.texture.needsUpdate = true
this.cache = null
this.cacheTime = -1e9
}
}
function hexToRgb(hex: string): string {
const h = hex.replace('#', '')
const n = parseInt(h, 16)
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
}