summaryrefslogtreecommitdiff
path: root/public/js/components/balcony.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/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.js592
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;
+ },
+ });
+})();