diff options
| author | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
| commit | e69ad01e700e80fa959dfee08cd556ca0d777ffd (patch) | |
| tree | 645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/balcony.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/balcony.js')
| -rw-r--r-- | public/js/components/balcony.js | 592 |
1 files changed, 592 insertions, 0 deletions
diff --git a/public/js/components/balcony.js b/public/js/components/balcony.js new file mode 100644 index 0000000..9538f4b --- /dev/null +++ b/public/js/components/balcony.js @@ -0,0 +1,592 @@ +/* global AFRAME, THREE */ +// The world: a balcony high up a glass tower, over a big city on a grey, +// foggy day. The city is plain boxes, snapped to 3.5 m floors and bays and +// painted with two window textures (concrete and glass), merged into one mesh +// per texture; every balcony up and down the tower is one instanced unit, and +// only ours has furniture. The fog (worlds.json) and two drifting layers of +// cloud, one well below the balcony and one not far above, hide how simple it +// all is: the street is out of sight, the tallest towers vanish into the +// overcast, and red aircraft lights blink on the roofs around. +// +// The balcony floor is at y = 0 and its middle at the origin; -z is out over +// the city, +z the glass doors back into the flat. +(function () { + const SEED = 2112; + + const BALCONY = { halfX: 3.4, front: -1.4, back: 1.3, slab: 0.25, rail: 1.08 }; + const STOREY = 3.5; // floor to floor, and the width of a bay + const GROUND = -140; // the street, 40 storeys down + const TILE = STOREY * 8; // the window textures are 8 bays × 8 storeys + // Our tower: its front is the balcony's back wall; its top is in the clouds. + const TOWER = { halfX: 16, depth: 32, top: 70 }; + const COLUMNS = [-11, 0, 11]; // balconies across the tower's face + const FLOORS = { below: 38, above: 10 }; + const BLOCK = 45; // city blocks, with streets between + const STREET = 15; + const REACH = 6; // blocks out from ours in each direction + const CLOUDS = { below: -58, above: 38, size: 1800 }; + const BLINK = 1.6; // seconds per aircraft-light flash cycle + const MARGIN = 0.25; // how close your head gets to the glass + + 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, { color = true } = {}) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + paint(canvas.getContext('2d'), width, height); + const texture = new THREE.CanvasTexture(canvas); + if (color) texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 8; + texture.wrapS = texture.wrapT = THREE.RepeatWrapping; + return texture; + } + + // A facade texture and its glow: 8 × 8 cells, one per bay and storey, with + // some windows lit. paintCell(ctx, x, y, size, lit) paints one cell; the + // glow gets the lit windows only. The top-left pixels are always plain wall, + // which is what roofs use. + function facade(rand, litShare, paintCell, paintGlow) { + const size = 64; + const lit = []; + for (let i = 0; i < 64; i++) lit.push(rand() < litShare); + const map = canvasTexture(512, 512, (ctx) => { + for (let i = 0; i < 64; i++) paintCell(ctx, (i % 8) * size, Math.floor(i / 8) * size, size, lit[i]); + }); + const glow = canvasTexture(512, 512, (ctx) => { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, 512, 512); + for (let i = 0; i < 64; i++) if (lit[i]) paintGlow(ctx, (i % 8) * size, Math.floor(i / 8) * size, size); + }); + return { map, glow }; + } + + // Punched windows in a pale wall (tinted per building). + function concreteFacade(rand) { + const win = (x, y) => [x + 12, y + 14, 40, 38]; + return facade(rand, 0.16, (ctx, x, y, s, lit) => { + ctx.fillStyle = '#fff'; + ctx.fillRect(x, y, s, s); + ctx.fillStyle = 'rgba(0,0,0,0.06)'; + ctx.fillRect(x, y + s - 6, s, 6); // a ledge under each storey + const [wx, wy, ww, wh] = win(x, y); + if (lit) { + ctx.fillStyle = '#ead9a6'; + } else { + const g = ctx.createLinearGradient(0, wy, 0, wy + wh); + g.addColorStop(0, '#5d6770'); + g.addColorStop(1, '#3c444b'); + ctx.fillStyle = g; + } + ctx.fillRect(wx, wy, ww, wh); + ctx.fillStyle = '#d9d9d9'; + ctx.fillRect(wx + ww / 2 - 1, wy, 2, wh); // the middle frame + }, (ctx, x, y) => { + const [wx, wy, ww, wh] = win(x, y); + ctx.fillStyle = '#8a7650'; + ctx.fillRect(wx, wy, ww, wh); + }); + } + + // A curtain wall: panes reflecting the grey sky, thin mullions, a dark band + // at each floor. + function glassFacade(rand) { + return facade(rand, 0.1, (ctx, x, y, s, lit) => { + const l = 52 + rand() * 10; + ctx.fillStyle = lit ? '#d9cba0' : `hsl(205,12%,${l}%)`; + ctx.fillRect(x, y, s, s); + if (!lit) { + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + ctx.fillRect(x, y, s, s * 0.45); + } + ctx.fillStyle = '#4c555c'; + ctx.fillRect(x, y + s - 9, s, 9); + ctx.fillStyle = '#c9ced1'; + ctx.fillRect(x, y, 4, s); + ctx.fillRect(x + s / 2 - 1, y, 2, s - 9); + }, (ctx, x, y, s) => { + ctx.fillStyle = '#6e6244'; + ctx.fillRect(x + 4, y, s - 4, s - 9); + }); + } + + // Grey concrete pavers, 60 cm square, four to a tile. + function pavers(rand) { + return canvasTexture(256, 256, (ctx, w, h) => { + const s = w / 2; + for (let i = 0; i < 4; i++) { + const x = (i % 2) * s; + const y = Math.floor(i / 2) * s; + ctx.fillStyle = `hsl(35,4%,${62 + rand() * 7}%)`; + ctx.fillRect(x, y, s, s); + for (let k = 0; k < 220; k++) { + ctx.fillStyle = `rgba(${rand() < 0.5 ? '0,0,0' : '255,255,255'},${0.03 + rand() * 0.05})`; + ctx.fillRect(x + rand() * s, y + rand() * s, 1 + rand() * 3, 1 + rand() * 3); + } + ctx.fillStyle = 'rgba(30,30,30,0.55)'; + ctx.fillRect(x, y, s, 2); + ctx.fillRect(x, y, 2, s); + } + }); + } + + // Soft cloud, tileable: blobs drawn again across each edge. + function cloudTexture(rand, base) { + return canvasTexture(256, 256, (ctx, w, h) => { + ctx.fillStyle = `rgba(255,255,255,${base})`; + ctx.fillRect(0, 0, w, h); + for (let i = 0; i < 70; i++) { + const x = rand() * w; + const y = rand() * h; + const r = 18 + rand() * 60; + const a = 0.08 + rand() * 0.22; + for (const dx of [-w, 0, w]) { + for (const dy of [-h, 0, h]) { + const g = ctx.createRadialGradient(x + dx, y + dy, 0, x + dx, y + dy, r); + g.addColorStop(0, `rgba(255,255,255,${a})`); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(x + dx - r, y + dy - r, r * 2, r * 2); + } + } + } + }); + } + + // --- merged boxes -------------------------------------------------------------- + + // Collects boxes into one geometry. Their sides take facade UVs in metres + // (÷ TILE) from a corner of the building, so windows line up with storeys; + // their tops all sample the textures' plain top-left corner. Bottoms are + // left out unless asked for, since nobody sees under a building. + class Boxes { + constructor() { + this.position = []; + this.normal = []; + this.uv = []; + this.color = []; + this.index = []; + } + + quad(corner, u, v, n, uv, color) { + const i = this.position.length / 3; + const pts = [corner, corner.clone().add(u), corner.clone().add(u).add(v), corner.clone().add(v)]; + for (let k = 0; k < 4; k++) { + this.position.push(pts[k].x, pts[k].y, pts[k].z); + this.normal.push(n.x, n.y, n.z); + this.uv.push(uv[k * 2], uv[k * 2 + 1]); + this.color.push(color.r, color.g, color.b); + } + this.index.push(i, i + 1, i + 2, i, i + 2, i + 3); + } + + // b: { x0, x1, y0, y1, z0, z1 }; u0/v0: texture offset of the building's + // corner (in tiles), or null for plain wall all round; base: the + // building's ground, where v starts; bottom: whether to close it underneath. + box(b, color, roofColor, u0 = 0, v0 = 0, base = b.y0, bottom = false) { + const V = THREE.Vector3; + const w = b.x1 - b.x0; + const d = b.z1 - b.z0; + const h = b.y1 - b.y0; + const p = 0.004; + const plain = [p, 1 - p, p, 1 - p, p, 1 - p, p, 1 - p]; + const vb = v0 + (b.y0 - base) / TILE; + const vt = vb + h / TILE; + const side = (corner, u, n, len, offset) => { + const ua = u0 + offset / TILE; + const ub = ua + len / TILE; + this.quad(corner, u, new V(0, h, 0), n, u0 === null ? plain : [ua, vb, ub, vb, ub, vt, ua, vt], color); + }; + // Round the building: front (-z), right (+x), back (+z), left (-x). + side(new V(b.x1, b.y0, b.z0), new V(-w, 0, 0), new V(0, 0, -1), w, 0); + side(new V(b.x0, b.y0, b.z0), new V(0, 0, d), new V(-1, 0, 0), d, w); + side(new V(b.x0, b.y0, b.z1), new V(w, 0, 0), new V(0, 0, 1), w, w + d); + side(new V(b.x1, b.y0, b.z1), new V(0, 0, -d), new V(1, 0, 0), d, 2 * w + d); + this.quad(new V(b.x0, b.y1, b.z1), new V(w, 0, 0), new V(0, 0, -d), new V(0, 1, 0), plain, roofColor); + if (bottom) this.quad(new V(b.x0, b.y0, b.z0), new V(w, 0, 0), new V(0, 0, d), new V(0, -1, 0), plain, roofColor); + } + + geometry() { + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(this.position, 3)); + g.setAttribute('normal', new THREE.Float32BufferAttribute(this.normal, 3)); + g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2)); + g.setAttribute('color', new THREE.Float32BufferAttribute(this.color, 3)); + g.setIndex(this.index); + g.computeBoundingSphere(); + return g; + } + } + + // An untextured box, for the balconies and furniture, closed all round: + // you see the undersides of the balconies above. + function plainBox(boxes, x0, x1, y0, y1, z0, z1, color) { + boxes.box({ x0, x1, y0, y1, z0, z1 }, color, color, null, 0, y0, true); + } + + AFRAME.registerComponent('balcony', { + schema: { + windSound: { default: '' }, + }, + + init() { + this.rand = mulberry32(SEED); + this.group = new THREE.Group(); + this.el.setObject3D('mesh', this.group); + this.disposables = []; + this.spawn = { x: 0.6, z: 0.5, yaw: 0 }; // facing the city + this.screen = { x: 0, y: 1.9, z: BALCONY.front - 4, yaw: 0, width: 3.2 }; // out over the street + + // Two chairs at a little table on the left, a bench on the right. + this.seats = [ + { x: -2.55, y: 0.45, z: -0.35, yaw: -0.25 }, + { x: -0.95, y: 0.45, z: -0.35, yaw: 0.25 }, + { x: 1.45, y: 0.45, z: 0.6, yaw: 0 }, + { x: 2.45, y: 0.45, z: 0.6, yaw: 0 }, + ]; + + this.buildSky(); + this.buildCity(); + this.buildBalconies(); + this.buildFurniture(); + this.buildClouds(); + + if (this.data.windSound) { + const sound = document.createElement('a-entity'); + sound.setAttribute('sound', { src: `url(${this.data.windSound})`, autoplay: true, loop: true, volume: 0.35, positional: false }); + this.el.appendChild(sound); + } + }, + + track(...things) { + this.disposables.push(...things); + return things[0]; + }, + + // A grey dome, a little brighter overhead, the fog's colour from the + // horizon down so the fogged city melts into it. + buildSky() { + const fog = this.el.sceneEl.object3D.fog; + const horizon = fog ? fog.color.clone() : new THREE.Color('#a3a9ae'); + const zenith = new THREE.Color('#c4c8cc'); + const geometry = this.track(new THREE.SphereGeometry(1500, 16, 10)); + const pos = geometry.attributes.position; + const colors = []; + const c = new THREE.Color(); + for (let i = 0; i < pos.count; i++) { + const up = Math.max(0, pos.getY(i) / 1500); + c.copy(horizon).lerp(zenith, Math.pow(up, 0.6)); + colors.push(c.r, c.g, c.b); + } + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); + const material = this.track(new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false })); + const dome = new THREE.Mesh(geometry, material); + dome.renderOrder = -1; + this.group.add(dome); + }, + + // Blocks on a grid round ours, cut into one to four lots, each a tower: + // a box, sometimes a narrower box on top, and a plant room on the roof. + buildCity() { + const r = this.rand; + const concrete = new Boxes(); + const glass = new Boxes(); + const lights = []; + const pitch = BLOCK + STREET; + // Our block's front edge is the balcony's back wall. + const originZ = BALCONY.back + BLOCK / 2; + const snap = (v) => Math.max(STOREY, Math.round(v / STOREY) * STOREY); + const tints = { + concrete: ['#dcd7ce', '#cdc7bb', '#bcc0c3', '#aea79d', '#d2cabf', '#a3a8ac', '#c2b6a6'], + glass: ['#e4ecf2', '#d3dce2', '#c4ced6', '#eef0f0', '#b9c6cf'], + }; + const tint = (list, k) => new THREE.Color(list[Math.floor(r() * list.length)]).multiplyScalar(k); + + const tower = (x0, x1, z0, z1, height) => { + const isGlass = r() < 0.38; + const boxes = isGlass ? glass : concrete; + const color = tint(isGlass ? tints.glass : tints.concrete, 0.92 + r() * 0.08); + const roof = color.clone().multiplyScalar(0.62); + const u0 = Math.floor(r() * 8) / 8; + const v0 = Math.floor(r() * 8) / 8; + const top = GROUND + height; + // A setback: the top third or so stands back from the edges. + const w = x1 - x0; + const d = z1 - z0; + if (height > 60 && w > 14 && d > 14 && r() < 0.5) { + const lower = GROUND + snap(height * (0.55 + r() * 0.2)); + boxes.box({ x0, x1, y0: GROUND, y1: lower, z0, z1 }, color, roof, u0, v0); + const ix = snap(w * 0.18); + const iz = snap(d * 0.18); + x0 += ix; x1 -= ix; z0 += iz; z1 -= iz; + boxes.box({ x0, x1, y0: lower, y1: top, z0, z1 }, color, roof, u0, v0, GROUND); + } else { + boxes.box({ x0, x1, y0: GROUND, y1: top, z0, z1 }, color, roof, u0, v0); + } + // The plant room, plain wall all round. + const pw = Math.min(x1 - x0, 8) * (0.4 + r() * 0.3); + const pd = Math.min(z1 - z0, 8) * (0.4 + r() * 0.3); + const px = x0 + (x1 - x0 - pw) * r(); + const pz = z0 + (z1 - z0 - pd) * r(); + const ph = 2.5 + r() * 3; + concrete.box({ x0: px, x1: px + pw, y0: top, y1: top + ph, z0: pz, z1: pz + pd }, roof, roof, null); + // Aircraft lights on the corners of the tall ones still under the cloud. + if (top > CLOUDS.below + 4 && top < CLOUDS.above - 4) { + for (const [x, z] of [[x0, z0], [x1, z0], [x0, z1], [x1, z1]]) lights.push(new THREE.Vector3(x, top + 0.6, z)); + } + }; + + for (let i = -REACH; i <= REACH; i++) { + for (let j = -REACH; j <= REACH; j++) { + const cx = i * pitch; + const cz = originZ + j * pitch; + if (i === 0 && j === 0) continue; // ours + const distance = Math.hypot(cx, cz); + if (distance > (REACH + 0.5) * pitch) continue; // round the city off + // Mostly below us, so there's a view; some up to our height, a few + // up into the cloud. In front, close by, keep them low. + const roll = r(); + let height = roll < 0.55 ? 30 + r() * 80 : roll < 0.88 ? 110 + r() * 70 : 190 + r() * 150; + const ahead = cz < 0 && Math.abs(cx) < 100; + if (ahead && distance < 150) height = Math.min(height, -GROUND - 25 - r() * 30); + // Cut the block into lots. + const split = r(); + const lots = []; + const h = BLOCK / 2; + if (split < 0.3) lots.push([cx - h, cx + h, cz - h, cz + h]); + else if (split < 0.55) lots.push([cx - h, cx, cz - h, cz + h], [cx, cx + h, cz - h, cz + h]); + else if (split < 0.75) lots.push([cx - h, cx + h, cz - h, cz], [cx - h, cx + h, cz, cz + h]); + else for (const [a, b] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) lots.push([cx + Math.min(0, a * h), cx + Math.max(0, a * h), cz + Math.min(0, b * h), cz + Math.max(0, b * h)]); + for (const [x0, x1, z0, z1] of lots) { + // Each lot's tower stands in from its edges by whole bays (or + // more), and varies around the block's height. + const w = snap((x1 - x0) - 2 - r() * 8); + const d = snap((z1 - z0) - 2 - r() * 8); + const mx = (x0 + x1) / 2 + (r() - 0.5) * ((x1 - x0) - w); + const mz = (z0 + z1) / 2 + (r() - 0.5) * ((z1 - z0) - d); + const lotHeight = snap(Math.max(20, height * (0.6 + r() * 0.5))); + const capped = ahead && distance < 150 ? Math.min(lotHeight, snap(height)) : lotHeight; + tower(mx - w / 2, mx + w / 2, mz - d / 2, mz + d / 2, capped); + } + } + } + + // Our own tower: glass, with the balconies on its face. + glass.box({ x0: -TOWER.halfX, x1: TOWER.halfX, y0: GROUND, y1: TOWER.top, z0: BALCONY.back, z1: BALCONY.back + TOWER.depth }, + new THREE.Color('#dde5ea'), new THREE.Color('#888'), 0, 0); + + const concreteTex = concreteFacade(r); + const glassTex = glassFacade(r); + for (const [boxes, tex] of [[concrete, concreteTex], [glass, glassTex]]) { + const material = this.track(new THREE.MeshLambertMaterial({ + map: tex.map, emissiveMap: tex.glow, emissive: '#ffffff', emissiveIntensity: 0.8, vertexColors: true, + }), tex.map, tex.glow); + this.group.add(new THREE.Mesh(this.track(boxes.geometry()), material)); + } + + // The street, far down in the murk. + const ground = new THREE.Mesh( + this.track(new THREE.PlaneGeometry(3000, 3000)), + this.track(new THREE.MeshLambertMaterial({ color: '#55595d' })), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.y = GROUND; + this.group.add(ground); + + // The aircraft lights all flash together. + this.lightMaterial = this.track(new THREE.MeshBasicMaterial({ color: '#ff2a1a' })); + const lamp = new THREE.InstancedMesh(this.track(new THREE.OctahedronGeometry(0.7, 0)), this.lightMaterial, lights.length); + const m = new THREE.Matrix4(); + lights.forEach((p, k) => lamp.setMatrixAt(k, m.makeTranslation(p.x, p.y, p.z))); + lamp.computeBoundingSphere(); + this.group.add(lamp); + }, + + // One balcony (slab, posts and rails, then its glass) instanced up and + // down the tower's face; ours is the one at floor 0 in the middle column. + buildBalconies() { + const B = BALCONY; + const steel = new THREE.Color('#3b4045'); + const slab = new THREE.Color('#c9c7c2'); + const unit = new Boxes(); + plainBox(unit, -B.halfX, B.halfX, -B.slab, 0, B.front, B.back, slab); + const post = 0.05; + for (const x of [-B.halfX, B.halfX]) { + plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel); + plainBox(unit, x - post / 2, x + post / 2, B.rail - 0.05, B.rail, B.front, B.back, steel); // side rails + } + for (const x of [-1.15, 1.15]) plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel); + plainBox(unit, -B.halfX, B.halfX, B.rail - 0.05, B.rail + 0.01, B.front - 0.01, B.front + 0.06, steel); // top rail + // The glass doors' frame across the back. + for (const x of [-B.halfX, -1.15, 1.15, B.halfX]) plainBox(unit, x - 0.04, x + 0.04, 0, STOREY - B.slab, B.back - 0.08, B.back, steel); + plainBox(unit, -B.halfX, B.halfX, STOREY - B.slab - 0.08, STOREY - B.slab, B.back - 0.08, B.back, steel); + plainBox(unit, -B.halfX, B.halfX, 0, 0.06, B.back - 0.08, B.back, steel); + + const panes = new Boxes(); + const white = new THREE.Color('#ffffff'); + const g = 0.012; + plainBox(panes, -B.halfX, B.halfX, 0.04, B.rail - 0.06, B.front + 0.02, B.front + 0.02 + g, white); + for (const x of [-B.halfX, B.halfX]) plainBox(panes, x - g / 2, x + g / 2, 0.04, B.rail - 0.06, B.front + post, B.back - 0.08, white); + + const opaque = this.track(new THREE.MeshLambertMaterial({ vertexColors: true })); + const glassMat = this.track(new THREE.MeshLambertMaterial({ color: '#a9c2c9', transparent: true, opacity: 0.22, depthWrite: false })); + const count = COLUMNS.length * (FLOORS.below + FLOORS.above + 1); + const units = new THREE.InstancedMesh(this.track(unit.geometry()), opaque, count); + const glass = new THREE.InstancedMesh(this.track(panes.geometry()), glassMat, count); + glass.renderOrder = 1; + const m = new THREE.Matrix4(); + let k = 0; + for (const x of COLUMNS) { + for (let f = -FLOORS.below; f <= FLOORS.above; f++) { + m.makeTranslation(x, f * STOREY, 0); + units.setMatrixAt(k, m); + glass.setMatrixAt(k, m); + k++; + } + } + units.computeBoundingSphere(); + glass.computeBoundingSphere(); + this.group.add(units, glass); + + // Pavers on our own floor. + const floorMap = this.track(pavers(this.rand)); + floorMap.repeat.set(B.halfX * 2 / 1.2, (B.back - B.front) / 1.2); + const floor = new THREE.Mesh( + this.track(new THREE.PlaneGeometry(B.halfX * 2, B.back - B.front)), + this.track(new THREE.MeshLambertMaterial({ map: floorMap })), + ); + floor.rotation.x = -Math.PI / 2; + floor.position.set(0, 0.002, (B.front + B.back) / 2); + this.group.add(floor); + + // Behind our own doors, a dim room rather than the tower's facade. + const inside = new THREE.Mesh( + this.track(new THREE.PlaneGeometry(B.halfX * 2, STOREY - B.slab)), + this.track(new THREE.MeshLambertMaterial({ color: '#2e3439', emissive: '#3a2f22', emissiveIntensity: 0.35 })), + ); + inside.position.set(0, (STOREY - B.slab) / 2, B.back - 0.01); + inside.rotation.y = Math.PI; + this.group.add(inside); + }, + + // Two chairs at a round table, a bench, and planters in the front corners. + buildFurniture() { + const r = this.rand; + const parts = new Boxes(); + const wood = new THREE.Color('#9c7650'); + const steel = new THREE.Color('#33383c'); + const leg = 0.035; + const chair = (x, z, yaw) => { + const o = new THREE.Group(); + o.position.set(x, 0, z); + o.rotation.y = yaw; + const b = new Boxes(); + plainBox(b, -0.24, 0.24, 0.42, 0.46, -0.22, 0.22, wood); // seat + plainBox(b, -0.24, 0.24, 0.5, 0.9, 0.2, 0.24, wood); // back + for (const [lx, lz] of [[-0.21, -0.19], [0.21, -0.19], [-0.21, 0.21], [0.21, 0.21]]) { + plainBox(b, lx - leg / 2, lx + leg / 2, 0, lz > 0 ? 0.9 : 0.42, lz - leg / 2, lz + leg / 2, steel); + } + o.add(new THREE.Mesh(this.track(b.geometry()), this.furnitureMat)); + this.group.add(o); + }; + this.furnitureMat = this.track(new THREE.MeshLambertMaterial({ vertexColors: true })); + for (const seat of this.seats.slice(0, 2)) chair(seat.x, seat.z + 0.05, seat.yaw); + + // The table between them. + const tableTop = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.34, 0.34, 0.03, 12)), this.track(new THREE.MeshLambertMaterial({ color: '#8a6a48' }))); + tableTop.position.set(-1.75, 0.68, -0.55); + const stem = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.03, 0.03, 0.67, 6)), this.track(new THREE.MeshLambertMaterial({ color: '#33383c' }))); + stem.position.set(-1.75, 0.335, -0.55); + const foot = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.2, 0.22, 0.03, 10)), stem.material); + foot.position.set(-1.75, 0.015, -0.55); + this.group.add(tableTop, stem, foot); + // A mug on it. + const mug = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.04, 0.035, 0.09, 8)), this.track(new THREE.MeshLambertMaterial({ color: '#e8e2d6' }))); + mug.position.set(-1.65, 0.74, -0.6); + this.group.add(mug); + + // The bench, against the doors. + plainBox(parts, 0.95, 2.95, 0.41, 0.46, 0.38, 0.82, wood); + plainBox(parts, 0.95, 2.95, 0.55, 0.9, 0.86, 0.9, wood); + for (const x of [1.02, 2.88]) { + plainBox(parts, x - 0.03, x + 0.03, 0, 0.41, 0.42, 0.48, steel); + plainBox(parts, x - 0.03, x + 0.03, 0, 0.9, 0.82, 0.88, steel); + } + // Planters: concrete troughs in the two front corners. + const trough = new THREE.Color('#8e8c88'); + const planters = [[-BALCONY.halfX + 0.3, BALCONY.front + 0.32], [BALCONY.halfX - 0.3, BALCONY.front + 0.32]]; + for (const [x, z] of planters) plainBox(parts, x - 0.25, x + 0.25, 0, 0.5, z - 0.25, z + 0.25, trough); + this.group.add(new THREE.Mesh(this.track(parts.geometry()), this.furnitureMat)); + + // Low-poly shrubs in them. + const leaves = this.track(new THREE.MeshLambertMaterial({ color: '#4f6a45', flatShading: true })); + const bush = this.track(new THREE.IcosahedronGeometry(0.28, 0)); + for (const [x, z] of planters) { + for (let i = 0; i < 3; i++) { + const b = new THREE.Mesh(bush, leaves); + b.position.set(x + (r() - 0.5) * 0.2, 0.68 + i * 0.16, z + (r() - 0.5) * 0.2); + b.scale.setScalar(1 - i * 0.22); + b.rotation.set(r() * 3, r() * 3, r() * 3); + this.group.add(b); + } + } + }, + + // A thin layer far below that hides the street, and the overcast just + // above, both drifting slowly. + buildClouds() { + const r = this.rand; + this.clouds = []; + for (const [y, base, opacity, repeat, drift] of [ + [CLOUDS.below, 0.5, 0.9, 5, [0.0009, 0.0004]], + [CLOUDS.above, 0.45, 0.95, 4, [-0.0006, 0.0005]], + ]) { + const map = this.track(cloudTexture(r, base)); + map.repeat.set(repeat, repeat); + const mesh = new THREE.Mesh( + this.track(new THREE.PlaneGeometry(CLOUDS.size, CLOUDS.size)), + this.track(new THREE.MeshBasicMaterial({ map, color: '#dde1e4', transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide })), + ); + mesh.rotation.x = -Math.PI / 2; + mesh.position.y = y; + mesh.renderOrder = 2; + this.group.add(mesh); + this.clouds.push({ map, drift }); + } + }, + + tick(time, delta) { + const dt = Math.min(delta || 0, 100) / 1000; + for (const { map, drift } of this.clouds) { + map.offset.x = (map.offset.x + drift[0] * dt) % 1; + map.offset.y = (map.offset.y + drift[1] * dt) % 1; + } + // A short bright flash, then dark. + const phase = (time / 1000 / BLINK) % 1; + this.lightMaterial.color.setRGB(phase < 0.35 ? 1 : 0.06, phase < 0.35 ? 0.1 : 0.01, phase < 0.35 ? 0.06 : 0.005); + }, + + remove() { + for (const d of this.disposables) d.dispose(); + this.el.removeObject3D('mesh'); + }, + + // Keeps a head position on the balcony, back from the glass. + confine(p) { + const B = BALCONY; + p.x = Math.min(B.halfX - MARGIN, Math.max(-B.halfX + MARGIN, p.x)); + p.z = Math.min(B.back - MARGIN, Math.max(B.front + MARGIN, p.z)); + return p; + }, + }); +})(); |