diff --git a/src/demo/lane-b.ts b/src/demo/lane-b.ts index 8a655b9..0b694bb 100644 --- a/src/demo/lane-b.ts +++ b/src/demo/lane-b.ts @@ -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() diff --git a/src/paint/buffs.ts b/src/paint/buffs.ts new file mode 100644 index 0000000..1b18cad --- /dev/null +++ b/src/paint/buffs.ts @@ -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 + } + } +} diff --git a/src/paint/cannon.ts b/src/paint/cannon.ts new file mode 100644 index 0000000..39454d8 --- /dev/null +++ b/src/paint/cannon.ts @@ -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 + > & { 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) +} diff --git a/src/paint/coverage-math.test.ts b/src/paint/coverage-math.test.ts new file mode 100644 index 0000000..1712140 --- /dev/null +++ b/src/paint/coverage-math.test.ts @@ -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>): 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 ✓`) diff --git a/src/paint/coverage-math.ts b/src/paint/coverage-math.ts new file mode 100644 index 0000000..cbb8567 --- /dev/null +++ b/src/paint/coverage-math.ts @@ -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(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 + 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, + } +} diff --git a/src/paint/hud.ts b/src/paint/hud.ts new file mode 100644 index 0000000..1cd26ed --- /dev/null +++ b/src/paint/hud.ts @@ -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() + 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(`BURN ×${mods.speedMul.toFixed(2)}`) + if (cov.byColor.green >= ACTIVATE) parts.push(`GRIP ${mods.grip.toFixed(2)}`) + if (cov.byColor.blue >= ACTIVATE) parts.push(`SLICK waterproof`) + parts.push(`mass ×${mods.massMul.toFixed(2)}`) + if (mods.glow > 0) parts.push('★ SUPER') + return parts.length ? parts.join('  ·  ') : 'no buffs' + } + + dispose(): void { + this.root.remove() + } +} diff --git a/src/paint/index.ts b/src/paint/index.ts new file mode 100644 index 0000000..51a27c8 --- /dev/null +++ b/src/paint/index.ts @@ -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() + } +} diff --git a/src/paint/skin.ts b/src/paint/skin.ts new file mode 100644 index 0000000..24c9dc6 --- /dev/null +++ b/src/paint/skin.ts @@ -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}` +}