summaryrefslogtreecommitdiff
path: root/public/js/components/cabin.js
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/cabin.js
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/cabin.js')
-rw-r--r--public/js/components/cabin.js1064
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;
+ },
+ });
+})();