feat: Workshop Phase 2 — Scene MVP with Three.js
All checks were successful
Tests / lint (pull_request) Successful in 3s
Tests / test (pull_request) Successful in 1m4s

Add the 3D Workshop scene: Timmy's room with wizard figure, Pip the
familiar, fireplace lighting, crystal ball, and touch-friendly controls.
All vanilla JS with Three.js from CDN — no npm dependencies.

Fixes #361

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kimi
2026-03-19 02:13:34 -04:00
parent e89aef41bc
commit 8037a63e9b
7 changed files with 650 additions and 0 deletions

55
static/world/controls.js vendored Normal file
View File

@@ -0,0 +1,55 @@
/**
* controls.js — Camera + touch controls for the Workshop scene.
*/
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
/**
* Set up the camera and OrbitControls with iPad-friendly defaults.
* Returns { camera, controls }.
*/
export function createControls(renderer) {
const camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.1,
50,
);
// Visitor just walked in the door
camera.position.set(0, 2.2, 4);
camera.lookAt(0, 1.2, -3);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
// Limit zoom range
controls.minDistance = 2;
controls.maxDistance = 8;
// Limit vertical rotation
controls.minPolarAngle = Math.PI * 0.2;
controls.maxPolarAngle = Math.PI * 0.55;
// Limit horizontal rotation
controls.minAzimuthAngle = -Math.PI * 0.4;
controls.maxAzimuthAngle = Math.PI * 0.4;
// Focus point — Timmy's desk area
controls.target.set(0, 1.2, -3);
// Touch-friendly
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
};
// Resize handler
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
return { camera, controls };
}

108
static/world/familiar.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* familiar.js — Pip the familiar: a glowing emerald orb that wanders the room.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const CORE_COLOR = 0x00b450;
const TRAIL_COLOR = 0xdaa520;
/**
* Create Pip and return { group, update(dt) }.
*/
export function createFamiliar() {
const group = new THREE.Group();
// Core orb
const coreGeo = new THREE.SphereGeometry(0.1, 16, 16);
const coreMat = new THREE.MeshStandardMaterial({
color: CORE_COLOR,
emissive: CORE_COLOR,
emissiveIntensity: 0.8,
});
const core = new THREE.Mesh(coreGeo, coreMat);
group.add(core);
// Point light attached to Pip
const glow = new THREE.PointLight(CORE_COLOR, 0.5, 4);
group.add(glow);
// Gold particle trail — small spheres that follow
const trailMat = new THREE.MeshBasicMaterial({
color: TRAIL_COLOR,
transparent: true,
opacity: 0.5,
});
const trailGeo = new THREE.SphereGeometry(0.025, 8, 8);
const TRAIL_COUNT = 6;
const trail = [];
for (let i = 0; i < TRAIL_COUNT; i++) {
const dot = new THREE.Mesh(trailGeo, trailMat.clone());
dot.visible = true;
group.add(dot);
trail.push(dot);
}
// Wander state
let _elapsed = 0;
let _target = _randomTarget();
let _speed = 0.6;
let _pauseTimer = 0;
// Start position
group.position.set(2, 2, -2);
function _randomTarget() {
return new THREE.Vector3(
(Math.random() - 0.5) * 8,
1.2 + Math.random() * 2.5,
-1 - Math.random() * 4,
);
}
function update(dt) {
_elapsed += dt;
// Pause occasionally near Timmy
if (_pauseTimer > 0) {
_pauseTimer -= dt;
// Bob gently while paused
group.position.y += Math.sin(_elapsed * 3) * 0.002;
_updateTrail();
return;
}
// Move toward target
const dir = _target.clone().sub(group.position);
const dist = dir.length();
if (dist < 0.3) {
_target = _randomTarget();
// 30% chance to pause near Timmy
if (Math.random() < 0.3) {
_pauseTimer = 1.5 + Math.random() * 2;
}
} else {
dir.normalize();
group.position.addScaledVector(dir, _speed * dt);
}
// Gentle bobbing
group.position.y += Math.sin(_elapsed * 2.5) * 0.003;
// Pulse the glow
coreMat.emissiveIntensity = 0.6 + Math.sin(_elapsed * 4) * 0.2;
glow.intensity = 0.4 + Math.sin(_elapsed * 4) * 0.15;
_updateTrail();
}
function _updateTrail() {
for (let i = trail.length - 1; i > 0; i--) {
trail[i].position.lerp(trail[i - 1].position, 0.3);
trail[i].material.opacity = 0.4 * (1 - i / trail.length);
}
// First trail dot follows core with slight lag
trail[0].position.set(0, 0, 0);
}
return { group, update };
}

