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/chess.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/chess.js')
| -rw-r--r-- | public/js/components/chess.js | 742 |
1 files changed, 742 insertions, 0 deletions
diff --git a/public/js/components/chess.js b/public/js/components/chess.js new file mode 100644 index 0000000..9b28c9b --- /dev/null +++ b/public/js/components/chess.js @@ -0,0 +1,742 @@ +/* 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; + }, + }); +})(); |