/* global AFRAME, THREE */ // The world: a log cabin at night. A stone fireplace with a chess table and // three armchairs, a bookshelf, a door, a magick circle and triangle painted // on the floor under a hanging lantern, two wands on the windowsill to draw // with, and a big window over a lake, where the sky (sky.js, pinMoon) holds the // real Moon. Everything is built here from boxes and cylinders with painted // canvas textures; only the pines outside are models (Kenney's Nature Kit, // like the campfire's). The chess board is its own entity (chess.js) in the // world's template, and the table is built under wherever it is. // // The room's floor is at y = 0 and its middle at the origin; -z is the window // (and the lake), +x the fireplace, -x the bookshelf, +z the door. (function () { const ENV_DIR = 'assets/env/'; const SEED = 1904; const ROOM = { halfX: 3, halfZ: 2.5 }; // inside faces of the walls const LOG = 0.13; // log radius const COURSES = 9; const EAVE = COURSES * LOG * 2; // 2.34 m, where the roof starts const RISE = 1.4; // ridge height above the eaves const OVERHANG = 0.22; // logs past the corners const CHINK = 0.07; // mortar this far in from the log centres const WINDOW = { half: 0.65, sill: LOG * 2 * 3, top: EAVE }; // a hole of whole courses const DOOR = { x: -1.7, half: 0.48, top: LOG * 2 * 8 }; const HEARTH = { front: 2.35, half: 0.85, opening: 0.43, openTop: 0.75, top: 1.25 }; const SHELF = { front: -2.62, z: 1.25, half: 0.8, height: 2.0 }; const LAKE = { x: 0, z: -88, r: 72, y: -0.22 }; const MARGIN = 0.25; // how close your head gets to a wall const LEAN = 0.08; // and to the chess table, so you can reach across it // Where the chess table goes if the template has no board; an armchair // stands at each side of it, and a third in the corner by the hearth. const TABLE = { x: 0.6, z: 0, half: 0.4, height: 0.62 }; const FIRESIDE = { x: 1.65, z: -1.7 }; const RUG_R = 1.1; // The magick circle: its middle, radius, and the triangle's gap from it and // side. East, toward the triangle, points into the corner by the window, // where the lantern hangs over the triangle. (The book's are 9 ft across, // 2 ft off and 3 ft; these are smaller to fit the room.) const CIRCLE = { x: -1.45, z: -0.95, r: 0.85, gap: 0.42, side: 0.75 }; const RING = 0.875; // the inner edge of the names' band, × r const LAMPS = 1.16; // how far out the nine pentagrams are, × r const PAINT = 1.34; // half the circle's painted square, × r const TRI_PAINT = 1.1; // the triangle's painted square (m) function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // --- painted textures -------------------------------------------------------- function canvasTexture(width, height, paint, { repeat = true } = {}) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; paint(canvas.getContext('2d'), width, height); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping; return texture; } function hsl(h, s, l, a = 1) { return `hsla(${h},${s}%,${l}%,${a})`; } // Boards running along u, eight to a tile, with seams, butt joints and grain. function planks(rand, { hue = 28, light = 34 } = {}) { return canvasTexture(512, 512, (ctx, w, h) => { const rows = 8; const rh = h / rows; for (let r = 0; r < rows; r++) { let x = -rand() * w; while (x < w) { const len = w * (0.45 + rand() * 0.6); const l = light + (rand() - 0.5) * 9; ctx.fillStyle = hsl(hue + (rand() - 0.5) * 6, 38, l); ctx.fillRect(x, r * rh, len, rh); for (let g = 0; g < 9; g++) { ctx.strokeStyle = hsl(hue, 40, l - 8 - rand() * 6, 0.35); ctx.lineWidth = 0.6 + rand(); ctx.beginPath(); const y = r * rh + rand() * rh; ctx.moveTo(x, y); ctx.bezierCurveTo(x + len / 3, y + (rand() - 0.5) * 6, x + (2 * len) / 3, y + (rand() - 0.5) * 6, x + len, y + (rand() - 0.5) * 4); ctx.stroke(); } if (rand() < 0.25) { ctx.fillStyle = hsl(hue, 40, l - 14, 0.6); ctx.beginPath(); ctx.ellipse(x + rand() * len, r * rh + rh * (0.3 + rand() * 0.4), 3 + rand() * 4, 2 + rand() * 2, 0, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = 'rgba(12,6,2,0.85)'; ctx.fillRect(x, r * rh, 2, rh); x += len; } ctx.fillStyle = 'rgba(12,6,2,0.9)'; ctx.fillRect(0, r * rh, w, 2.5); } }); } // Bark-less logs: streaks along their length (v), which stretching can't spoil. function logGrain(rand) { return canvasTexture(128, 16, (ctx, w, h) => { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); for (let i = 0; i < 70; i++) { ctx.fillStyle = `rgba(60,35,15,${0.05 + rand() * 0.18})`; ctx.fillRect(rand() * w, 0, 0.5 + rand() * 2.5, h); } }); } // Fieldstones in dark mortar, about 16 to a metre-wide tile. function stones(rand) { return canvasTexture(512, 512, (ctx, w, h) => { ctx.fillStyle = '#2e2a26'; ctx.fillRect(0, 0, w, h); const rows = 7; const rh = h / rows; for (let r = 0; r < rows; r++) { let x = -rand() * 60; while (x < w) { const sw = 60 + rand() * 70; const sh = rh * (0.78 + rand() * 0.16); const cx = x + sw / 2; const cy = r * rh + rh / 2 + (rand() - 0.5) * 6; const l = 34 + rand() * 20; const g = ctx.createRadialGradient(cx - sw * 0.15, cy - sh * 0.2, 2, cx, cy, sw * 0.6); g.addColorStop(0, hsl(25 + rand() * 20, 8 + rand() * 10, l + 8)); g.addColorStop(1, hsl(25 + rand() * 20, 8 + rand() * 8, l - 12)); ctx.fillStyle = g; ctx.beginPath(); ctx.roundRect(cx - sw / 2 + 3, cy - sh / 2, sw - 6, sh, Math.min(sw, sh) * 0.35); ctx.fill(); x += sw; } } // Wrap the edge stones round, so the tile repeats without a seam. ctx.drawImage(ctx.canvas, w - 4, 0, 4, h, 0, 0, 4, h); }); } // A round rug: wine red, a gold border, and a unicursal hexagram in a circle. function rug() { return canvasTexture(1024, 1024, (ctx, w) => { const c = w / 2; const ring = (r, color, width) => { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.arc(c, c, r, 0, Math.PI * 2); ctx.stroke(); }; ctx.fillStyle = '#5b1a1c'; ctx.beginPath(); ctx.arc(c, c, c - 4, 0, Math.PI * 2); ctx.fill(); ring(c - 30, '#b88a3e', 14); ring(c - 58, '#b88a3e', 4); for (let i = 0; i < 48; i++) { const a = (i / 48) * Math.PI * 2; ctx.fillStyle = '#c39a52'; ctx.beginPath(); ctx.arc(c + Math.cos(a) * (c - 44), c + Math.sin(a) * (c - 44), 5, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = '#2a1626'; ctx.beginPath(); ctx.arc(c, c, c - 120, 0, Math.PI * 2); ctx.fill(); ring(c - 120, '#b88a3e', 6); // The unicursal hexagram in one stroke: from the top point down to the // lower right, through the middle to the upper left, down to the bottom, // up to the upper right, through the middle to the lower left and back // up to the top. A five-petalled rose covers the crossing. const R = c - 150; const pts = [90, 330, 150, 270, 30, 210].map((d) => { const a = (d * Math.PI) / 180; return [c + Math.cos(a) * R, c - Math.sin(a) * R]; }); ctx.strokeStyle = '#c9a457'; ctx.lineWidth = 12; ctx.lineJoin = 'miter'; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (const [x, y] of pts.slice(1)) ctx.lineTo(x, y); ctx.closePath(); ctx.stroke(); for (let i = 0; i < 5; i++) { const a = (i / 5) * Math.PI * 2 - Math.PI / 2; ctx.fillStyle = '#9c2a30'; ctx.strokeStyle = '#c9a457'; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(c + Math.cos(a) * 30, c + Math.sin(a) * 30, 30, 21, a, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.fillStyle = '#c9a457'; ctx.beginPath(); ctx.arc(c, c, 13, 0, Math.PI * 2); ctx.fill(); }, { repeat: false }); } const VERMILION = '#e2452f'; const PAINT_GOLD = '#d2ad52'; const LETTERS = "Georgia, 'Times New Roman', serif"; // Scuffs in floor paint: specks rubbed away. function wear(ctx, size, rand) { ctx.save(); ctx.globalCompositeOperation = 'destination-out'; for (let i = 0; i < size * 1.2; i++) { ctx.fillStyle = `rgba(0,0,0,${0.15 + rand() * 0.5})`; ctx.beginPath(); ctx.ellipse(rand() * size, rand() * size, 1 + rand() * 4, 0.5 + rand() * 1.5, rand() * Math.PI, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } // The circle of Book 4, Part II, ch. II, as DuQuette draws it ("Modern // Circle", Aleister Crowley's Illustrated Goetia): the circle green with the // names in vermilion, the Tau of ten squares (the Sephiroth) in the midst, // the three diamonds where the sides of a triangle escribed about the Tau // would cross, and nine pentagrams without, each with its lamp. East is up. function magickCircle(rand) { return canvasTexture(2048, 2048, (ctx, w) => { const c = w / 2; const R = c / PAINT; const inner = R * RING; ctx.fillStyle = '#2d6a3e'; ctx.beginPath(); ctx.arc(c, c, R, 0, Math.PI * 2); ctx.arc(c, c, inner, 0, Math.PI * 2, true); ctx.fill(); ctx.strokeStyle = PAINT_GOLD; ctx.lineWidth = 5; for (const r of [R, inner]) { ctx.beginPath(); ctx.arc(c, c, r, 0, Math.PI * 2); ctx.stroke(); } // The names, clockwise from a point in the East, tops outward. const names = ['HADIT', 'RA HOOR KHVIT', 'CHAOS', 'BABALON', 'LAYLAH', 'PERDVRABO', 'NVIT']; const glyphs = names.flatMap((name) => ['·', ...name]); const weight = (g) => (g === '·' ? 1.8 : g === ' ' ? 0.6 : 1); const total = glyphs.reduce((sum, g) => sum + weight(g), 0); const band = R - inner; const mid = (R + inner) / 2; ctx.fillStyle = VERMILION; ctx.font = `bold ${Math.round(band * 0.62)}px ${LETTERS}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; let at = -weight(glyphs[0]) / 2; for (const g of glyphs) { const a = ((at + weight(g) / 2) / total) * Math.PI * 2; at += weight(g); ctx.save(); ctx.translate(c + Math.sin(a) * mid, c - Math.cos(a) * mid); ctx.rotate(a); if (g === '·') { ctx.beginPath(); ctx.arc(0, 0, band * 0.08, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillText(g, 0, band * 0.04); } ctx.restore(); } // The Tau: five squares above a bar of five, its middle on the centre. const s = R * 0.125; ctx.fillStyle = VERMILION; ctx.strokeStyle = '#4a120c'; ctx.lineWidth = 4; for (let i = 0; i < 10; i++) { const x = i < 5 ? c - s / 2 : c - 2.5 * s + (i - 5) * s; const y = i < 5 ? c - 3 * s + i * s : c + 2 * s; ctx.fillRect(x, y, s, s); ctx.strokeRect(x, y, s, s); } // Only the corners of the triangle about the Tau are marked: diamonds // just inside the circle, where two of its sides would overlap. const tip = inner - R * 0.03; const long = R * 0.2; const wide = long * Math.tan(Math.PI / 6); ctx.fillStyle = PAINT_GOLD; for (let k = 0; k < 3; k++) { ctx.save(); ctx.translate(c, c); ctx.rotate((k * 2 * Math.PI) / 3); ctx.beginPath(); ctx.moveTo(0, -tip); ctx.lineTo(wide / 2, -tip + long / 2); ctx.lineTo(0, -tip + long); ctx.lineTo(-wide / 2, -tip + long / 2); ctx.closePath(); ctx.fill(); ctx.restore(); } // Nine pentagrams without, equidistant, the first in the East, each // pointing outward. Their lamps stand in the middles (buildCircle). ctx.strokeStyle = PAINT_GOLD; ctx.lineWidth = 6; ctx.lineJoin = 'miter'; for (let k = 0; k < 9; k++) { const a = (k / 9) * Math.PI * 2; const pr = R * 0.1; ctx.save(); ctx.translate(c + Math.sin(a) * R * LAMPS, c - Math.cos(a) * R * LAMPS); ctx.rotate(a); ctx.beginPath(); for (let i = 0; i < 5; i++) { const b = -Math.PI / 2 + (i * 4 * Math.PI) / 5; ctx[i ? 'lineTo' : 'moveTo'](Math.cos(b) * pr, Math.sin(b) * pr); } ctx.closePath(); ctx.stroke(); ctx.restore(); } wear(ctx, w, rand); }, { repeat: false }); } // DuQuette's triangle: AGAPE, THELEMA and ABRAHADABRA about it in // vermilion like the circle's names, the inside white and its circle green // as in the Goetia's own. The apex (up) points away from the circle, and the // triangle's centroid is the middle of the texture. function triangleOfArt(rand) { return canvasTexture(1024, 1024, (ctx, w) => { const c = w / 2; const px = w / TRI_PAINT; const side = CIRCLE.side * px; const h = (side * Math.sqrt(3)) / 2; const apex = [c, c - (2 * h) / 3]; const left = [c - side / 2, c + h / 3]; const right = [c + side / 2, c + h / 3]; ctx.fillStyle = '#e4dbc6'; ctx.strokeStyle = '#17110d'; ctx.lineWidth = 8; ctx.lineJoin = 'miter'; ctx.beginPath(); ctx.moveTo(...apex); ctx.lineTo(...left); ctx.lineTo(...right); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#1f4b2b'; ctx.lineWidth = 5; ctx.beginPath(); ctx.arc(c, c, h / 3 - 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // The names outside the sides, tops outward, and under the base. const size = Math.round(px * 0.07); ctx.fillStyle = VERMILION; ctx.font = `bold ${size}px ${LETTERS}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const along = (text, [x0, y0], [x1, y1]) => { const dx = x1 - x0; const dy = y1 - y0; const len = Math.hypot(dx, dy); ctx.save(); ctx.translate((x0 + x1) / 2 + (dy / len) * size * 0.85, (y0 + y1) / 2 - (dx / len) * size * 0.85); ctx.rotate(Math.atan2(dy, dx)); ctx.fillText(text, 0, 0); ctx.restore(); }; along('AGAPE', left, apex); along('THELEMA', apex, right); ctx.fillText('ABRAHADABRA', c, c + h / 3 + size * 0.95); wear(ctx, w, rand); }, { repeat: false }); } function glow(inner, outer) { return canvasTexture(64, 64, (ctx, w) => { const g = ctx.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); g.addColorStop(0, inner); g.addColorStop(0.35, outer); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, w); }, { repeat: false }); } // Broken glints for the Moon's path on the water; it scrolls toward you. function glints(rand) { return canvasTexture(64, 256, (ctx, w, h) => { for (let i = 0; i < 260; i++) { const y = rand() * h; const x = w / 2 + (rand() - 0.5) * w * Math.pow(rand(), 0.6); ctx.fillStyle = `rgba(255,255,255,${0.25 + rand() * 0.75})`; ctx.fillRect(x, y, 1 + rand() * 6, 1); } }); } // Box texture coordinates in metres / tile, so a texture keeps its scale on boxes of any size. function boxUV(geometry, w, h, d, tile) { const uv = geometry.attributes.uv; const faces = [[d, h], [d, h], [w, d], [w, d], [w, h], [w, h]]; // px nx py ny pz nz for (let f = 0; f < 6; f++) { for (let i = f * 4; i < f * 4 + 4; i++) uv.setXY(i, uv.getX(i) * faces[f][0] / tile, uv.getY(i) * faces[f][1] / tile); } return geometry; } AFRAME.registerComponent('cabin', { schema: { fireSound: { default: '' }, sky: { type: 'selector', default: '#sky' }, }, init() { this.rand = mulberry32(SEED); this.group = new THREE.Group(); this.el.setObject3D('mesh', this.group); this.animated = []; this.spawn = { x: -0.5, z: 1.45, yaw: 0 }; // facing the window this.screen = { x: 0.9, y: 1.45, z: ROOM.halfZ - 0.06, yaw: Math.PI, width: 1.8 }; // beside the door // Child entities start first, so the board is there already; its // table goes under it. const chess = this.chess(); const board = chess && chess.el.object3D.position; this.table = chess ? { x: board.x, z: board.z, half: chess.data.table, height: chess.data.height } : TABLE; this.touchables = chess ? chess.touchables : []; // [x, z, yaw] of the armchairs: white's and black's facing across the // board (white is at +z, see chess.js), and one by the hearth facing the fire. const t = this.table; const across = t.half + 0.4; const fire = { x: HEARTH.front + 0.28, z: 0 }; this.chairs = [ [t.x, t.z + across, 0], [t.x, t.z - across, Math.PI], [FIRESIDE.x, FIRESIDE.z, Math.atan2(-(fire.x - FIRESIDE.x), -(fire.z - FIRESIDE.z))], ]; this.seats = this.chairs.map(([x, z, yaw]) => ({ x, y: 0.45, z, yaw })); const r = this.rand; this.mat = { log: new THREE.MeshLambertMaterial({ map: logGrain(r) }), chink: new THREE.MeshLambertMaterial({ color: '#6e6150', side: THREE.DoubleSide }), floor: new THREE.MeshLambertMaterial({ map: planks(r, { hue: 26, light: 30 }) }), boards: new THREE.MeshLambertMaterial({ map: planks(r, { hue: 30, light: 36 }) }), wood: new THREE.MeshLambertMaterial({ color: '#5a3a22' }), darkWood: new THREE.MeshLambertMaterial({ color: '#2f1e12' }), stone: new THREE.MeshLambertMaterial({ map: stones(r) }), soot: new THREE.MeshLambertMaterial({ color: '#0d0a08', side: THREE.BackSide }), iron: new THREE.MeshLambertMaterial({ color: '#1c1a19' }), fabric: new THREE.MeshLambertMaterial({ color: '#6a2428' }), wax: new THREE.MeshLambertMaterial({ color: '#efe3c4', emissive: '#3a2a12' }), glass: new THREE.MeshBasicMaterial({ color: '#9fb4d8', transparent: true, opacity: 0.07, depthWrite: false }), }; this.flameTexture = glow('rgba(255,240,200,1)', 'rgba(255,150,50,0.5)'); this.buildWalls(); this.buildRoof(); this.buildWindow(); this.buildDoor(); this.buildFireplace(); this.buildFurniture(); this.buildBookshelf(); this.buildCircle(); this.buildWands(); this.buildOutside(); }, tick(time, delta) { const t = time / 1000; for (const fn of this.animated) fn(t, Math.min(delta, 100) / 1000); }, remove() { this.group.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) [].concat(o.material).forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); }); }); this.el.removeObject3D('mesh'); }, chess() { const el = this.el.querySelector('[chess]'); return el && el.components.chess; }, resetChess() { const chess = this.chess(); if (chess) chess.resetChess(); }, add(geometry, material, x, y, z, parent = this.group) { const mesh = new THREE.Mesh(geometry, material); mesh.position.set(x, y, z); parent.add(mesh); return mesh; }, // A box from its min and max corners, its texture in metres / tile if it has one. box(material, x0, y0, z0, x1, y1, z1, { tile = 1, parent } = {}) { const w = x1 - x0; const h = y1 - y0; const d = z1 - z0; const geometry = new THREE.BoxGeometry(w, h, d); if (material.map) boxUV(geometry, w, h, d, tile); return this.add(geometry, material, (x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, parent); }, // --- walls and roof ---------------------------------------------------------- buildWalls() { const logs = []; // [x, y, z, length, along z?] const wx = ROOM.halfX + LOG; const wz = ROOM.halfZ + LOG; const endX = wx + LOG + OVERHANG; const endZ = wz + LOG + OVERHANG; // A course of logs along a wall, leaving out [cut0, cut1] when it crosses a hole. const course = (y, alongZ, at, end, holes) => { let pieces = [[-end, end]]; for (const [a, b, bottom, top] of holes) { if (y + LOG <= bottom + 1e-6 || y - LOG >= top - 1e-6) continue; pieces = pieces.flatMap(([s, e]) => [[s, Math.min(e, a)], [Math.max(s, b), e]].filter(([p, q]) => q - p > 0.05)); } for (const [s, e] of pieces) logs.push(alongZ ? [at, y, (s + e) / 2, e - s, true] : [(s + e) / 2, y, at, e - s, false]); }; for (let i = 0; i < COURSES; i++) { const y = LOG + i * LOG * 2; course(y, false, -wz, endX, [[-WINDOW.half, WINDOW.half, WINDOW.sill, WINDOW.top]]); course(y, false, wz, endX, [[DOOR.x - DOOR.half, DOOR.x + DOOR.half, 0, DOOR.top]]); course(y, true, wx, endZ, []); course(y, true, -wx, endZ, []); } // The gables, shortening up to the ridge. const span = wz + LOG; for (let y = EAVE + LOG; ; y += LOG * 2) { const half = span * (1 - (y + LOG - EAVE) / RISE); if (half < 0.15) break; for (const x of [wx, -wx]) logs.push([x, y, 0, half * 2, true]); } const geometry = new THREE.CylinderGeometry(LOG, LOG, 1, 14, 1).rotateZ(Math.PI / 2); const mesh = new THREE.InstancedMesh(geometry, this.mat.log, logs.length); const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const turn = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); const color = new THREE.Color(); logs.forEach(([x, y, z, len, alongZ], i) => { m.compose(new THREE.Vector3(x, y, z), alongZ ? turn : q, new THREE.Vector3(len, 1, 1)); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHSL(0.075 + (this.rand() - 0.5) * 0.02, 0.42, 0.3 + this.rand() * 0.08, THREE.SRGBColorSpace)); }); this.group.add(mesh); // Chinking: a mortar wall a little inside the log centres, which shows // between the logs, with the window and door cut out of it. const shape = (halfU, holes, gable) => { const s = new THREE.Shape(); s.moveTo(-halfU, 0); s.lineTo(halfU, 0); s.lineTo(halfU, EAVE); if (gable) s.lineTo(0, EAVE + RISE * (1 - 0.0)); s.lineTo(-halfU, EAVE); s.closePath(); for (const [u0, u1, v0, v1] of holes) { const hole = new THREE.Path(); hole.moveTo(u0, v0); hole.lineTo(u0, v1); hole.lineTo(u1, v1); hole.lineTo(u1, v0); hole.closePath(); s.holes.push(hole); } return new THREE.ShapeGeometry(s); }; const cx = wx - CHINK; const cz = wz - CHINK; const front = new THREE.Mesh(shape(cx, [[-WINDOW.half, WINDOW.half, WINDOW.sill, WINDOW.top]]), this.mat.chink); front.position.z = -cz; const back = new THREE.Mesh(shape(cx, [[-(DOOR.x + DOOR.half), -(DOOR.x - DOOR.half), 0, DOOR.top]]), this.mat.chink); back.position.z = cz; back.rotation.y = Math.PI; const right = new THREE.Mesh(shape(cz, [], true), this.mat.chink); right.position.x = cx; right.rotation.y = -Math.PI / 2; const left = new THREE.Mesh(shape(cz, [], true), this.mat.chink); left.position.x = -cx; left.rotation.y = Math.PI / 2; this.group.add(front, back, right, left); // The floor, which is also the cabin's base outside. const fl = this.box(this.mat.floor, -wx - LOG, -0.3, -wz - LOG, wx + LOG, 0, wz + LOG, { tile: 1.2 }); fl.geometry.attributes.uv.needsUpdate = true; }, buildRoof() { const span = ROOM.halfZ + LOG * 2; // outer face of the walls const pitch = Math.atan2(RISE, span); const eaves = 0.35; // past the walls const slope = (span + eaves) / Math.cos(pitch); const halfX = ROOM.halfX + LOG * 2 + eaves; for (const side of [-1, 1]) { const geometry = boxUV(new THREE.BoxGeometry(halfX * 2, 0.06, slope), halfX * 2, 0.06, slope, 1.2); const roof = new THREE.Mesh(geometry, this.mat.boards); // Its underside runs from the ridge out over the eaves. const mid = (slope / 2) * Math.cos(pitch); roof.position.set(0, EAVE + RISE - (slope / 2) * Math.sin(pitch) + 0.03 / Math.cos(pitch), side * mid); roof.rotation.x = side * pitch; this.group.add(roof); // Rafters under it. for (let x = -ROOM.halfX + 0.25; x <= ROOM.halfX - 0.2; x += 0.95) { const len = span / Math.cos(pitch); const rafter = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.15, len), this.mat.darkWood); rafter.position.set(x, EAVE + RISE / 2 - 0.075 / Math.cos(pitch), side * span / 2); rafter.rotation.x = side * pitch; this.group.add(rafter); } } this.box(this.mat.darkWood, -ROOM.halfX - 0.3, EAVE + RISE - 0.28, -0.09, ROOM.halfX + 0.3, EAVE + RISE - 0.02, 0.09); for (const x of [-1.25, 1.25]) { this.box(this.mat.darkWood, x - 0.09, EAVE - 0.1, -ROOM.halfZ - LOG, x + 0.09, EAVE + 0.1, ROOM.halfZ + LOG); } }, // --- the window and door ---------------------------------------------------- buildWindow() { const { half, sill, top } = WINDOW; const z0 = -ROOM.halfZ - LOG * 2 - 0.02; const z1 = -ROOM.halfZ + 0.02; const t = 0.07; const w = this.mat.wood; this.box(w, -half, sill, z0, half, sill + t, z1); this.box(w, -half, top - t, z0, half, top, z1); this.box(w, -half, sill, z0, -half + t, top, z1); this.box(w, half - t, sill, z0, half, top, z1); const zm = -ROOM.halfZ - LOG; const mid = (sill + top) / 2; this.box(w, -0.025, sill, zm - 0.03, 0.025, top, zm + 0.03); this.box(w, -half, mid - 0.025, zm - 0.03, half, mid + 0.025, zm + 0.03); const glass = this.add(new THREE.PlaneGeometry(half * 2, top - sill), this.mat.glass, 0, mid, zm - 0.01); glass.renderOrder = 3; // A deep sill inside, with candles on it. this.box(this.mat.boards, -half - 0.15, sill - 0.04, z1 - 0.04, half + 0.15, sill + t, z1 + 0.16, { tile: 1.2 }); const y = sill + t; this.candle(-0.5, y, -ROOM.halfZ + 0.08, 0.16, 0.026); this.candle(-0.4, y, -ROOM.halfZ + 0.1, 0.1, 0.022); this.candle(0.48, y, -ROOM.halfZ + 0.09, 0.13, 0.024); this.candleLight(0, y + 0.3, -ROOM.halfZ + 0.3, 1.1); }, buildDoor() { const x0 = DOOR.x - DOOR.half; const x1 = DOOR.x + DOOR.half; const z0 = ROOM.halfZ - 0.02; const z1 = ROOM.halfZ + LOG * 2 + 0.02; const t = 0.07; this.box(this.mat.wood, x0, DOOR.top - t, z0, x1, DOOR.top, z1); this.box(this.mat.wood, x0, 0, z0, x0 + t, DOOR.top, z1); this.box(this.mat.wood, x1 - t, 0, z0, x1, DOOR.top, z1); // Vertical boards with two battens and an iron ring. const door = new THREE.Group(); door.position.set(DOOR.x, 0, ROOM.halfZ + LOG); this.group.add(door); const boards = this.mat.boards.clone(); boards.map = this.mat.boards.map.clone(); boards.map.rotation = Math.PI / 2; boards.map.needsUpdate = true; this.box(boards, x0 + t - DOOR.x, 0.01, -0.03, x1 - t - DOOR.x, DOOR.top - t, 0.03, { tile: 1.2, parent: door }); for (const y of [0.35, 1.7]) this.box(this.mat.darkWood, -DOOR.half + t + 0.04, y, -0.06, DOOR.half - t - 0.04, y + 0.12, -0.03, { parent: door }); const ring = this.add(new THREE.TorusGeometry(0.05, 0.008, 6, 16), this.mat.iron, DOOR.half - 0.22, 1.05, -0.07, door); ring.rotation.y = 0; this.add(new THREE.CylinderGeometry(0.018, 0.018, 0.03, 8).rotateX(Math.PI / 2), this.mat.iron, DOOR.half - 0.22, 1.1, -0.045, door); }, // --- the fireplace ----------------------------------------------------------- buildFireplace() { const { front, half, opening, openTop, top } = HEARTH; const back = ROOM.halfX + 0.02; const s = this.mat.stone; this.box(s, front, 0, -half, back, top, -opening); this.box(s, front, 0, opening, back, top, half); this.box(s, front, openTop, -opening, back, top, opening); this.box(this.mat.soot, front + 0.01, 0.0, -opening, back - 0.02, openTop, opening); // Hearthstone, mantel and chimney breast up through the roof. this.box(s, front - 0.45, 0, -half - 0.15, back, 0.07, half + 0.15, { tile: 0.8 }); this.box(this.mat.darkWood, front - 0.12, top, -half - 0.12, back, top + 0.13, half + 0.12); this.box(s, front + 0.15, top + 0.13, -0.55, back, EAVE + RISE + 0.9, 0.55); // Candles and a little brass bowl on the mantel. const y = top + 0.13; this.candle(front + 0.1, y, -0.75, 0.22, 0.03); this.candle(front + 0.12, y, -0.62, 0.15, 0.026); this.candle(front + 0.1, y, 0.72, 0.18, 0.03); this.add(new THREE.SphereGeometry(0.07, 16, 8, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#8a6a2c', emissive: '#1a1004', side: THREE.DoubleSide }), front + 0.12, y + 0.07, 0.35); this.buildFire(front + 0.28, 0.07, 0); }, buildFire(x, y, z) { const fire = new THREE.Group(); fire.position.set(x, y, z); this.group.add(fire); // Andiron logs, crossed. for (const [dz, yaw, ly] of [[-0.06, 0.25, 0.05], [0.06, -0.25, 0.05], [0, Math.PI / 2, 0.13]]) { const l = this.add(new THREE.CylinderGeometry(0.05, 0.055, 0.55, 8).rotateZ(Math.PI / 2), this.mat.darkWood, 0.02, ly, dz, fire); l.rotation.y = yaw + Math.PI / 2; } const coals = this.add(new THREE.CircleGeometry(0.24, 16).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: '#ff5a1a', transparent: true, opacity: 0.8 }), 0, 0.012, 0, fire); const colors = ['#ff5a14', '#ff8c1a', '#ffb23b', '#ffe08a']; const flames = colors.map((color, i) => { const h = 0.5 - i * 0.08; const mesh = new THREE.Mesh( new THREE.ConeGeometry(0.16 - i * 0.03, h, 8, 1, true).translate(0, h / 2, 0), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.55 + i * 0.1, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false }), ); mesh.position.set(i % 2 ? 0.03 : -0.03, 0.06, i % 3 ? -0.03 : 0.03); fire.add(mesh); return { mesh, phase: i * 1.7 }; }); const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: glow('rgba(255,190,110,0.9)', 'rgba(255,110,30,0.35)'), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false, })); halo.position.y = 0.25; halo.scale.set(1.3, 1.3, 1); fire.add(halo); // The light sits a little in front of the opening so it fills the room. const light = document.createElement('a-entity'); light.setAttribute('light', { type: 'point', color: '#ff9446', intensity: 7, distance: 11, decay: 1.6 }); light.setAttribute('position', `${HEARTH.front - 0.35} 0.95 0`); this.el.appendChild(light); this.animated.push((t) => { for (const f of flames) { const n = Math.sin(t * 9 + f.phase) * 0.12 + Math.sin(t * 15.3 + f.phase * 2) * 0.08; f.mesh.scale.set(1 + n * 0.4, 1 + n, 1 + n * 0.4); f.mesh.rotation.y = t * 0.7 + f.phase; } const flicker = 1 + Math.sin(t * 11) * 0.06 + Math.sin(t * 23.7) * 0.05 + (Math.random() - 0.5) * 0.05; halo.material.opacity = 0.75 * flicker; coals.material.opacity = 0.65 + 0.15 * Math.sin(t * 2.3); const obj = light.getObject3D('light'); if (obj) obj.intensity = 7 * flicker; }); if (this.data.fireSound) { const sound = document.createElement('a-entity'); sound.setAttribute('sound', { src: `url(${this.data.fireSound})`, autoplay: true, loop: true, volume: 0.2, refDistance: 1.5, rolloffFactor: 1.4, positional: true }); sound.setAttribute('position', `${x} 0.4 ${z}`); this.el.appendChild(sound); } }, // --- small things ------------------------------------------------------------ candle(x, y, z, height, radius, parent = this.group) { this.add(new THREE.CylinderGeometry(radius, radius * 1.05, height, 12), this.mat.wax, x, y + height / 2, z, parent); const flame = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false })); flame.position.set(x, y + height + 0.025, z); flame.scale.set(0.03, 0.06, 1); parent.add(flame); const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.25, fog: false })); halo.position.copy(flame.position); halo.scale.set(0.22, 0.22, 1); parent.add(halo); const phase = this.rand() * 10; this.animated.push((t) => { const f = 1 + Math.sin(t * 13 + phase) * 0.08 + Math.sin(t * 31 + phase) * 0.05; flame.scale.set(0.03 * (2 - f), 0.06 * f, 1); }); }, candleLight(x, y, z, intensity, distance = 4) { const light = document.createElement('a-entity'); light.setAttribute('light', { type: 'point', color: '#ffb36b', intensity, distance, decay: 2 }); light.setAttribute('position', `${x} ${y} ${z}`); this.el.appendChild(light); const phase = this.rand() * 10; this.animated.push((t) => { const obj = light.getObject3D('light'); if (obj) obj.intensity = intensity * (1 + Math.sin(t * 9 + phase) * 0.05 + Math.sin(t * 23 + phase) * 0.03); }); }, armchair(x, z, yaw) { const chair = new THREE.Group(); chair.position.set(x, 0, z); chair.rotation.y = yaw; this.group.add(chair); const f = this.mat.fabric; const p = { parent: chair }; this.box(f, -0.36, 0.1, -0.34, 0.36, 0.4, 0.34, p); // base this.box(f, -0.28, 0.4, -0.32, 0.28, 0.47, 0.28, p); // cushion const back = this.box(f, -0.36, 0.1, 0.24, 0.36, 1.0, 0.4, p); back.rotation.x = -0.12; for (const s of [-1, 1]) this.box(f, s * 0.36 - 0.08, 0.1, -0.34, s * 0.36 + 0.08, 0.62, 0.36, p); for (const [lx, lz] of [[-0.3, -0.28], [0.3, -0.28], [-0.3, 0.3], [0.3, 0.3]]) { this.box(this.mat.darkWood, lx - 0.03, 0, lz - 0.03, lx + 0.03, 0.1, lz + 0.03, p); } }, buildFurniture() { for (const [x, z, yaw] of this.chairs) this.armchair(x, z, yaw); const { x, z, half, height } = this.table; const rugMesh = this.add(new THREE.CircleGeometry(RUG_R, 64).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ map: rug(), polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }), x, 0.004, z); rugMesh.rotation.y = Math.PI / 2; // The chess table: a square top on an apron and four turned legs. this.box(this.mat.wood, x - half, height - 0.04, z - half, x + half, height, z + half); const apron = half - 0.05; this.box(this.mat.darkWood, x - apron, height - 0.13, z - apron, x + apron, height - 0.04, z + apron); const leg = new THREE.CylinderGeometry(0.03, 0.022, height - 0.04, 10); const inset = half - 0.08; for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { this.add(leg, this.mat.darkWood, x + sx * inset, (height - 0.04) / 2, z + sz * inset); } }, buildBookshelf() { const { front, half, height } = SHELF; const back = -ROOM.halfX - 0.02; const w = this.mat.wood; const shelf = new THREE.Group(); shelf.position.z = SHELF.z; this.group.add(shelf); const p = { parent: shelf }; this.box(w, back, 0, -half, front, height, -half + 0.04, p); this.box(w, back, 0, half - 0.04, front, height, half, p); this.box(w, back, height - 0.04, -half, front, height, half, p); this.box(this.mat.darkWood, back, 0, -half, back + 0.02, height, half, p); const shelves = [0.06, 0.45, 0.84, 1.22, 1.6]; for (const y of shelves) this.box(w, back, y - 0.03, -half + 0.04, front, y, half - 0.04, p); const colors = ['#4c1a1d', '#24341f', '#1f2740', '#6b4a1e', '#151313', '#4a2c18', '#3b2340', '#5c5a3a']; const books = []; for (const y of shelves) { let z = -half + 0.06; while (z < half - 0.1) { if (this.rand() < 0.08) { z += 0.06 + this.rand() * 0.12; continue; } const t = 0.025 + this.rand() * 0.035; const h = 0.2 + this.rand() * 0.13; const d = 0.18 + this.rand() * 0.07; if (z + t > half - 0.06) break; books.push([z + t / 2, y, t, h, d, colors[Math.floor(this.rand() * colors.length)]]); z += t + 0.003; } } const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), new THREE.MeshLambertMaterial(), books.length); const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const c = new THREE.Color(); books.forEach(([z, y, t, h, d, color], i) => { m.compose(new THREE.Vector3(back + 0.03 + d / 2, y, z), q, new THREE.Vector3(d, h, t)); mesh.setMatrixAt(i, m); mesh.setColorAt(i, c.set(color).offsetHSL(0, 0, (this.rand() - 0.5) * 0.05)); }); shelf.add(mesh); }, // --- the circle, the triangle and the lantern ------------------------------ buildCircle() { const { x, z, r, gap, side } = CIRCLE; // East points from the middle into the corner; south is to its right. const east = new THREE.Vector2(-ROOM.halfX - x, -ROOM.halfZ - z).normalize(); const at = (u, v) => [x - u * east.y + v * east.x, z + u * east.x + v * east.y]; const yaw = Math.atan2(-east.x, -east.y); const paint = (texture, size, [px, pz]) => { const mesh = this.add(new THREE.PlaneGeometry(size, size).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ map: texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, }), px, 0.003, pz); mesh.rotation.y = yaw; }; paint(magickCircle(this.rand), 2 * PAINT * r, [x, z]); const centroid = r + gap + (side * Math.sqrt(3)) / 6; paint(triangleOfArt(this.rand), TRI_PAINT, at(0, centroid)); // A beeswax lamp in each of the nine pentagrams. for (let k = 0; k < 9; k++) { const a = (k / 9) * Math.PI * 2; const [lx, lz] = at(Math.sin(a) * r * LAMPS, Math.cos(a) * r * LAMPS); this.candle(lx, 0, lz, 0.07 + this.rand() * 0.03, 0.018); } this.buildLantern(...at(0, centroid)); }, // An iron lantern with a candle in it, hanging by a chain from a bracket on // the wall, over the triangle. It sways a little. buildLantern(x, z) { const iron = this.mat.iron; const wall = -ROOM.halfX; const armY = 2.28; this.box(iron, wall, armY - 0.3, z - 0.03, wall + 0.02, armY + 0.04, z + 0.03); this.box(iron, wall, armY - 0.012, z - 0.012, x + 0.02, armY + 0.012, z + 0.012); const [bx, by] = [0.3, 0.26]; const brace = this.add(new THREE.BoxGeometry(Math.hypot(bx, by), 0.012, 0.012), iron, wall + bx / 2, armY - by / 2 - 0.01, z); brace.rotation.z = Math.atan2(by, bx); const pivot = new THREE.Group(); pivot.position.set(x, armY - 0.012, z); this.group.add(pivot); const link = new THREE.TorusGeometry(0.011, 0.0025, 6, 12); for (let i = 0; i < 8; i++) { this.add(link, iron, 0, -0.011 - i * 0.022, 0, pivot).rotation.y = (i % 2) * (Math.PI / 2); } const body = new THREE.Group(); body.position.y = -0.4; pivot.add(body); this.add(new THREE.TorusGeometry(0.022, 0.004, 6, 14), iron, 0, 0.228, 0, body); this.add(new THREE.CylinderGeometry(0.018, 0.024, 0.04, 8), iron, 0, 0.2, 0, body); this.add(new THREE.ConeGeometry(0.115, 0.09, 4).rotateY(Math.PI / 4), iron, 0, 0.155, 0, body); this.box(iron, -0.085, -0.125, -0.085, 0.085, -0.1, 0.085, { parent: body }); for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { this.box(iron, sx * 0.075 - 0.007, -0.1, sz * 0.075 - 0.007, sx * 0.075 + 0.007, 0.11, sz * 0.075 + 0.007, { parent: body }); } const glass = this.add(new THREE.BoxGeometry(0.14, 0.21, 0.14), new THREE.MeshBasicMaterial({ color: '#ffb35a', transparent: true, opacity: 0.22, depthWrite: false, side: THREE.DoubleSide, }), 0, 0.005, 0, body); glass.renderOrder = 3; this.candle(0, -0.1, 0, 0.08, 0.02, body); const glowSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.3, fog: false })); glowSprite.scale.set(0.6, 0.6, 1); body.add(glowSprite); this.candleLight(x, armY - 0.4, z, 2.4, 5); this.animated.push((t) => { pivot.rotation.x = Math.sin(t * 0.9) * 0.02; pivot.rotation.z = Math.sin(t * 0.67 + 1) * 0.025; }); }, // Two wands on the windowsill, a black and a white, to pick up and draw in // the air with (wield.js). Each lies along its +x, point first. buildWands() { const y = WINDOW.sill + 0.07 + 0.01; const wands = [ { name: 'black-wand', label: 'the black Wand', at: [-0.04, -ROOM.halfZ + 0.04], shaft: '#141110', ends: '#c9a24a', ink: '#ffc04a' }, { name: 'white-wand', label: 'the white Wand', at: [0.1, -ROOM.halfZ + 0.13], shaft: '#e6dfcf', ends: '#c4c9d2', ink: '#bfe0ff' }, ]; const V = (px) => new THREE.Vector3(px, 0, 0); this.tools = wands.map(({ name, label, at: [x, z], shaft, ends, ink }, i) => { const wand = new THREE.Group(); wand.position.set(x, y, z); wand.rotation.y = i * 0.06; this.group.add(wand); this.add(new THREE.CylinderGeometry(0.006, 0.006, 0.44, 8), new THREE.MeshLambertMaterial({ color: shaft }), 0, 0, 0, wand).rotation.z = Math.PI / 2; const tipMaterial = new THREE.MeshLambertMaterial({ color: ends, emissive: ends, emissiveIntensity: 0.15 }); for (const end of [-1, 1]) this.add(new THREE.SphereGeometry(0.01, 8, 6), tipMaterial, end * 0.22, 0, 0, wand).scale.set(1.4, 1, 1); return { name, label, object: wand, grip: V(-0.12), tip: V(0.234), butt: V(-0.234), ink }; }); }, // --- outside ----------------------------------------------------------------- buildOutside() { this.add(new THREE.CircleGeometry(170, 48).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#16211a' }), 0, -0.25, 0); this.add(new THREE.CircleGeometry(LAKE.r, 64).rotateX(-Math.PI / 2), new THREE.MeshPhongMaterial({ color: '#070d18', specular: '#5a6c99', shininess: 80, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), LAKE.x, LAKE.y, LAKE.z); // The Moon's path on the water, out from the shore toward where the sky holds it. const skyEl = this.data.sky; const bearing = skyEl && skyEl.components.sky ? skyEl.components.sky.data.moonBearing : 180; const near = Math.abs(LAKE.z) - LAKE.r + 1; const far = 160; const path = new THREE.BufferGeometry(); path.setAttribute('position', new THREE.Float32BufferAttribute([-1.5, 0, -near, 1.5, 0, -near, -14, 0, -far, 14, 0, -far], 3)); path.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 6, 1, 6], 2)); path.setIndex([0, 1, 2, 1, 3, 2]); const glade = new THREE.Mesh(path, new THREE.MeshBasicMaterial({ map: glints(this.rand), color: '#cfd8ff', transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, depthWrite: false, })); glade.position.y = LAKE.y + 0.01; glade.rotation.y = THREE.MathUtils.degToRad(bearing - 180); glade.renderOrder = 2; this.group.add(glade); this.animated.push((t) => { glade.material.map.offset.y = -t * 0.04; const sky = skyEl && skyEl.components.sky; glade.material.opacity = 0.08 + 0.45 * (sky ? sky.moonLit : 0.5); }); // Pines all round, but not on the lake or between the window and it. const models = ['tree_pineTallA', 'tree_pineTallB', 'tree_pineTallC', 'tree_pineDefaultA', 'tree_pineDefaultB', 'tree_pineRoundA', 'tree_pineRoundC']; const placements = new Map(); for (let n = 0, tries = 0; n < 170 && tries < 3000; tries++) { const r = 7 + Math.sqrt(this.rand()) * 75; const a = this.rand() * Math.PI * 2; const x = Math.sin(a) * r; const z = Math.cos(a) * r; if (Math.hypot(x - LAKE.x, z - LAKE.z) < LAKE.r + 2) continue; if (z < 0 && Math.abs(x) < 1.5 - z * 0.35) continue; const name = models[Math.floor(this.rand() * models.length)]; const s = 3.2 + this.rand() * 2.6; const m = new THREE.Matrix4().compose(new THREE.Vector3(x, -0.25, z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rand() * 6.3), new THREE.Vector3(s, s, s)); if (!placements.has(name)) placements.set(name, []); placements.get(name).push(m); n++; } const loader = new THREE.GLTFLoader(); const out = new THREE.Matrix4(); for (const [name, matrices] of placements) { loader.load(`${ENV_DIR}${name}.glb`, (gltf) => { gltf.scene.updateMatrixWorld(true); gltf.scene.traverse((obj) => { if (!obj.isMesh) return; const mats = [].concat(obj.material).map((mt) => new THREE.MeshLambertMaterial({ color: mt.color ? mt.color.clone() : 0xffffff, map: mt.map || null })); const mesh = new THREE.InstancedMesh(obj.geometry, Array.isArray(obj.material) ? mats : mats[0], matrices.length); matrices.forEach((mm, i) => mesh.setMatrixAt(i, out.multiplyMatrices(mm, obj.matrixWorld))); mesh.computeBoundingSphere(); this.group.add(mesh); }); }, undefined, (err) => console.warn(`Couldn't load ${name}`, err)); } }, // Keeps a head position inside the walls and out of the fireplace, // bookshelf and chess table. confine(p) { const wx = ROOM.halfX - MARGIN; const wz = ROOM.halfZ - MARGIN; p.x = Math.min(wx, Math.max(-wx, p.x)); p.z = Math.min(wz, Math.max(-wz, p.z)); const hearth = HEARTH.front - 0.15; if (p.x > hearth && Math.abs(p.z) < HEARTH.half + 0.1) p.x = hearth; const shelf = SHELF.front + 0.15; if (p.x < shelf && Math.abs(p.z - SHELF.z) < SHELF.half + 0.1) p.x = shelf; const t = this.table; const tx = p.x - t.x; const tz = p.z - t.z; const reach = t.half + LEAN; if (Math.abs(tx) < reach && Math.abs(tz) < reach) { if (Math.abs(tx) > Math.abs(tz)) p.x = t.x + Math.sign(tx) * reach; else p.z = t.z + (Math.sign(tz) || 1) * reach; } return p; }, }); })();