Files
AgentCompany/landing/components/world/office.ts
T
ZacharyZhang-NYandClaude Opus 5.5 2d7af224dd
CI / Typecheck, lint, tests, e2e (push) Failing after 3m51s
Website: 16-bit pixel-art rebuild with a live Three.js office, downloads from Gitea v1.5.1
- Title screen over a real-time voxel office rendered at low resolution and quantized to a rotating 32-colour palette with Bayer dithering; day part and season re-light the scene and every surface.
- 16-bit UI kit: name-plate dialogs, menu cursor, inventory, XP bar, badges, NPC speech, GAME OVER with CONTINUE, hand-placed 12x12 icons, Fusion Pixel font (Latin subset preloaded).
- Sections as a game: quest log (onboarding), character select (AI CEO / AI Co-Founder), inventory (features), party roster (providers), live download XP, save point (all v1.5.1 builds), FAQ as NPC dialog, credits.
- Downloads and source point to the Gitea release; the download function only redirects to git.zacharyzhang.com.
- Tailwind and the old components removed; unit tests for platform/downloads, rewritten e2e suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:37:23 -04:00

285 lines
12 KiB
TypeScript

import {
BufferGeometry, DataTexture, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, NearestFilter, PlaneGeometry,
PointLight, Points, PointsMaterial, RepeatWrapping, type Object3D,
} from "three";
import { INK } from "../../lib/palette";
import { CEO_DESK, COFFEE_MACHINE, CORRIDOR, DESKS, FLOOR, MEETING_TABLE, SHARED_SPOTS, WINDOWS, chairOf, type Desk, type Point } from "./layout";
import { box, type Inks } from "./voxel";
export interface Office {
root: Group;
screens: MeshBasicMaterial[];
lamps: PointLight[];
glows: PointLight[];
bulbs: MeshBasicMaterial;
steam: PointsMaterial;
update(time: number): void;
dispose(): void;
}
const CODE_ROWS = 32;
const STEAM = 7;
const LAMPS: Point[] = [[-9.3, -4], [3.4, -1.7], [8.9, 2.4], [-0.1, 6.5]];
function random(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
function codeTexture(): DataTexture {
const width = 8;
const data = new Uint8Array(width * CODE_ROWS * 4);
const next = random(7);
for (let row = 0; row < CODE_ROWS; row++) {
const indent = Math.floor(next() * 3);
const length = 2 + Math.floor(next() * 5);
const level = next() > 0.7 ? 150 : 255;
for (let x = 0; x < width; x++) {
const on = row % 2 === 0 && x >= indent && x < indent + length;
data.fill(on ? level : 36, (row * width + x) * 4, (row * width + x) * 4 + 3);
data[(row * width + x) * 4 + 3] = 255;
}
}
const texture = new DataTexture(data, width, CODE_ROWS);
texture.magFilter = texture.minFilter = NearestFilter;
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(1, 5 / CODE_ROWS);
texture.needsUpdate = true;
return texture;
}
/** Builds a piece locally (facing +z) and drops it into the room at (x, z) turned to `face`. */
function placed(parent: Object3D, x: number, z: number, face: number): Group {
const group = new Group();
group.position.set(x, 0, z);
group.rotation.y = face;
parent.add(group);
return group;
}
function chair(parent: Object3D, inks: Inks, [x, z]: Point, face: number, seat: number = INK.accentDark): void {
const g = placed(parent, x, z, face);
box(g, inks[INK.inkSoft]!, 0.08, 0.38, 0.08, 0, 0, 0);
box(g, inks[seat]!, 0.52, 0.1, 0.5, 0, 0.38, 0);
box(g, inks[seat]!, 0.52, 0.42, 0.1, 0, 0.48, -0.24);
}
function plant(parent: Object3D, inks: Inks, x: number, z: number, scale = 1): void {
const g = placed(parent, x, z, 0);
g.scale.setScalar(scale);
box(g, inks[INK.brick]!, 0.5, 0.42, 0.5, 0, 0, 0);
box(g, inks[INK.woodDark]!, 0.42, 0.04, 0.42, 0, 0.42, 0);
box(g, inks[INK.leaf0]!, 0.64, 0.42, 0.64, 0, 0.46, 0);
box(g, inks[INK.leaf1]!, 0.5, 0.36, 0.5, 0.06, 0.82, 0.04);
box(g, inks[INK.leaf2]!, 0.28, 0.26, 0.28, -0.08, 1.12, 0.1);
box(g, inks[INK.bloom]!, 0.12, 0.12, 0.12, 0.26, 0.74, 0.26);
box(g, inks[INK.bloom]!, 0.12, 0.12, 0.12, -0.2, 1.0, 0.24);
}
function desk(parent: Object3D, inks: Inks, d: Desk, screenGeometry: PlaneGeometry, screen: MeshBasicMaterial, ceo = false): void {
const g = placed(parent, d.x, d.z, d.facing === 1 ? 0 : Math.PI);
const width = ceo ? 2.3 : 1.6;
const top = inks[ceo ? INK.woodDark : INK.wood]!;
for (const sx of [-1, 1]) for (const sz of [-1, 1]) box(g, inks[INK.woodDark]!, 0.08, 0.7, 0.08, sx * (width / 2 - 0.1), 0, sz * 0.34);
box(g, top, width, 0.08, 0.9, 0, 0.7, 0);
if (ceo) {
box(g, inks[INK.woodDark]!, width, 0.55, 0.06, 0, 0.15, 0.4);
box(g, inks[INK.gold]!, width + 0.02, 0.06, 0.92, 0, 0.72, 0);
box(g, inks[INK.gold]!, 0.14, 0.12, 0.14, 0.85, 0.78, 0.1);
box(g, inks[INK.gold]!, 0.22, 0.14, 0.22, 0.85, 0.9, 0.1);
box(g, inks[INK.gold]!, 0.5, 0.1, 0.06, -0.6, 0.78, 0.32);
}
box(g, inks[INK.inkSoft]!, 0.3, 0.03, 0.2, 0, 0.78, -0.2);
box(g, inks[INK.inkSoft]!, 0.08, 0.16, 0.08, 0, 0.8, -0.22);
box(g, inks[INK.ink]!, 0.84, 0.52, 0.06, 0, 0.92, -0.2);
const panel = new Mesh(screenGeometry, screen);
panel.position.set(0, 1.18, -0.165);
g.add(panel);
box(g, inks[INK.stoneLight]!, 0.5, 0.03, 0.16, 0, 0.78, 0.16);
box(g, inks[INK.accent]!, 0.09, 0.11, 0.09, width / 2 - 0.22, 0.78, 0.12);
chair(parent, inks, chairOf(d), d.facing === 1 ? Math.PI : 0, ceo ? INK.accent : INK.accentDark);
}
function wallRun(parent: Object3D, inks: Inks, [x1, z1]: Point, [x2, z2]: Point, height: number, thick: number, body: number = INK.paper): void {
const w = Math.max(Math.abs(x2 - x1), thick);
const d = Math.max(Math.abs(z2 - z1), thick);
box(parent, inks[body]!, w, height, d, (x1 + x2) / 2, 0, (z1 + z2) / 2);
box(parent, inks[INK.inkSoft]!, w + 0.04, 0.1, d + 0.04, (x1 + x2) / 2, height, (z1 + z2) / 2);
}
function shell(root: Object3D, inks: Inks): void {
const { halfX, halfZ, wall } = FLOOR;
const W = halfX * 2 + 0.6;
const D = halfZ * 2 + 0.6;
box(root, inks[INK.stone]!, W, 1.1, D, 0, -1.6, 0);
box(root, inks[INK.brick]!, W, 0.4, D, 0, -0.5, 0);
box(root, inks[INK.wood]!, W, 0.1, D, 0, -0.1, 0);
for (let x = -halfX + 2; x < halfX; x += 2) box(root, inks[INK.woodDark]!, 0.1, 0.01, halfZ * 2, x, 0, 0);
box(root, inks[INK.berry]!, 5.6, 0.03, 5.6, -7, 0, -4);
box(root, inks[INK.stoneLight]!, 5.6, 0.03, 5.6, 7, 0, -4);
box(root, inks[INK.rug]!, halfX * 2 - 1.4, 0.03, 1.1, 0, 0, CORRIDOR);
const backZ = -halfZ - 0.15;
box(root, inks[INK.paper]!, W, 0.9, 0.3, 0, 0, backZ);
box(root, inks[INK.paper]!, W, wall - 2.6, 0.3, 0, 2.6, backZ);
box(root, inks[INK.inkSoft]!, W + 0.04, 0.12, 0.34, 0, wall, backZ);
const edges = [-W / 2, ...WINDOWS.flatMap((c) => [c - 1.1, c + 1.1]), W / 2];
for (let i = 0; i < edges.length; i += 2) box(root, inks[INK.paper]!, edges[i + 1]! - edges[i]!, 1.7, 0.3, (edges[i]! + edges[i + 1]!) / 2, 0.9, backZ);
for (const c of WINDOWS) {
box(root, inks[INK.white]!, 2.3, 0.1, 0.5, c, 0.82, backZ + 0.05);
box(root, inks[INK.white]!, 0.08, 1.7, 0.14, c, 0.9, backZ);
box(root, inks[INK.white]!, 2.2, 0.08, 0.14, c, 1.75, backZ);
}
const leftX = -halfX - 0.15;
box(root, inks[INK.paper]!, 0.3, wall, D, leftX, 0, 0);
box(root, inks[INK.inkSoft]!, 0.34, 0.12, D + 0.04, leftX, wall, 0);
box(root, inks[INK.stone]!, 0.04, 0.16, D, leftX + 0.17, 0, 0);
box(root, inks[INK.stone]!, W, 0.16, 0.04, 0, 0, backZ + 0.17);
const low = (a: Point, b: Point) => wallRun(root, inks, a, b, 0.9, 0.16, INK.stoneLight);
low([-halfX, -1], [-7.9, -1]);
low([-6.1, -1], [-4, -1]);
low([-4, -halfZ], [-4, -1]);
low([4, -1], [6.1, -1]);
low([7.9, -1], [halfX, -1]);
low([4, -halfZ], [4, -1]);
}
function furnish(root: Object3D, inks: Inks, bulbs: MeshBasicMaterial): void {
const shelf = placed(root, -9.72, -6.0, Math.PI / 2);
box(shelf, inks[INK.woodDark]!, 1.8, 2.1, 0.5, 0, 0, 0);
const books = [INK.accent, INK.sea, INK.gold, INK.berry, INK.mint, INK.rug, INK.paper, INK.accentDark];
const next = random(3);
for (const y of [0.12, 0.8, 1.45]) {
let x = -0.78;
while (x < 0.7) {
const w = 0.12 + next() * 0.1;
box(shelf, inks[books[Math.floor(next() * books.length)]!]!, w - 0.02, 0.36 + next() * 0.14, 0.4, x + w / 2, y, 0.08);
x += w;
}
}
const board = placed(root, -9.97, 1.6, Math.PI / 2);
box(board, inks[INK.stone]!, 2.6, 1.4, 0.06, 0, 1.05, 0);
box(board, inks[INK.white]!, 2.44, 1.24, 0.08, 0, 1.13, 0);
[INK.gold, INK.bloom, INK.mint, INK.sea, INK.gold].forEach((ink, i) => box(board, inks[ink]!, 0.24, 0.24, 0.1, -0.9 + i * 0.42, 1.65 - (i % 2) * 0.4, 0));
box(board, inks[INK.accent]!, 1.2, 0.06, 0.1, 0.3, 1.35, 0);
box(board, inks[INK.ink]!, 0.8, 0.06, 0.1, 0.1, 1.25, 0);
const [cx, cz] = COFFEE_MACHINE;
box(root, inks[INK.woodDark]!, 0.9, 0.9, 1.5, cx, 0, cz);
box(root, inks[INK.wood]!, 0.96, 0.06, 1.56, cx, 0.9, cz);
box(root, inks[INK.ink]!, 0.5, 0.7, 0.55, cx - 0.1, 0.96, cz - 0.1);
box(root, inks[INK.stone]!, 0.2, 0.1, 0.24, cx + 0.2, 1.32, cz - 0.1);
box(root, inks[INK.mint]!, 0.04, 0.08, 0.08, cx + 0.16, 1.5, cz + 0.08);
box(root, inks[INK.white]!, 0.12, 0.14, 0.12, cx + 0.24, 0.96, cz - 0.1);
box(root, inks[INK.accent]!, 0.12, 0.12, 0.12, cx + 0.1, 0.96, cz + 0.45);
box(root, inks[INK.stoneLight]!, 0.44, 0.9, 0.44, cx, 0, 6.1);
box(root, inks[INK.sea]!, 0.34, 0.46, 0.34, cx, 0.9, 6.1);
const [tx, tz] = MEETING_TABLE;
for (const sx of [-1, 1]) box(root, inks[INK.woodDark]!, 0.16, 0.7, 0.6, tx + sx * 1.1, 0, tz);
box(root, inks[INK.wood]!, 3.0, 0.1, 1.5, tx, 0.7, tz);
box(root, inks[INK.paper]!, 0.3, 0.02, 0.4, tx - 0.6, 0.8, tz + 0.2);
box(root, inks[INK.paper]!, 0.3, 0.02, 0.4, tx + 0.7, 0.8, tz - 0.1);
box(root, inks[INK.gold]!, 0.12, 0.12, 0.12, tx, 0.8, tz);
for (const spot of SHARED_SPOTS.filter((s) => s.kind === "meeting")) chair(root, inks, spot.at, spot.face, INK.sea);
for (const [x, z] of [[-4.6, -6.5], [-3.4, -6.5], [3.4, -6.5], [9.4, -6.5], [9.4, 6.5], [-9.4, -1.6]] as Point[]) plant(root, inks, x, z);
const tree = placed(root, 12.1, 5.2, 0);
box(tree, inks[INK.woodDark]!, 0.4, 1.6, 0.4, 0, 0, 0);
box(tree, inks[INK.leaf0]!, 2.0, 1.1, 2.0, 0, 1.2, 0);
box(tree, inks[INK.leaf1]!, 1.6, 0.9, 1.6, 0.1, 2.1, 0.1);
box(tree, inks[INK.leaf2]!, 0.9, 0.6, 0.9, -0.1, 2.9, 0.2);
box(tree, inks[INK.leaf1]!, 0.8, 0.7, 0.8, 0.9, 1.5, 0.6);
for (const [x, y, z] of [[0.7, 1.9, 0.85], [-0.6, 2.4, 0.7], [0.3, 3.0, 0.6], [-0.95, 1.5, 0.4]]) box(tree, inks[INK.bloom]!, 0.16, 0.16, 0.16, x!, y!, z!);
const bench = placed(root, 11.9, 2.6, -Math.PI / 2);
box(bench, inks[INK.inkSoft]!, 0.1, 0.4, 0.4, -0.6, 0, 0);
box(bench, inks[INK.inkSoft]!, 0.1, 0.4, 0.4, 0.6, 0, 0);
box(bench, inks[INK.wood]!, 1.5, 0.1, 0.5, 0, 0.4, 0);
box(bench, inks[INK.wood]!, 1.5, 0.4, 0.08, 0, 0.5, -0.22);
box(root, inks[INK.leaf1]!, 3.2, 0.12, 6.2, 11.9, -0.1, 4.2);
box(root, inks[INK.woodDark]!, 3.2, 1.5, 6.2, 11.9, -1.6, 4.2);
for (const [x, z] of LAMPS) {
const lamp = placed(root, x, z, 0);
box(lamp, inks[INK.inkSoft]!, 0.36, 0.06, 0.36, 0, 0, 0);
box(lamp, inks[INK.inkSoft]!, 0.06, 1.6, 0.06, 0, 0, 0);
box(lamp, bulbs, 0.44, 0.32, 0.44, 0, 1.6, 0).castShadow = false;
}
}
export function buildOffice(inks: Inks): Office {
const root = new Group();
const code = codeTexture();
const screenGeometry = new PlaneGeometry(0.74, 0.44);
const screens: MeshBasicMaterial[] = [];
const bulbs = new MeshBasicMaterial();
shell(root, inks);
for (const d of [CEO_DESK, ...DESKS]) {
const map = code.clone();
map.offset.y = screens.length * 6 / CODE_ROWS;
const screen = new MeshBasicMaterial({ map });
screens.push(screen);
desk(root, inks, d, screenGeometry, screen, d === CEO_DESK);
}
furnish(root, inks, bulbs);
const lamps = LAMPS.map(([x, z]) => {
const light = new PointLight(0xffffff, 0, 7, 1.4);
light.position.set(x, 1.6, z);
root.add(light);
return light;
});
const glows = [CEO_DESK, ...DESKS].map((d) => {
const light = new PointLight(0xffffff, 0, 2.8, 1.6);
light.position.set(d.x, 1.2, d.z + d.facing * 0.3);
root.add(light);
return light;
});
const steamGeometry = new BufferGeometry();
steamGeometry.setAttribute("position", new Float32BufferAttribute(new Float32Array(STEAM * 3), 3));
const steam = new PointsMaterial({ size: 1, sizeAttenuation: false });
const puffs = new Points(steamGeometry, steam);
puffs.frustumCulled = false;
root.add(puffs);
const [cx, cz] = COFFEE_MACHINE;
return {
root,
screens,
lamps,
glows,
bulbs,
steam,
update(time) {
screens.forEach((screen, i) => {
screen.map!.offset.y = (Math.floor(time * (2 + (i % 3)) + i * 5) * 2) / CODE_ROWS;
});
const position = steamGeometry.attributes.position!;
for (let i = 0; i < STEAM; i++) {
const life = (time * 0.45 + i / STEAM) % 1;
position.setXYZ(i, cx + 0.24 + Math.sin(life * 9 + i) * 0.08, 1.15 + life * 0.9, cz - 0.1 + Math.cos(life * 7 + i) * 0.06);
}
position.needsUpdate = true;
},
dispose() {
steamGeometry.dispose();
screens.forEach((screen) => {
screen.map!.dispose();
screen.dispose();
});
code.dispose();
screenGeometry.dispose();
bulbs.dispose();
steam.dispose();
[...lamps, ...glows].forEach((light) => light.dispose());
},
};
}