/* global AFRAME, THREE, NAF */ // A chess board and its pieces on a table. Nothing knows the rules: pick a // piece up and, while it's over the board, the square under it lights up (red // if a piece is already there); let go and it stands in the middle of that // square, and a piece that was there goes off to the side of the board. Let go // anywhere else and it drops onto the table, or the floor. In VR, squeeze grip // next to a piece (the one you'd get glows). On desktop, F picks up the piece // you're looking at; it hovers over wherever you look, and F again puts it there. resetChess() (the // world's action, or the buttons on the table in VR) sets the board up again. // In a shared room everyone sees the same board, newcomers included. // // The entity sits on the middle of the tabletop. White is at +z, so someone // who arrives facing -z sits down to play white. (function () { const SQUARE = 0.055; const HALF = SQUARE * 4; // half the playing area const BORDER = 0.022; const EDGE = HALF + BORDER; // half the whole board const THICK = 0.016; // the squares are on top const SCALE = 0.12; // the models' king is 0.89 tall const SLOT = 0.045; // spacing of taken pieces beside the board const GRAB_REACH = 0.06; // hand to a piece's middle line const LOOK_REACH = 2.2; // desktop: head to piece const LOOK_AIM = 0.03; // desktop: how far off your line of sight const BUTTON_REACH = 0.1; // hand to a reset button const TOUCH_REACH = 0.2; // wield.js's reach for touchables const ARMED_MS = 3000; // VR: how long the second trigger pull has to reset const POSE_MS = 66; // how often a held piece's pose is sent const SMOOTH = 0.05; // seconds to ease toward someone else's pose const GRAVITY = 9.8; const MODEL_DIR = 'assets/chess/'; const SOUNDS = ['chess-1', 'chess-2', 'chess-3']; const SOUND_DIR = 'assets/sounds/'; const BACK_RANK = ['rook', 'knight', 'bishop', 'queen', 'king', 'bishop', 'knight', 'rook']; const HEIGHT = { pawn: 0.6, rook: 0.7, knight: 0.72, bishop: 0.75, queen: 0.78, king: 0.89 }; const COLOR = { white: '#efe4cc', black: '#2e2622' }; // On desktop a held piece hovers this high over where you look, or else // floats low in front of you. const DESK_HOVER = 0.05; const DESK_AHEAD = 0.32; const DESK_DROP = 0.2; const Y = new THREE.Vector3(0, 1, 0); const v1 = new THREE.Vector3(); const v2 = new THREE.Vector3(); const v3 = new THREE.Vector3(); const q1 = new THREE.Quaternion(); const m1 = new THREE.Matrix4(); const euler = new THREE.Euler(0, 0, 0, 'YXZ'); 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 round = (x) => Math.round(x * 1e4) / 1e4; const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); // Where a piece can be: on a square, beside the board having been taken, or // anywhere else ("loose"), all in the board's own coordinates. function validAt(at) { if (!at || typeof at !== 'object') return null; if (Number.isInteger(at.sq) && at.sq >= 0 && at.sq < 64) return { sq: at.sq }; if (Number.isInteger(at.slot) && at.slot >= 0 && at.slot < 16) return { slot: at.slot }; if (finite(at.p, 3) && Number.isFinite(at.yaw)) return { p: at.p.slice(), yaw: at.yaw }; return null; } 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. } } // Distance from the ray (origin o, unit direction d) to point p, and how far along it. function offRay(o, d, p) { v3.subVectors(p, o); const along = v3.dot(d); return [v3.addScaledVector(d, -along).length(), along]; } AFRAME.registerComponent('chess', { schema: { table: { default: 0.4 }, // half the tabletop's width height: { default: 0.75 }, // the tabletop above the floor head: { type: 'selector', default: '#player' }, leftHand: { type: 'selector', default: '#left-hand' }, rightHand: { type: 'selector', default: '#right-hand' }, rig: { type: 'selector', default: '#rig' }, }, init() { this.group = new THREE.Group(); this.el.setObject3D('mesh', this.group); this.epoch = 0; // goes up each time the board is set up again this.pieces = []; this.lastPose = 0; this.armedAt = -Infinity; this.buildBoard(); this.buildButtons(); this.loadPieces(); this.loadSounds(); const hand = (el, vr) => ({ el, vr, piece: null, near: null }); this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)]; this.desk = hand(this.data.head, false); this.listeners = []; const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); this.listeners.push([target, type, fn, opts]); }; // The world is added after the rig, so wield.js hears grip first and a // pen it picks up wins; screens.js waits for both of us. for (const h of this.hands) { on(h.el, 'gripdown', () => this.gripDown(h)); on(h.el, 'gripup', () => this.release(h)); 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))); // F is wield.js's key too: take it first when it's about a piece. const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea, select'); on(window, 'keydown', (evt) => { if (evt.code !== 'KeyF' || evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return; if (this.desk.piece) { this.release(this.desk); } else { const wield = this.wield(); if (wield && wield.desk.tool) return; // F puts down the pen const piece = this.looked(); if (!piece) return; if (piece.by) this.data.rig.emit('wield-failed', { reason: 'held', label: 'that piece' }, false); else this.grab(this.desk, piece); } evt.stopImmediatePropagation(); }, true); if (typeof NAF !== 'undefined' && NAF.connection) { NAF.connection.subscribeToDataChannel('chess', (from, type, msg) => this.onMessage(msg)); on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId)); on(document.body, 'clientDisconnected', (evt) => { for (const piece of this.pieces) { if (piece.by !== evt.detail.clientId) continue; piece.by = null; piece.target = null; this.settle(piece, this.restingAt(piece), true); } }); } }, remove() { for (const [target, type, fn, opts] of this.listeners) target.removeEventListener(type, fn, opts); for (const h of this.hands.concat(this.desk)) this.release(h); if (typeof NAF !== 'undefined' && NAF.connection) NAF.connection.unsubscribeToDataChannel('chess'); for (const s of this.sounds || []) if (s.isPlaying) s.stop(); this.group.traverse((o) => { if (o.geometry) o.geometry.dispose(); }); this.el.removeObject3D('mesh'); }, // ---- making it --------------------------------------------------------------- buildBoard() { const wood = new THREE.MeshStandardMaterial({ color: '#4a2e1c', roughness: 0.55 }); const frame = new THREE.Mesh(new THREE.BoxGeometry(EDGE * 2, THICK - 0.002, EDGE * 2), wood); frame.position.y = (THICK - 0.002) / 2; this.group.add(frame); const light = new THREE.MeshStandardMaterial({ color: '#e3c99a', roughness: 0.5 }); const dark = new THREE.MeshStandardMaterial({ color: '#8a5a36', roughness: 0.5 }); const tile = new THREE.BoxGeometry(SQUARE, 0.002, SQUARE); for (let sq = 0; sq < 64; sq++) { const mesh = new THREE.Mesh(tile, (sq % 8 + Math.floor(sq / 8)) % 2 ? light : dark); mesh.position.copy(this.squareCenter(sq)).y -= 0.001; this.group.add(mesh); } // The square a held piece would go to. this.glowGeometry = new THREE.PlaneGeometry(SQUARE * 0.94, SQUARE * 0.94).rotateX(-Math.PI / 2); this.glowFree = new THREE.MeshBasicMaterial({ color: '#6fd0ff', transparent: true, opacity: 0.55, depthWrite: false }); this.glowTake = new THREE.MeshBasicMaterial({ color: '#ff6a4a', transparent: true, opacity: 0.6, depthWrite: false }); }, // A red button on each of the two free corners of the table: in VR, reach // for one and pull the trigger twice to set the board up again. buildButtons() { const t = this.data.table - 0.06; const red = new THREE.MeshStandardMaterial({ color: '#b8352a', roughness: 0.4 }); const base = new THREE.MeshStandardMaterial({ color: '#222222', roughness: 0.6 }); this.buttons = [new THREE.Vector3(-t, 0, t), new THREE.Vector3(t, 0, -t)].map((at, k) => { const button = new THREE.Group(); button.position.copy(at); const ring = new THREE.Mesh(new THREE.CylinderGeometry(0.032, 0.034, 0.008, 24), base); ring.position.y = 0.004; const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.024, 0.01, 24), red); cap.position.y = 0.012; button.add(ring, cap); this.group.add(button); const label = document.createElement('a-entity'); label.setAttribute('text', { value: 'reset', align: 'center', width: 0.35, color: '#333333' }); // Lying on the table, the right way up for the player it's nearest. label.object3D.position.set(at.x, 0.001, at.z + (k ? 0.048 : -0.048)); label.object3D.rotation.set(-Math.PI / 2, 0, k ? Math.PI : 0); this.el.appendChild(label); return button; }); const world = this; this.touchables = this.buttons.map((button) => ({ name: 'chess-reset', label: () => (world.el.sceneEl.time - world.armedAt < ARMED_MS ? 'again to reset the board' : 'set up the chess board'), // Closer than for most touchables, so reaching for a piece doesn't offer it. distance: (p) => button.getWorldPosition(v1).distanceTo(p) + TOUCH_REACH - BUTTON_REACH, use: () => { const now = world.el.sceneEl.time; if (now - world.armedAt < ARMED_MS) { world.armedAt = -Infinity; world.resetChess(); } else { world.armedAt = now; } }, })); }, loadPieces() { const loader = new THREE.GLTFLoader(); const kinds = [...new Set(BACK_RANK.concat('pawn'))]; const load = (kind) => new Promise((resolve, reject) => loader.load(`${MODEL_DIR}${kind}.glb`, resolve, undefined, reject)) .then((gltf) => { let geometry = null; let material = null; gltf.scene.traverse((o) => { if (o.isMesh && !geometry) { geometry = o.geometry; material = o.material; } }); return [kind, geometry, material]; }); Promise.all(kinds.map(load)).then((loaded) => { const models = {}; for (const [kind, geometry, material] of loaded) models[kind] = geometry; // Each side's own look, and a glow for the piece your hand would take. const template = loaded[0][2]; this.materials = {}; for (const side of ['white', 'black']) { const plain = template.clone(); plain.color.set(COLOR[side]); plain.roughness = side === 'white' ? 0.45 : 0.35; const lit = plain.clone(); lit.emissive.set('#3d9bd6'); lit.emissiveIntensity = side === 'white' ? 0.45 : 0.8; this.materials[side] = { plain, lit }; } this.makePieces(models); }).catch((err) => console.warn("Couldn't load the chess pieces", err)); }, // Pieces 0–15 are white's, 16–31 black's; each starts on its own square. makePieces(models) { for (let i = 0; i < 32; i++) { const side = i < 16 ? 'white' : 'black'; const n = i % 16; const file = n % 8; const rank = side === 'white' ? (n < 8 ? 0 : 1) : (n < 8 ? 7 : 6); const kind = n < 8 ? BACK_RANK[file] : 'pawn'; const mesh = new THREE.Mesh(models[kind], this.materials[side].plain); mesh.scale.setScalar(SCALE); const object = new THREE.Group(); object.add(mesh); this.group.add(object); const glow = new THREE.Mesh(this.glowGeometry, this.glowFree); glow.visible = false; this.group.add(glow); const piece = { i, side, kind, mesh, object, glow, home: rank * 8 + file, height: HEIGHT[kind] * SCALE, at: null, from: null, holder: null, by: null, v: 0, target: null, fall: null, lit: false, }; piece.at = { sq: piece.home }; this.pose(piece, piece.at); this.pieces.push(piece); } }, loadSounds() { const sceneEl = this.el.sceneEl; if (!sceneEl.audioListener) { sceneEl.audioListener = new THREE.AudioListener(); if (sceneEl.camera) sceneEl.camera.add(sceneEl.audioListener); sceneEl.addEventListener('camera-set-active', (e) => e.detail.cameraEl.getObject3D('camera').add(sceneEl.audioListener)); } const loader = new THREE.AudioLoader(); Promise.all(SOUNDS.map((name) => new Promise((resolve, reject) => loader.load(`${SOUND_DIR}${name}.ogg`, resolve, undefined, reject)))) .then((buffers) => { this.buffers = buffers; this.sounds = [0, 1, 2, 3].map(() => { const sound = new THREE.PositionalAudio(sceneEl.audioListener); sound.setRefDistance(0.6); sound.setRolloffFactor(1.2); this.group.add(sound); return sound; }); }) .catch((err) => console.warn("Couldn't load the chess sounds", err)); }, // A wooden knock where a piece lands, a little different each time. knock(at, loud) { if (!this.sounds) return; const sound = this.sounds.find((s) => !s.isPlaying); if (!sound) return; sound.setBuffer(this.buffers[Math.floor(Math.random() * this.buffers.length)]); sound.setPlaybackRate(0.92 + Math.random() * 0.22); sound.setVolume(loud * (0.75 + Math.random() * 0.25)); sound.position.copy(at); sound.play(); }, // ---- where pieces go ----------------------------------------------------------- // Square 0 is a1 (white's left), 7 is h1, 63 is h8. squareCenter(sq, out = new THREE.Vector3()) { return out.set((sq % 8 - 3.5) * SQUARE, THICK, (3.5 - Math.floor(sq / 8)) * SQUARE); }, // The square under a point, if it's over the board. squareAt(p) { if (Math.abs(p.x) > EDGE || Math.abs(p.z) > EDGE || p.y < -0.05) return null; const file = clamp(Math.floor((p.x + HALF) / SQUARE), 0, 7); const rank = clamp(Math.floor((HALF - p.z) / SQUARE), 0, 7); return rank * 8 + file; }, // Pieces face the other side: the knights' heads point at the opponent. facing(piece) { return piece.side === 'white' ? Math.PI : 0; }, // Where a piece stands for `at`: { p, yaw } in the board's coordinates. placeOf(piece, at) { if (at.sq !== undefined) return { p: this.squareCenter(at.sq), yaw: this.facing(piece) }; if (at.slot !== undefined) { // Taken pieces stand in two rows beside the board, on the right of // whoever took them: black's on white's right, white's on black's. const s = piece.side === 'black' ? 1 : -1; const col = Math.floor(at.slot / 8); const row = at.slot % 8; const p = new THREE.Vector3(s * (EDGE + 0.035 + col * SLOT), 0, s * (EDGE + 0.1 - row * SLOT)); return { p, yaw: this.facing(piece) }; } return { p: new THREE.Vector3().fromArray(at.p), yaw: at.yaw }; }, // Puts a piece straight there. pose(piece, at) { const { p, yaw } = this.placeOf(piece, at); piece.object.position.copy(p); piece.object.quaternion.setFromAxisAngle(Y, yaw); }, // Where a piece that's let go of here lands: on the table under it, or on the floor. looseAt(piece) { const p = piece.object.position; const t = this.data.table; const y = Math.abs(p.x) < t && Math.abs(p.z) < t && p.y > -0.02 ? 0 : -this.data.height; euler.setFromQuaternion(piece.object.quaternion); return { p: [round(p.x), y, round(p.z)], yaw: round(euler.y) }; }, // Where a piece that's let go of here goes: the square under it, or else // down onto the table or floor. restingAt(piece) { const sq = this.squareAt(piece.object.position); return sq !== null ? { sq } : this.looseAt(piece); }, // Moves a piece to `at`: it drops straight down if that's under it, or else // glides there; then it knocks. settle(piece, at, sound) { piece.at = at; const { p, yaw } = this.placeOf(piece, at); const from = piece.object.position.clone(); const drop = from.y - p.y; const across = Math.hypot(from.x - p.x, from.z - p.z); const falling = drop > 0.005 && across < 0.04; piece.fall = { from, to: p, q0: piece.object.quaternion.clone(), q1: new THREE.Quaternion().setFromAxisAngle(Y, yaw), t: 0, falling, sound, dur: falling ? Math.sqrt(2 * drop / GRAVITY) : clamp(from.distanceTo(p) / 1.5, 0.1, 0.35), }; }, animate(piece, dt) { const f = piece.fall; f.t += dt; const k = Math.min(1, f.t / f.dur); const obj = piece.object; if (f.falling) { obj.position.lerpVectors(f.from, f.to, k); obj.position.y = Math.max(f.to.y, f.from.y - 0.5 * GRAVITY * f.t * f.t); } else { obj.position.lerpVectors(f.from, f.to, k * k * (3 - 2 * k)); } obj.quaternion.slerpQuaternions(f.q0, f.q1, Math.min(1, f.t / Math.min(f.dur, 0.12))); if (k < 1) return; piece.fall = null; if (f.sound) this.knock(obj.position, f.falling ? clamp(0.4 + (f.from.y - f.to.y) * 2, 0.4, 1) : 0.5); }, occupant(sq, except) { return this.pieces.find((p) => p !== except && p.at && p.at.sq === sq); }, freeSlot(side) { const used = new Set(this.pieces.filter((p) => p.side === side && p.at && p.at.slot !== undefined).map((p) => p.at.slot)); for (let slot = 0; slot < 16; slot++) if (!used.has(slot)) return slot; return 15; }, // Puts down a piece you had, taking whatever was on its square. place(piece, at) { if (at.sq !== undefined) { const taken = this.occupant(at.sq, piece); if (taken) { taken.v++; this.settle(taken, { slot: this.freeSlot(taken.side) }, true); this.send({ op: 'put', i: taken.i, v: taken.v, at: taken.at }, true); } } piece.v++; this.settle(piece, at, true); this.send({ op: 'put', i: piece.i, v: piece.v, at: piece.at }, true); }, // Everything back where it starts, for everyone. resetChess() { if (!this.pieces.length) return; this.setUp(this.epoch + 1); this.send({ op: 'reset' }, true); this.knock(v1.set(0, THICK, 0), 0.8); }, setUp(epoch) { this.epoch = epoch; for (const piece of this.pieces) { if (piece.holder) this.letGoOf(piece); Object.assign(piece, { at: { sq: piece.home }, by: null, v: 0, target: null, fall: null }); this.pose(piece, piece.at); } }, // ---- holding --------------------------------------------------------------------- wield() { const el = this.data.rig; return el && el.components.wield; }, myId() { return online() ? NAF.clientId : 'me'; }, // Whether a hand of yours (its entity) holds a piece; for screens.js. holds(handEl) { return this.hands.some((h) => h.el === handEl && h.piece); }, // The base and top of a piece, in the world. ends(piece) { piece.object.updateWorldMatrix(true, false); const base = piece.object.getWorldPosition(new THREE.Vector3()); const up = Y.clone().applyQuaternion(piece.object.getWorldQuaternion(q1)); return [base, base.clone().addScaledVector(up, piece.height)]; }, // The piece nearest an empty hand, within reach. nearest(hand) { const at = hand.el.object3D.getWorldPosition(v2); let best = null; let bestDistance = GRAB_REACH; for (const piece of this.pieces) { if (piece.by || piece.holder) continue; const [a, b] = this.ends(piece); v3.subVectors(b, a); const t = clamp(v3.dot(v1.subVectors(at, a)) / v3.lengthSq(), 0, 1); const d = at.distanceTo(v1.copy(a).addScaledVector(v3, t)); if (d < bestDistance) { best = piece; bestDistance = d; } } return best; }, // Desktop: the piece nearest your line of sight, within reach. looked() { 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; for (const piece of this.pieces) { if (piece.holder) continue; const [a, b] = this.ends(piece); for (let k = 0; k <= 6; k++) { const [aim, along] = offRay(eye, ahead, v2.lerpVectors(a, b, k / 6)); if (along < 0 || along > LOOK_REACH || aim >= bestAim) continue; best = piece; bestAim = aim; } } return best; }, // Desktop: where you're looking, as somewhere to put a piece: a square, // a spot on the table, or nowhere. lookedAt(piece) { const head = this.data.head.object3D; const o = this.group.worldToLocal(head.getWorldPosition(new THREE.Vector3())); m1.copy(this.group.matrixWorld).invert(); const d = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1)).transformDirection(m1); if (d.y > -0.05) return null; const onBoard = o.clone().addScaledVector(d, (THICK - o.y) / d.y); const sq = this.squareAt(onBoard); if (sq !== null && onBoard.distanceTo(o) < LOOK_REACH) return { sq }; const onTable = o.clone().addScaledVector(d, -o.y / d.y); const t = this.data.table - 0.02; if (Math.abs(onTable.x) < t && Math.abs(onTable.z) < t && onTable.distanceTo(o) < LOOK_REACH) { euler.setFromQuaternion(piece.object.quaternion); return { p: [round(onTable.x), 0, round(onTable.z)], yaw: round(euler.y) }; } return null; }, gripDown(hand) { if (hand.piece || !this.pieces.length) return; const wield = this.wield(); const held = wield && wield.hands.find((h) => h.el === hand.el); if (held && held.tool) return; // it picked up a pen instead const piece = this.nearest(hand); if (piece) this.grab(hand, piece); }, grab(hand, piece) { if (piece.holder) this.letGoOf(piece); // from your other hand piece.from = piece.at; piece.at = null; piece.fall = null; piece.target = null; piece.by = null; piece.holder = hand; hand.piece = piece; // In VR it stays just as it was in your hand; on desktop it floats in front of you. if (hand.vr) hand.el.object3D.attach(piece.object); piece.v++; this.sendPose(piece, true); if (hand.vr) buzz(hand.el, 0.4, 30); }, // Takes a piece out of your hand without putting it anywhere. letGoOf(piece) { const hand = piece.holder; if (!hand) return; hand.piece = null; piece.holder = null; piece.glow.visible = false; this.group.attach(piece.object); }, release(hand) { const piece = hand.piece; if (!piece) return; let at; if (hand.vr) { this.letGoOf(piece); at = this.restingAt(piece); buzz(hand.el, 0.25, 20); } else { at = this.lookedAt(piece) || piece.from || this.looseAt(piece); this.letGoOf(piece); } this.place(piece, at); }, // ---- syncing ------------------------------------------------------------------------- send(msg, guaranteed, to) { if (!online()) return; msg.e = this.epoch; if (to) NAF.connection.sendDataGuaranteed(to, 'chess', msg); else if (guaranteed) NAF.connection.broadcastDataGuaranteed('chess', msg); else NAF.connection.broadcastData('chess', msg); }, // A held piece's pose in the board's coordinates. sendPose(piece, guaranteed, to) { if (!online()) return; piece.object.updateWorldMatrix(true, false); m1.copy(this.group.matrixWorld).invert().multiply(piece.object.matrixWorld); const p = new THREE.Vector3(); const q = new THREE.Quaternion(); m1.decompose(p, q, v1); this.send({ op: 'pose', i: piece.i, v: piece.v, by: piece.holder ? this.myId() : piece.by, p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)], }, guaranteed, to); }, onMessage(msg) { if (!msg || !Number.isInteger(msg.e) || msg.e < this.epoch || !this.pieces.length) return; if (msg.e > this.epoch) this.setUp(msg.e); if (msg.op === 'reset') return; const piece = Number.isInteger(msg.i) && this.pieces[msg.i]; if (!piece || !Number.isInteger(msg.v) || msg.v < piece.v) return; const by = msg.op === 'pose' && typeof msg.by === 'string' ? msg.by : null; const current = piece.holder ? this.myId() : piece.by; // Two people took it at once: the lower id keeps it, the same for everyone. if (msg.v === piece.v && by !== current && (by || '￿') > (current || '￿')) return; if (msg.op === 'pose') { if (!finite(msg.p, 3) || !finite(msg.q, 4)) return; const q = new THREE.Quaternion().fromArray(msg.q); if (q.lengthSq() < 1e-6) return; piece.v = msg.v; if (piece.holder && by !== this.myId()) this.letGoOf(piece); if (piece.holder) return; if (piece.at) piece.from = piece.at; Object.assign(piece, { at: null, fall: null, by, target: { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() } }); } else if (msg.op === 'put') { const at = validAt(msg.at); if (!at) return; piece.v = msg.v; if (piece.holder) this.letGoOf(piece); piece.by = null; piece.target = null; this.settle(piece, at, true); } }, // Tells someone who just arrived about every piece that's been moved since // the board was last set up. Everyone here does this; they keep the newest. catchUp(clientId) { if (!online()) return; if (this.epoch > 0) this.send({ op: 'reset' }, true, clientId); for (const piece of this.pieces) { if (!piece.v) continue; if (piece.holder || piece.by) this.sendPose(piece, true, clientId); else if (piece.at) this.send({ op: 'put', i: piece.i, v: piece.v, at: piece.at }, true, clientId); } }, // ---- every frame ----------------------------------------------------------------------- tick(time, delta) { if (!this.pieces.length) return; const dt = Math.min(delta, 100) / 1000; const ease = 1 - Math.exp(-dt / SMOOTH); const vr = this.el.sceneEl.is('vr-mode'); if (this.desk.piece) this.hover(this.desk.piece, ease); for (const piece of this.pieces) { if (piece.target && !piece.holder) { piece.object.position.lerp(piece.target.p, ease); piece.object.quaternion.slerp(piece.target.q, ease); } if (piece.fall) this.animate(piece, dt); this.showSquare(piece); } // The piece each empty hand would take glows. const wield = this.wield(); const near = new Set(); for (const hand of this.hands) { const busy = hand.piece || !vr || !hand.el.object3D.visible || (wield && wield.hands.some((h) => h.el === hand.el && h.tool)); const piece = busy ? null : this.nearest(hand); if (piece && piece !== hand.near) buzz(hand.el, 0.1, 12); hand.near = piece; if (piece) near.add(piece); } if (!vr && !this.desk.piece) { const piece = this.looked(); if (piece && !piece.by) near.add(piece); } for (const piece of this.pieces) { const lit = near.has(piece); if (lit !== piece.lit) { piece.lit = lit; piece.mesh.material = this.materials[piece.side][lit ? 'lit' : 'plain']; } } if (time - this.lastPose >= POSE_MS) { this.lastPose = time; for (const piece of this.pieces) if (piece.holder) this.sendPose(piece, false); } }, // Desktop: a held piece hovers over where you're looking, upright, or // floats low in front of you if that's nowhere. hover(piece, ease) { const at = this.lookedAt(piece); let p; if (at) { p = this.placeOf(piece, at).p; p.y += DESK_HOVER; } else { const head = this.data.head.object3D; euler.setFromQuaternion(head.getWorldQuaternion(q1)); p = v2.set(-Math.sin(euler.y), 0, -Math.cos(euler.y)).multiplyScalar(DESK_AHEAD).add(head.getWorldPosition(v1)); p.y -= DESK_DROP; this.group.worldToLocal(p); } piece.object.position.lerp(p, Math.min(1, ease * 2)); piece.object.quaternion.setFromAxisAngle(Y, this.facing(piece)); }, // Lights the square a held piece would go to: under it in VR or for // someone else, or where you're looking on desktop. showSquare(piece) { let sq = null; if (piece.holder === this.desk) { const at = this.lookedAt(piece); sq = at && at.sq !== undefined ? at.sq : null; } else if (piece.holder || piece.by) { piece.object.updateWorldMatrix(true, false); sq = this.squareAt(this.group.worldToLocal(piece.object.getWorldPosition(v1))); } const glow = piece.glow; glow.visible = sq !== null; if (sq === null) return; if (sq !== piece.glowSq && piece.holder && piece.holder.vr) buzz(piece.holder.el, 0.08, 10); piece.glowSq = sq; this.squareCenter(sq, glow.position).y += 0.0015; glow.material = this.occupant(sq, piece) ? this.glowTake : this.glowFree; }, }); })();