summaryrefslogtreecommitdiff
path: root/public/js/components/chess.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/components/chess.js')
-rw-r--r--public/js/components/chess.js742
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;
+ },
+ });
+})();