121
static/world/index.html Normal file
View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Timmy's Workshop</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Status overlay -->
<div id="status-overlay">
<div class="name">Timmy</div>
<div class="mood" id="mood-text">focused</div>
</div>
<!-- Speech bubble area -->
<div id="speech-area">
<div id="speech-bubble"></div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
import { createRoom, createLighting } from "./scene.js";
import { createWizard } from "./wizard.js";
import { createFamiliar } from "./familiar.js";
import { initState, getState, onStateChange } from "./state.js";
// ---- Renderer ----
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
document.body.appendChild(renderer.domElement);
// ---- Scene ----
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a14);
scene.fog = new THREE.Fog(0x0a0a14, 8, 16);
// ---- Camera + Controls ----
const camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.1,
50,
);
camera.position.set(0, 2.2, 4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 2;
controls.maxDistance = 8;
controls.minPolarAngle = Math.PI * 0.2;
controls.maxPolarAngle = Math.PI * 0.55;
controls.minAzimuthAngle = -Math.PI * 0.4;
controls.maxAzimuthAngle = Math.PI * 0.4;
controls.target.set(0, 1.2, -3);
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
// ---- Build scene ----
const room = createRoom();
scene.add(room);
createLighting(scene);
const wizard = createWizard();
scene.add(wizard.group);
const familiar = createFamiliar();
scene.add(familiar.group);
// ---- State ----
const moodEl = document.getElementById("mood-text");
const speechBubble = document.getElementById("speech-bubble");
onStateChange((state) => {
if (state.timmyState) {
moodEl.textContent = state.timmyState.mood || "idle";
}
});
initState();
// ---- Resize ----
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ---- Render loop ----
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
wizard.update(dt);
familiar.update(dt);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

163
static/world/scene.js Normal file
View File

@@ -0,0 +1,163 @@
/**
* scene.js — Room geometry, lighting, and materials for Timmy's Workshop.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
/**
* Build the Workshop room and return the group.
*/
export function createRoom() {
const room = new THREE.Group();
// Floor — dark stone
const floorGeo = new THREE.PlaneGeometry(12, 12);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x1a1a2e,
roughness: 0.9,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
room.add(floor);
// Back wall
const wallGeo = new THREE.PlaneGeometry(12, 6);
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1a1a2e,
roughness: 0.95,
side: THREE.DoubleSide,
});
const backWall = new THREE.Mesh(wallGeo, wallMat);
backWall.position.set(0, 3, -6);
backWall.receiveShadow = true;
room.add(backWall);
// Left wall
const leftWall = new THREE.Mesh(wallGeo, wallMat);
leftWall.position.set(-6, 3, 0);
leftWall.rotation.y = Math.PI / 2;
leftWall.receiveShadow = true;
room.add(leftWall);
// Right wall
const rightWall = new THREE.Mesh(wallGeo, wallMat);
rightWall.position.set(6, 3, 0);
rightWall.rotation.y = -Math.PI / 2;
rightWall.receiveShadow = true;
room.add(rightWall);
// Desk
const desk = _createDesk();
desk.position.set(0, 0, -3);
room.add(desk);
// Crystal ball on desk
const crystal = _createCrystalBall();
crystal.position.set(0.8, 1.15, -3);
room.add(crystal);
return room;
}
function _createDesk() {
const desk = new THREE.Group();
const woodColor = 0x3e2723;
// Tabletop
const topGeo = new THREE.BoxGeometry(3, 0.1, 1.5);
const woodMat = new THREE.MeshStandardMaterial({
color: woodColor,
roughness: 0.8,
});
const top = new THREE.Mesh(topGeo, woodMat);
top.position.y = 1.0;
top.castShadow = true;
top.receiveShadow = true;
desk.add(top);
// Four legs
const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 1.0, 8);
const offsets = [
[-1.3, -0.6],
[1.3, -0.6],
[-1.3, 0.6],
[1.3, 0.6],
];
offsets.forEach(([x, z]) => {
const leg = new THREE.Mesh(legGeo, woodMat);
leg.position.set(x, 0.5, z);
leg.castShadow = true;
desk.add(leg);
});
// Scattered papers (thin boxes on the desk surface)
const paperMat = new THREE.MeshStandardMaterial({
color: 0xd4c5a9,
roughness: 0.6,
});
for (let i = 0; i < 3; i++) {
const paperGeo = new THREE.BoxGeometry(
0.3 + Math.random() * 0.3,
0.005,
0.4 + Math.random() * 0.2,
);
const paper = new THREE.Mesh(paperGeo, paperMat);
paper.position.set(-0.5 + i * 0.5, 1.06, 0.1 * (i - 1));
paper.rotation.y = (Math.random() - 0.5) * 0.4;
desk.add(paper);
}
return desk;
}
function _createCrystalBall() {
const group = new THREE.Group();
// Base
const baseGeo = new THREE.CylinderGeometry(0.15, 0.2, 0.08, 16);
const baseMat = new THREE.MeshStandardMaterial({
color: 0x555555,
metalness: 0.6,
roughness: 0.3,
});
const base = new THREE.Mesh(baseGeo, baseMat);
group.add(base);
// Glass sphere
const sphereGeo = new THREE.SphereGeometry(0.18, 24, 24);
const sphereMat = new THREE.MeshPhysicalMaterial({
color: 0x88ccff,
transmission: 0.8,
roughness: 0.05,
metalness: 0.0,
thickness: 0.3,
emissive: 0x004488,
emissiveIntensity: 0.3,
});
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
sphere.position.y = 0.2;
group.add(sphere);
return group;
}
/**
* Set up all scene lighting.
*/
export function createLighting(scene) {
// Emerald ambient
const ambient = new THREE.AmbientLight(0x00b450, 0.15);
scene.add(ambient);
// Fireplace warm light (stage left, off-screen)
const fireLight = new THREE.PointLight(0xff6633, 1.2, 15);
fireLight.position.set(-7, 3, -1);
fireLight.castShadow = true;
fireLight.shadow.mapSize.set(512, 512);
scene.add(fireLight);
// Soft fill from above
const fillLight = new THREE.PointLight(0xffeedd, 0.3, 12);
fillLight.position.set(0, 5, 0);
scene.add(fillLight);
}

