diff options
Diffstat (limited to 'public/js/components/cabin.js')
| -rw-r--r-- | public/js/components/cabin.js | 1064 |
1 files changed, 1064 insertions, 0 deletions
diff --git a/public/js/components/cabin.js b/public/js/components/cabin.js new file mode 100644 index 0000000..e9519dd --- /dev/null +++ b/public/js/components/cabin.js @@ -0,0 +1,1064 @@ +/* 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; + }, + }); +})(); |