CI / Typecheck, lint, tests, e2e (push) Failing after 3m51s
- 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>
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
import {
|
||
BasicShadowMap, Camera, HalfFloatType, Mesh, NearestFilter, OrthographicCamera, PlaneGeometry, Scene, Vector2,
|
||
WebGLRenderer, WebGLRenderTarget,
|
||
} from "three";
|
||
import type { DayPart } from "../../lib/palette";
|
||
import { createAgents } from "./agents";
|
||
import { applyLighting, createLights } from "./lighting";
|
||
import { buildOffice } from "./office";
|
||
import { createQuantizeMaterial, setQuantizePalette } from "./quantize";
|
||
import { createSky, setSkyPalette } from "./sky";
|
||
import { UNIT_BOX, createInks, paintInks } from "./voxel";
|
||
|
||
export interface World {
|
||
setPalette(colours: string[], part: DayPart): void;
|
||
resize(width: number, height: number): void;
|
||
setPointer(x: number, y: number): void;
|
||
setScroll(progress: number): void;
|
||
setFocusRight(right: boolean): void;
|
||
setRunning(running: boolean): void;
|
||
render(): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
const ART_PIXELS = 320 * 180;
|
||
const MAX_DPR = 2;
|
||
const TARGET = { x: 1.6, y: 0.2, z: 0.4 };
|
||
const RIGHT_SHARE = 0.55;
|
||
|
||
/** Real-time office diorama drawn at ~320×180, then palette-quantized onto the canvas. */
|
||
export function createWorld(canvas: HTMLCanvasElement): World {
|
||
const renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "low-power" });
|
||
renderer.setPixelRatio(1);
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = BasicShadowMap;
|
||
|
||
const target = new WebGLRenderTarget(320, 180, { type: HalfFloatType, minFilter: NearestFilter, magFilter: NearestFilter });
|
||
const quantize = createQuantizeMaterial(target.texture);
|
||
const post = new Scene();
|
||
const postQuad = new Mesh(new PlaneGeometry(2, 2), quantize);
|
||
postQuad.frustumCulled = false;
|
||
post.add(postQuad);
|
||
const postCamera = new Camera();
|
||
|
||
const scene = new Scene();
|
||
const inks = createInks();
|
||
const office = buildOffice(inks);
|
||
const agents = createAgents(inks);
|
||
const lights = createLights();
|
||
const sky = createSky();
|
||
scene.add(sky, office.root, agents.root, lights.sun, lights.sun.target, lights.sky);
|
||
|
||
const camera = new OrthographicCamera(-1, 1, 1, -1, 1, 120);
|
||
const pointer = new Vector2();
|
||
const eased = new Vector2();
|
||
let scroll = 0;
|
||
let focusRight = false;
|
||
let aspect = 16 / 9;
|
||
let time = 0;
|
||
let last = 0;
|
||
let frame = 0;
|
||
|
||
function place(): void {
|
||
eased.lerp(pointer, 0.06);
|
||
const yaw = Math.PI / 4 + Math.sin(time * 0.08) * 0.16 + eased.x * 0.12;
|
||
const pitch = 0.56 + eased.y * 0.05;
|
||
camera.position.set(
|
||
TARGET.x + Math.sin(yaw) * Math.cos(pitch) * 50,
|
||
TARGET.y + Math.sin(pitch) * 50,
|
||
TARGET.z + Math.cos(yaw) * Math.cos(pitch) * 50,
|
||
);
|
||
camera.lookAt(TARGET.x, TARGET.y, TARGET.z);
|
||
const share = focusRight ? RIGHT_SHARE : 1;
|
||
const half = Math.max(10.6, 13 / (aspect * share)) / (1 + scroll * 0.18);
|
||
const width = half * aspect * 2;
|
||
const centre = focusRight ? 1 - share / 2 : 0.5;
|
||
camera.top = half;
|
||
camera.bottom = -half;
|
||
camera.left = -width * centre;
|
||
camera.right = width * (1 - centre);
|
||
camera.updateProjectionMatrix();
|
||
(sky.material.uniforms.offset!.value as Vector2).set(-eased.x * 14 - Math.sin(time * 0.08) * 16, 0);
|
||
sky.material.uniforms.time!.value = time;
|
||
}
|
||
|
||
function render(): void {
|
||
place();
|
||
office.update(time);
|
||
renderer.setRenderTarget(target);
|
||
renderer.render(scene, camera);
|
||
renderer.setRenderTarget(null);
|
||
renderer.render(post, postCamera);
|
||
}
|
||
|
||
function tick(now: number): void {
|
||
const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
|
||
last = now;
|
||
time += dt;
|
||
agents.update(dt, time);
|
||
render();
|
||
frame = requestAnimationFrame(tick);
|
||
}
|
||
|
||
return {
|
||
setPalette(colours, part) {
|
||
paintInks(inks, colours);
|
||
setSkyPalette(sky.material, colours, part);
|
||
setQuantizePalette(quantize, colours);
|
||
applyLighting(lights, office, colours, part);
|
||
},
|
||
resize(width, height) {
|
||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||
const deviceWidth = width * dpr;
|
||
const deviceHeight = height * dpr;
|
||
const scale = Math.max(1, Math.round(Math.sqrt((deviceWidth * deviceHeight) / ART_PIXELS)));
|
||
const artWidth = Math.ceil(deviceWidth / scale);
|
||
const artHeight = Math.ceil(deviceHeight / scale);
|
||
aspect = artWidth / artHeight;
|
||
renderer.setSize(artWidth, artHeight, false);
|
||
target.setSize(artWidth, artHeight);
|
||
(sky.material.uniforms.resolution!.value as Vector2).set(artWidth, artHeight);
|
||
canvas.style.width = `${(artWidth * scale) / dpr}px`;
|
||
canvas.style.height = `${(artHeight * scale) / dpr}px`;
|
||
},
|
||
setPointer(x, y) {
|
||
pointer.set(x, y);
|
||
},
|
||
setScroll(progress) {
|
||
scroll = progress;
|
||
},
|
||
setFocusRight(right) {
|
||
focusRight = right;
|
||
},
|
||
setRunning(running) {
|
||
cancelAnimationFrame(frame);
|
||
last = 0;
|
||
if (running) frame = requestAnimationFrame(tick);
|
||
},
|
||
render() {
|
||
eased.copy(pointer);
|
||
render();
|
||
},
|
||
dispose() {
|
||
cancelAnimationFrame(frame);
|
||
office.dispose();
|
||
inks.forEach((material) => material.dispose());
|
||
UNIT_BOX.dispose();
|
||
sky.geometry.dispose();
|
||
sky.material.dispose();
|
||
postQuad.geometry.dispose();
|
||
quantize.dispose();
|
||
lights.sun.dispose();
|
||
lights.sky.dispose();
|
||
target.dispose();
|
||
renderer.dispose();
|
||
},
|
||
};
|
||
}
|