47
static/world/state.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* state.js — State reader for Timmy's Workshop.
*
* Phase 2: hardcoded JSON fallback.
* Phase 3 will upgrade to WebSocket via /api/world/ws.
*/
const DEFAULT_STATE = {
timmyState: {
mood: "focused",
activity: "thinking",
energy: 0.7,
confidence: 0.8,
},
visitorPresent: false,
updatedAt: new Date().toISOString(),
};
let _current = { ...DEFAULT_STATE };
let _listeners = [];
export function getState() {
return _current;
}
export function onStateChange(fn) {
_listeners.push(fn);
}
function _notify() {
_listeners.forEach((fn) => fn(_current));
}
/**
* Fetch initial state from the API, fall back to defaults.
*/
export async function initState() {
try {
const res = await fetch("/api/world/state");
if (res.ok) {
_current = await res.json();
}
} catch {
// Graceful degradation — use defaults
}
_notify();
}

70
static/world/style.css Normal file
View File

@@ -0,0 +1,70 @@
/* Workshop overlay styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #0a0a14;
font-family: "Courier New", monospace;
color: #cccccc;
}
canvas {
display: block;
}
/* Status overlay — top-left */
#status-overlay {
position: fixed;
top: 16px;
left: 16px;
z-index: 10;
pointer-events: none;
}
#status-overlay .name {
font-size: 14px;
letter-spacing: 2px;
text-transform: uppercase;
color: #daa520;
text-shadow: 0 0 8px rgba(218, 165, 32, 0.4);
}
#status-overlay .mood {
font-size: 12px;
color: #00b450;
margin-top: 4px;
opacity: 0.8;
}
/* Speech area — bottom-center */
#speech-area {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: 480px;
width: 90%;
text-align: center;
pointer-events: none;
}
#speech-bubble {
background: rgba(10, 10, 20, 0.85);
border: 1px solid rgba(218, 165, 32, 0.3);
border-radius: 8px;
padding: 12px 18px;
font-size: 14px;
line-height: 1.5;
color: #e0e0e0;
opacity: 0;
transition: opacity 0.4s ease;
}
#speech-bubble.visible {
opacity: 1;
}

