diff options
| author | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
| commit | e69ad01e700e80fa959dfee08cd556ca0d777ffd (patch) | |
| tree | 645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/wield.js | |
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser:
A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide
everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and
Test room worlds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/wield.js')
| -rw-r--r-- | public/js/components/wield.js | 629 |
1 files changed, 629 insertions, 0 deletions
diff --git a/public/js/components/wield.js b/public/js/components/wield.js new file mode 100644 index 0000000..864ff8d --- /dev/null +++ b/public/js/components/wield.js @@ -0,0 +1,629 @@ +/* global AFRAME, THREE, NAF, CurrentWorld */ +// Picking things up and drawing in the air with them, like the pens in +// VRChat. A world offers `tools` (see world.js), each lying along its own +x. +// In VR, squeeze grip near one and it goes into your hand by its grip; let go +// and it floats where you left it. Pull the trigger while holding it to draw +// from its tip. With an empty hand, touch a line and pull the trigger to erase +// it. On desktop, F picks up (or lets go of) the one you're looking at, holding +// the mouse button draws, and Backspace erases your last line. In a shared room +// everyone sees who holds what and every line, and newcomers are caught up. +// A world can also offer `touchables` (the temple's Veil): reach an empty hand +// to one, a label above your hand says what the trigger will do, and the +// trigger does it. +(function () { + const GRAB_REACH = 0.1; // hand to tool (m) + const LOOK_REACH = 2.2; // desktop: head to tool + const LOOK_AIM = 0.35; // desktop: how far off your line of sight it can be + const ERASE_REACH = 0.05; // hand to line + const TOUCH_REACH = 0.2; // hand to a touchable + const STEP = 0.003; // a new point every 3 mm of tip travel + const RADIUS = 0.004; + const SIDES = 6; + const MAX_POINTS = 1024; // per line; a longer stroke carries on in a new line + const MAX_LINES = 300; + const MAX_TOTAL = 100000; // points in all lines; the oldest lines go first + const POSE_MS = 66; // how often a held tool's pose is sent + const INK_MS = 100; // how often new points are sent + const SYNC_POINTS = 3000; // points per catch-up message + const SMOOTH = 0.05; // seconds to ease toward someone else's pose + + // In the WebXR grip space a held object runs along -Z out of the fist and + // the palm faces ±X, so the tool's +x goes to -Z and its flat faces the palm. + const HOLD = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis( + new THREE.Vector3(0, 0, -1), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0))); + // On desktop it's held low and to the right, its tip toward the middle of your view. + const DESK_POS = new THREE.Vector3(0.16, -0.2, -0.3); + const DESK_QUAT = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.35, 0.35, 0, 'YXZ')).multiply(HOLD); + + const v1 = new THREE.Vector3(); + const v2 = new THREE.Vector3(); + const v3 = new THREE.Vector3(); + const side = new THREE.Vector3(); + const up = new THREE.Vector3(); + const q1 = new THREE.Quaternion(); + + const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected(); + const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000); + const validId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 40; + const validColor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c); + const round = (x) => Math.round(x * 1e4) / 1e4; + + // Distance from p to the segment a–b. + function toSegment(p, a, b) { + v3.subVectors(b, a); + const t = THREE.MathUtils.clamp(v3.dot(v1.subVectors(p, a)) / v3.lengthSq(), 0, 1); + return p.distanceTo(v1.copy(a).addScaledVector(v3, t)); + } + + // Every line has the same triangles, so they share one index. + let sharedIndex = null; + function lineIndex() { + if (sharedIndex) return sharedIndex; + const index = new Uint16Array((MAX_POINTS - 1) * SIDES * 6); + let k = 0; + for (let seg = 0; seg < MAX_POINTS - 1; seg++) { + for (let s = 0; s < SIDES; s++) { + const a = seg * SIDES + s; + const b = seg * SIDES + ((s + 1) % SIDES); + index.set([a, b, a + SIDES, b, b + SIDES, a + SIDES], k); + k += 6; + } + } + sharedIndex = new THREE.BufferAttribute(index, 1); + return sharedIndex; + } + + const materials = new Map(); + function inkMaterial(color) { + if (!materials.has(color)) { + materials.set(color, new THREE.MeshBasicMaterial({ color, toneMapped: false, fog: false, side: THREE.DoubleSide })); + } + return materials.get(color); + } + + // One stroke: a thin tube that grows a ring at a time. + class Line { + constructor(id, color) { + this.id = id; + this.color = color; + this.points = []; // x, y, z, … + this.count = 0; + this.normal = new THREE.Vector3(); + this.tangent = new THREE.Vector3(); + const geometry = new THREE.BufferGeometry(); + this.position = new THREE.BufferAttribute(new Float32Array(MAX_POINTS * SIDES * 3), 3); + this.position.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('position', this.position); + geometry.setIndex(lineIndex()); + geometry.setDrawRange(0, 0); + this.mesh = new THREE.Mesh(geometry, inkMaterial(color)); + this.mesh.frustumCulled = false; + } + + get full() { + return this.count >= MAX_POINTS; + } + + last(out) { + const p = this.points; + return out.set(p[p.length - 3], p[p.length - 2], p[p.length - 1]); + } + + push(x, y, z) { + if (this.full) return; + const i = this.count; + this.points.push(x, y, z); + this.count++; + if (i === 0) return; + const p = this.points; + const t = this.tangent.set(x - p[3 * i - 3], y - p[3 * i - 2], z - p[3 * i - 1]); + if (t.lengthSq() < 1e-12) t.set(1, 0, 0); + t.normalize(); + // Carry the ring's orientation along the line so the tube doesn't twist. + const n = this.normal; + if (i > 1) n.addScaledVector(t, -n.dot(t)); + if (i === 1 || n.lengthSq() < 1e-6) { + up.set(0, 1, 0); + if (Math.abs(t.y) > 0.9) up.set(1, 0, 0); + n.copy(up).addScaledVector(t, -up.dot(t)); + } + n.normalize(); + if (i === 1) this.ring(0); + this.ring(i); + this.mesh.geometry.setDrawRange(0, (this.count - 1) * SIDES * 6); + this.position.needsUpdate = true; + } + + ring(i) { + const p = this.points; + const b = side.crossVectors(this.tangent, this.normal); + const a = this.position.array; + for (let s = 0; s < SIDES; s++) { + const angle = (s / SIDES) * Math.PI * 2; + const c = Math.cos(angle) * RADIUS; + const d = Math.sin(angle) * RADIUS; + const o = (i * SIDES + s) * 3; + a[o] = p[3 * i] + this.normal.x * c + b.x * d; + a[o + 1] = p[3 * i + 1] + this.normal.y * c + b.y * d; + a[o + 2] = p[3 * i + 2] + this.normal.z * c + b.z * d; + } + } + + near(pos, reach) { + const p = this.points; + const r2 = reach * reach; + for (let k = 0; k < p.length; k += 3) { + const dx = p[k] - pos.x; + const dy = p[k + 1] - pos.y; + const dz = p[k + 2] - pos.z; + if (dx * dx + dy * dy + dz * dz < r2) return true; + } + return false; + } + + dispose() { + this.mesh.removeFromParent(); + this.mesh.geometry.setIndex(null); // the index is shared; keep it + this.mesh.geometry.dispose(); + } + } + + function buzz(handEl, strength, ms) { + try { + const tracked = handEl.components['tracked-controls']; + const pad = tracked && tracked.controller && tracked.controller.gamepad; + const motor = pad && pad.hapticActuators && pad.hapticActuators[0]; + if (motor && motor.pulse) Promise.resolve(motor.pulse(strength, ms)).catch(() => {}); + } catch (e) { + // No haptics on this controller. + } + } + + AFRAME.registerComponent('wield', { + schema: { + head: { type: 'selector', default: '#player' }, + leftHand: { type: 'selector', default: '#left-hand' }, + rightHand: { type: 'selector', default: '#right-hand' }, + }, + + init() { + this.tools = null; // the world's, once it has loaded + this.touchables = []; + this.lines = new Map(); // id → Line, oldest first + this.erased = new Set(); // ids of erased lines, so a late catch-up doesn't bring them back + this.total = 0; + this.prefix = Math.random().toString(36).slice(2, 8); + this.serial = 0; + this.lastPose = 0; + this.group = new THREE.Group(); + this.el.sceneEl.object3D.add(this.group); + + const hand = (el, vr) => ({ el, vr, tool: null, line: null, sent: 0, sentAt: 0, trigger: false, erasing: false, touching: null }); + this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)]; + this.desk = hand(this.data.head, false); + for (const h of this.hands) h.prompt = this.makePrompt(); + + this.listeners = []; + const on = (target, type, fn) => { + target.addEventListener(type, fn); + this.listeners.push([target, type, fn]); + }; + + for (const h of this.hands) { + on(h.el, 'gripdown', () => this.grabNear(h)); + on(h.el, 'gripup', () => this.release(h)); + on(h.el, 'triggerdown', () => this.pull(h, true)); + on(h.el, 'triggerup', () => this.pull(h, false)); + on(h.el, 'controllerdisconnected', () => this.release(h)); + } + on(this.el.sceneEl, 'enter-vr', () => this.release(this.desk)); + on(this.el.sceneEl, 'exit-vr', () => this.hands.forEach((h) => this.release(h))); + + const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea'); + on(window, 'keydown', (evt) => { + if (evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return; + if (evt.code === 'KeyF') { + if (this.desk.tool) this.release(this.desk); + else this.grabLooked(); + } + if (evt.code === 'Backspace') { + evt.preventDefault(); + this.eraseLast(); + } + }); + on(window, 'mousedown', (evt) => { + if (evt.button === 0 && document.pointerLockElement === this.el.sceneEl.canvas) this.pull(this.desk, true); + }); + on(window, 'mouseup', (evt) => { + if (evt.button === 0) this.pull(this.desk, false); + }); + on(window, 'blur', () => this.pull(this.desk, false)); + + if (typeof NAF !== 'undefined' && NAF.connection) { + NAF.connection.subscribeToDataChannel('wield', (from, type, msg) => this.onPose(msg)); + NAF.connection.subscribeToDataChannel('ink', (from, type, msg) => this.onInk(msg)); + on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId)); + on(document.body, 'clientDisconnected', (evt) => { + for (const tool of this.tools || []) if (tool.by === evt.detail.clientId) tool.by = null; + }); + } + }, + + remove() { + for (const [target, type, fn] of this.listeners) target.removeEventListener(type, fn); + this.group.removeFromParent(); + }, + + // A label that floats above your hand while it's near something to touch. + makePrompt() { + const el = document.createElement('a-entity'); + el.setAttribute('geometry', { primitive: 'plane', width: 0.26, height: 0.045 }); + el.setAttribute('material', { shader: 'flat', color: '#000000', opacity: 0.6, transparent: true }); + el.setAttribute('text', { value: '', align: 'center', width: 0.4, wrapCount: 28, color: '#ffe2c4' }); + el.object3D.visible = false; + this.el.sceneEl.appendChild(el); + return el; + }, + + // The world is added after the rig, so look for its tools until they're there. + findTools() { + const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null; + if (!world) return; + this.touchables = world.touchables || []; + this.tools = (world.tools || []).map((spec) => { + const mark = new THREE.Mesh(new THREE.SphereGeometry(0.005, 10, 8), inkMaterial(spec.ink)); + mark.position.copy(spec.tip); + mark.visible = false; + spec.object.add(mark); + return { ...spec, mark, holder: null, by: null, v: 0, target: null }; + }); + }, + + // ---- holding ------------------------------------------------------------- + + myId() { + return online() ? NAF.clientId : 'me'; + }, + + // Who holds it now: you, someone else, or no one (null). + holderId(tool) { + return tool.holder ? this.myId() : tool.by; + }, + + worldEnds(tool) { + tool.object.updateWorldMatrix(true, false); + return [tool.object.localToWorld(new THREE.Vector3().copy(tool.butt)), tool.object.localToWorld(new THREE.Vector3().copy(tool.tip))]; + }, + + grabNear(hand) { + if (!this.tools || hand.tool) return; + const at = hand.el.object3D.getWorldPosition(new THREE.Vector3()); + let best = null; + let bestDistance = GRAB_REACH; + for (const tool of this.tools) { + if (tool.by) continue; // someone else has it + const [a, b] = this.worldEnds(tool); + const d = toSegment(at, a, b); + if (d < bestDistance) { + best = tool; + bestDistance = d; + } + } + if (best) this.grab(hand, best); + }, + + // Desktop: the tool nearest your line of sight, within reach. + grabLooked() { + if (!this.tools) return; + const head = this.data.head.object3D; + const eye = head.getWorldPosition(new THREE.Vector3()); + const ahead = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1)); + let best = null; + let bestAim = LOOK_AIM; + let heldNearby = null; + for (const tool of this.tools) { + const [a, b] = this.worldEnds(tool); + for (let i = 0; i <= 8; i++) { + const p = v2.lerpVectors(a, b, i / 8).sub(eye); + const along = p.dot(ahead); + if (along < 0 || p.length() > LOOK_REACH) continue; + const aim = p.addScaledVector(ahead, -along).length(); + if (aim >= bestAim) continue; + if (tool.by) { + heldNearby = tool; + continue; + } + best = tool; + bestAim = aim; + } + } + if (best) this.grab(this.desk, best); + else this.el.emit('wield-failed', { reason: heldNearby ? 'held' : 'far', label: heldNearby && heldNearby.label }, false); + }, + + grab(hand, tool) { + if (tool.holder && tool.holder !== hand) { + // From your other hand. + this.stopDrawing(tool.holder); + tool.holder.tool = null; + } + const obj = tool.object; + hand.el.object3D.add(obj); + obj.quaternion.copy(hand.vr ? HOLD : DESK_QUAT); + obj.position.copy(tool.grip).applyQuaternion(obj.quaternion).negate(); + if (!hand.vr) obj.position.add(DESK_POS); + tool.holder = hand; + tool.by = null; + tool.target = null; + hand.tool = tool; + tool.v++; + this.sendPose(tool, true); + if (hand.vr) buzz(hand.el, 0.4, 40); + hand.erasing = false; + if (hand.trigger) this.startLine(hand); // trigger already held + }, + + // Lets go: it stays exactly where it was, floating. + release(hand) { + const tool = hand.tool; + if (!tool) return; + this.stopDrawing(hand); + this.el.sceneEl.object3D.attach(tool.object); + tool.holder = null; + hand.tool = null; + tool.v++; + this.sendPose(tool, true); + }, + + sendPose(tool, guaranteed, to) { + if (!online()) return; + const obj = tool.object; + obj.updateWorldMatrix(true, false); + const p = obj.getWorldPosition(v1); + const q = obj.getWorldQuaternion(q1); + const msg = { + t: tool.name, by: this.holderId(tool), v: tool.v, + p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)], + }; + if (to) NAF.connection.sendDataGuaranteed(to, 'wield', msg); + else if (guaranteed) NAF.connection.broadcastDataGuaranteed('wield', msg); + else NAF.connection.broadcastData('wield', msg); + }, + + onPose(msg) { + if (!this.tools) this.findTools(); + if (!this.tools || !msg) return; + const tool = this.tools.find((t) => t.name === msg.t); + if (!tool || !Number.isInteger(msg.v) || !finite(msg.p, 3) || !finite(msg.q, 4)) return; + const by = typeof msg.by === 'string' ? msg.by : null; + const current = this.holderId(tool); + if (msg.v < tool.v) return; + // Two people grabbed it at once: the lower id keeps it, the same for everyone. + if (msg.v === tool.v && by !== current && (by || '') > (current || '')) return; + tool.v = msg.v; + if (tool.holder && by !== this.myId()) { + const hand = tool.holder; + this.stopDrawing(hand); + hand.tool = null; + tool.holder = null; + } + if (tool.holder) return; + tool.by = by; + if (tool.object.parent !== this.el.sceneEl.object3D) this.el.sceneEl.object3D.attach(tool.object); + const q = new THREE.Quaternion().fromArray(msg.q); + if (q.lengthSq() < 1e-6) return; + tool.target = { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() }; + }, + + // ---- drawing and erasing ------------------------------------------------- + + // Trigger (or mouse button) down or up: draw with what you hold, use what + // an empty hand is touching, or else erase with it. + pull(hand, down) { + if (hand.trigger === down) return; + hand.trigger = down; + if (!down) { + hand.erasing = false; + this.stopDrawing(hand); + } else if (hand.tool) { + this.startLine(hand); + } else if (hand.touching) { + hand.touching.use(); + buzz(hand.el, 0.5, 60); + } else { + hand.erasing = hand.vr; + } + }, + + // Which touchable an empty hand is near, and its label above the hand. + feel(hand) { + let near = null; + if (!hand.tool && this.el.sceneEl.is('vr-mode') && hand.el.object3D.visible) { + const at = hand.el.object3D.getWorldPosition(v1); + let best = TOUCH_REACH; + for (const t of this.touchables) { + const d = t.distance(at); + if (d < best) { + near = t; + best = d; + } + } + } + if (near && near !== hand.touching) buzz(hand.el, 0.15, 20); + hand.touching = near; + const prompt = hand.prompt.object3D; + prompt.visible = Boolean(near); + if (!near) return; + const text = `Trigger: ${near.label()}`; + if (hand.prompt.getAttribute('text').value !== text) hand.prompt.setAttribute('text', 'value', text); + prompt.position.copy(v1).y += 0.1; + prompt.lookAt(this.data.head.object3D.getWorldPosition(v2)); + }, + + startLine(hand) { + const line = this.addLine(`${this.prefix}.${this.serial++}`, hand.tool.ink); + hand.line = line; + hand.sent = 0; + hand.sentAt = 0; + }, + + stopDrawing(hand) { + const line = hand.line; + if (!line) return; + this.sendInk(hand); + hand.line = null; + if (line.count < 2) this.erase([line.id], hand.sent > 0); + }, + + addLine(id, color) { + const line = new Line(id, color); + this.lines.set(id, line); + this.group.add(line.mesh); + return line; + }, + + addPoints(line, x, y, z) { + line.push(x, y, z); + this.total++; + }, + + // The oldest lines go once there are too many, here and for everyone else + // by the same rule. + trim() { + for (const line of this.lines.values()) { + if (this.lines.size <= MAX_LINES && this.total <= MAX_TOTAL) return; + if (this.hands.concat(this.desk).some((h) => h.line === line)) continue; + this.erase([line.id], false); + } + }, + + erase(ids, share) { + for (const id of ids) { + this.erased.add(id); + const line = this.lines.get(id); + if (!line) continue; + this.total -= line.count; + line.dispose(); + this.lines.delete(id); + } + if (this.erased.size > 5000) this.erased.delete(this.erased.values().next().value); + if (share && online()) NAF.connection.broadcastDataGuaranteed('ink', { op: 'erase', ids }); + }, + + eraseLast() { + const mine = [...this.lines.values()].reverse().find((l) => l.id.startsWith(`${this.prefix}.`) && l !== this.desk.line); + if (mine) this.erase([mine.id], true); + }, + + sendInk(hand) { + const line = hand.line; + if (!line || hand.sent >= line.count) return; + if (online()) { + const pts = line.points.slice(hand.sent * 3).map((x) => Math.round(x * 1000)); + NAF.connection.broadcastDataGuaranteed('ink', { op: 'add', id: line.id, c: line.color, n: hand.sent, pts }); + } + hand.sent = line.count; + }, + + // Points (in mm) for a line, from someone else; `n` is how many it already had. + takePoints(id, color, n, pts) { + if (!validId(id) || this.erased.has(id) || !validColor(color) || !Number.isInteger(n) || n < 0) return; + if (!Array.isArray(pts) || pts.length % 3 || pts.length > MAX_POINTS * 3 || !pts.every((x) => Number.isFinite(x) && Math.abs(x) < 1e6)) return; + const line = this.lines.get(id) || this.addLine(id, color); + for (let k = Math.max(0, line.count - n) * 3; k < pts.length && !line.full; k += 3) { + this.addPoints(line, pts[k] / 1000, pts[k + 1] / 1000, pts[k + 2] / 1000); + } + this.trim(); + }, + + onInk(msg) { + if (!msg) return; + if (msg.op === 'add') this.takePoints(msg.id, msg.c, msg.n, msg.pts); + if (msg.op === 'erase' && Array.isArray(msg.ids)) this.erase(msg.ids.filter(validId).slice(0, 50), false); + if (msg.op === 'sync') { + if (Array.isArray(msg.erased)) this.erase(msg.erased.filter(validId).slice(0, 1000), false); + if (Array.isArray(msg.lines)) for (const l of msg.lines.slice(0, MAX_LINES)) if (l) this.takePoints(l.id, l.c, 0, l.pts); + } + }, + + // Tells someone who just arrived where the tools are and what's drawn. + // Everyone here does this; lines they already have are skipped. + catchUp(clientId) { + if (!online()) return; + for (const tool of this.tools || []) if (tool.v > 0) this.sendPose(tool, true, clientId); + let batch = []; + let size = 0; + const send = (erased) => { + NAF.connection.sendDataGuaranteed(clientId, 'ink', { op: 'sync', lines: batch, erased }); + batch = []; + size = 0; + }; + for (const line of this.lines.values()) { + batch.push({ id: line.id, c: line.color, pts: line.points.map((x) => Math.round(x * 1000)) }); + size += line.count; + if (size >= SYNC_POINTS) send([]); + } + send([...this.erased].slice(-1000)); + }, + + // ---- every frame ----------------------------------------------------------- + + tick(time, delta) { + if (!this.tools) { + this.findTools(); + if (!this.tools) return; + } + const dt = Math.min(delta, 100) / 1000; + const ease = 1 - Math.exp(-dt / SMOOTH); + + for (const tool of this.tools) { + tool.mark.visible = Boolean(tool.holder || tool.by); + if (!tool.target || tool.holder) continue; + const obj = tool.object; + obj.position.lerp(tool.target.p, ease); + obj.quaternion.slerp(tool.target.q, ease); + if (!tool.by && obj.position.distanceToSquared(tool.target.p) < 1e-8) tool.target = null; + } + + for (const hand of this.hands) this.feel(hand); + for (const hand of this.hands.concat(this.desk)) { + if (hand.line) this.draw(hand, time); + if (hand.erasing) { + const at = hand.el.object3D.getWorldPosition(v1); + const hit = [...this.lines.values()].filter((l) => l.near(at, ERASE_REACH)).map((l) => l.id); + if (hit.length) { + this.erase(hit, true); + buzz(hand.el, 0.25, 25); + } + } + } + + if (time - this.lastPose >= POSE_MS) { + this.lastPose = time; + for (const tool of this.tools) if (tool.holder) this.sendPose(tool, false); + } + }, + + draw(hand, time) { + const tool = hand.tool; + tool.object.updateWorldMatrix(true, false); + const tip = tool.object.localToWorld(v2.copy(tool.tip)); + let line = hand.line; + if (line.count && line.last(v3).distanceTo(tip) < STEP) return; + if (line.full) { + // Carry on in a new line from where this one ends. + const end = line.last(new THREE.Vector3()); + this.sendInk(hand); + this.startLine(hand); + line = hand.line; + this.addPoints(line, end.x, end.y, end.z); + } + this.addPoints(line, tip.x, tip.y, tip.z); + this.trim(); + if (time - hand.sentAt >= INK_MS) { + hand.sentAt = time; + this.sendInk(hand); + } + }, + }); +})(); |