86
static/world/wizard.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* wizard.js — Timmy's character model built from primitives + idle animations.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const ROBE_COLOR = 0x2d1b4e;
const GOLD_TRIM = 0xdaa520;
/**
* Build the wizard figure and return { group, update(dt) }.
*/
export function createWizard() {
const group = new THREE.Group();
const robeMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.7,
});
const goldMat = new THREE.MeshStandardMaterial({
color: GOLD_TRIM,
metalness: 0.5,
roughness: 0.4,
});
const skinMat = new THREE.MeshStandardMaterial({
color: 0xd4a574,
roughness: 0.6,
});
// Body — cone
const bodyGeo = new THREE.ConeGeometry(0.55, 1.6, 12);
const body = new THREE.Mesh(bodyGeo, robeMat);
body.position.y = 0.8;
body.castShadow = true;
group.add(body);
// Gold belt
const beltGeo = new THREE.TorusGeometry(0.35, 0.03, 8, 24);
const belt = new THREE.Mesh(beltGeo, goldMat);
belt.position.y = 0.7;
belt.rotation.x = Math.PI / 2;
group.add(belt);
// Head
const headGeo = new THREE.SphereGeometry(0.22, 16, 16);
const head = new THREE.Mesh(headGeo, skinMat);
head.position.y = 1.82;
head.castShadow = true;
group.add(head);
// Hood — larger cone behind head
const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 12, 1, true);
const hood = new THREE.Mesh(hoodGeo, robeMat);
hood.position.set(0, 1.95, -0.05);
hood.rotation.x = 0.15;
group.add(hood);
// Arms — cylinders angled outward
[-1, 1].forEach((side) => {
const armGeo = new THREE.CylinderGeometry(0.08, 0.06, 0.7, 8);
const arm = new THREE.Mesh(armGeo, robeMat);
arm.position.set(side * 0.45, 1.1, 0.15);
arm.rotation.z = side * 0.4;
arm.castShadow = true;
group.add(arm);
});
// Position behind desk, facing visitor
group.position.set(0, 0, -4.2);
// Animation state
let _elapsed = 0;
function update(dt) {
_elapsed += dt;
// Breathing — gentle Y scale oscillation
const breathe = 1 + Math.sin(_elapsed * 1.5) * 0.01;
group.scale.y = breathe;
// Occasional head tilt
const tilt = Math.sin(_elapsed * 0.4) * 0.06;
head.rotation.z = tilt;
}
return { group, update };
}