feat: Workshop Phase 2 — Scene MVP (Three.js room) (#401)

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-19 02:14:09 -04:00
committed by hermes
parent e89aef41bc
commit ab3546ae4b
7 changed files with 745 additions and 0 deletions

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

@@ -0,0 +1,50 @@
/**
* Camera + touch controls for the Workshop scene.
*
* Uses Three.js OrbitControls with constrained range — the visitor
* can look around the room but not leave it.
*/
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
/**
* Set up camera controls.
* @param {THREE.PerspectiveCamera} camera
* @param {HTMLCanvasElement} domElement
* @returns {OrbitControls}
*/
export function setupControls(camera, domElement) {
const controls = new OrbitControls(camera, domElement);
// Smooth damping
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Limit zoom range
controls.minDistance = 3;
controls.maxDistance = 12;
// Limit vertical angle (don't look below floor or straight up)
controls.minPolarAngle = Math.PI * 0.2;
controls.maxPolarAngle = Math.PI * 0.6;
// Limit horizontal rotation range (stay facing the desk area)
controls.minAzimuthAngle = -Math.PI * 0.4;
controls.maxAzimuthAngle = Math.PI * 0.4;
// Target: roughly the desk area
controls.target.set(0, 1.2, 0);
// Touch settings
controls.touches = {
ONE: 0, // ROTATE
TWO: 2, // DOLLY
};
// Disable panning (visitor stays in place)
controls.enablePan = false;
controls.update();
return controls;
}

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

@@ -0,0 +1,150 @@
/**
* Pip the Familiar — a small glowing orb that floats around the room.
*
* Emerald green core with a gold particle trail.
* Wanders on a randomized path, occasionally pauses near Timmy.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const CORE_COLOR = 0x00b450;
const GLOW_COLOR = 0x00b450;
const TRAIL_COLOR = 0xdaa520;
/**
* Create the familiar and return { group, update }.
* Call update(dt) each frame.
*/
export function createFamiliar() {
const group = new THREE.Group();
// --- Core orb ---
const coreGeo = new THREE.SphereGeometry(0.08, 12, 10);
const coreMat = new THREE.MeshStandardMaterial({
color: CORE_COLOR,
emissive: GLOW_COLOR,
emissiveIntensity: 1.5,
roughness: 0.2,
});
const core = new THREE.Mesh(coreGeo, coreMat);
group.add(core);
// --- Glow (larger transparent sphere) ---
const glowGeo = new THREE.SphereGeometry(0.15, 10, 8);
const glowMat = new THREE.MeshBasicMaterial({
color: GLOW_COLOR,
transparent: true,
opacity: 0.15,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
// --- Point light from Pip ---
const light = new THREE.PointLight(CORE_COLOR, 0.4, 4);
group.add(light);
// --- Trail particles (simple small spheres) ---
const trailCount = 6;
const trails = [];
const trailGeo = new THREE.SphereGeometry(0.02, 4, 4);
const trailMat = new THREE.MeshBasicMaterial({
color: TRAIL_COLOR,
transparent: true,
opacity: 0.6,
});
for (let i = 0; i < trailCount; i++) {
const t = new THREE.Mesh(trailGeo, trailMat.clone());
t.visible = false;
group.add(t);
trails.push({ mesh: t, age: 0, maxAge: 0.3 + Math.random() * 0.3 });
}
// Starting position
group.position.set(1.5, 1.8, -0.5);
// Wandering state
let elapsed = 0;
let trailTimer = 0;
let trailIndex = 0;
// Waypoints for random wandering
const waypoints = [
new THREE.Vector3(1.5, 1.8, -0.5),
new THREE.Vector3(-1.0, 2.0, 0.5),
new THREE.Vector3(0.0, 1.5, -0.3), // near Timmy
new THREE.Vector3(1.2, 2.2, 0.8),
new THREE.Vector3(-0.5, 1.3, -0.2), // near desk
new THREE.Vector3(0.3, 2.5, 0.3),
];
let waypointIndex = 0;
let target = waypoints[0].clone();
let pauseTimer = 0;
function pickNextTarget() {
waypointIndex = (waypointIndex + 1) % waypoints.length;
target.copy(waypoints[waypointIndex]);
// Add randomness
target.x += (Math.random() - 0.5) * 0.6;
target.y += (Math.random() - 0.5) * 0.3;
target.z += (Math.random() - 0.5) * 0.6;
}
function update(dt) {
elapsed += dt;
// Move toward target
if (pauseTimer > 0) {
pauseTimer -= dt;
} else {
const dir = target.clone().sub(group.position);
const dist = dir.length();
if (dist < 0.15) {
pickNextTarget();
// Occasionally pause
if (Math.random() < 0.3) {
pauseTimer = 1.0 + Math.random() * 2.0;
}
} else {
dir.normalize();
const speed = 0.4;
group.position.add(dir.multiplyScalar(speed * dt));
}
}
// Bob up and down
group.position.y += Math.sin(elapsed * 3.0) * 0.002;
// Pulse glow
const pulse = 0.12 + Math.sin(elapsed * 4.0) * 0.05;
glowMat.opacity = pulse;
coreMat.emissiveIntensity = 1.2 + Math.sin(elapsed * 3.5) * 0.4;
// Trail particles
trailTimer += dt;
if (trailTimer > 0.1) {
trailTimer = 0;
const t = trails[trailIndex];
t.mesh.position.copy(group.position);
t.mesh.position.x += (Math.random() - 0.5) * 0.1;
t.mesh.position.y += (Math.random() - 0.5) * 0.1;
t.mesh.visible = true;
t.age = 0;
// Convert to local space
group.worldToLocal(t.mesh.position);
trailIndex = (trailIndex + 1) % trailCount;
}
// Age and fade trail particles
for (const t of trails) {
if (!t.mesh.visible) continue;
t.age += dt;
if (t.age >= t.maxAge) {
t.mesh.visible = false;
} else {
t.mesh.material.opacity = 0.6 * (1.0 - t.age / t.maxAge);
}
}
}
return { group, update };
}

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

@@ -0,0 +1,108 @@
<!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>
<div id="overlay">
<div id="status">
<div class="name">Timmy</div>
<div class="mood" id="mood-text">focused</div>
</div>
<div id="connection-dot"></div>
<div id="speech-area">
<div class="bubble" id="speech-bubble"></div>
</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 { buildRoom } from "./scene.js";
import { createWizard } from "./wizard.js";
import { createFamiliar } from "./familiar.js";
import { setupControls } from "./controls.js";
import { StateReader } from "./state.js";
// --- Renderer ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;
document.body.prepend(renderer.domElement);
// --- Scene ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a14);
scene.fog = new THREE.Fog(0x0a0a14, 5, 12);
// --- Camera (visitor at the door) ---
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 50
);
camera.position.set(0, 2.0, 4.5);
// --- Build scene elements ---
const { crystalBall, fireLight } = buildRoom(scene);
const wizard = createWizard();
scene.add(wizard.group);
const familiar = createFamiliar();
scene.add(familiar.group);
// --- Controls ---
const controls = setupControls(camera, renderer.domElement);
// --- State ---
const stateReader = new StateReader();
const moodEl = document.getElementById("mood-text");
stateReader.onChange((state) => {
if (moodEl) {
moodEl.textContent = state.timmyState.mood;
}
});
stateReader.connect();
// --- Resize ---
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Animation loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
// Update scene elements
wizard.update(dt);
familiar.update(dt);
controls.update();
// Crystal ball subtle rotation
crystalBall.rotation.y += dt * 0.3;
// Fireplace flicker
fireLight.intensity = 1.2 + Math.sin(Date.now() * 0.005) * 0.15
+ Math.sin(Date.now() * 0.013) * 0.1;
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

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

@@ -0,0 +1,154 @@
/**
* Workshop scene — room geometry, lighting, materials.
*
* A dark stone room with a wooden desk, crystal ball, fireplace glow,
* and faint emerald ambient light. This is Timmy's Workshop.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const WALL_COLOR = 0x1a1a2e;
const FLOOR_COLOR = 0x1a1a1a;
const DESK_COLOR = 0x3e2723;
const DESK_TOP_COLOR = 0x4e342e;
/**
* Build the room and add it to the given scene.
* Returns { crystalBall } for animation.
*/
export function buildRoom(scene) {
// --- Floor ---
const floorGeo = new THREE.PlaneGeometry(8, 8);
const floorMat = new THREE.MeshStandardMaterial({
color: FLOOR_COLOR,
roughness: 0.9,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// --- Back wall ---
const wallGeo = new THREE.PlaneGeometry(8, 4);
const wallMat = new THREE.MeshStandardMaterial({
color: WALL_COLOR,
roughness: 0.95,
});
const backWall = new THREE.Mesh(wallGeo, wallMat);
backWall.position.set(0, 2, -4);
scene.add(backWall);
// --- Side walls ---
const leftWall = new THREE.Mesh(wallGeo, wallMat);
leftWall.position.set(-4, 2, 0);
leftWall.rotation.y = Math.PI / 2;
scene.add(leftWall);
const rightWall = new THREE.Mesh(wallGeo, wallMat);
rightWall.position.set(4, 2, 0);
rightWall.rotation.y = -Math.PI / 2;
scene.add(rightWall);
// --- Desk ---
// Table top
const topGeo = new THREE.BoxGeometry(1.8, 0.08, 0.9);
const topMat = new THREE.MeshStandardMaterial({
color: DESK_TOP_COLOR,
roughness: 0.6,
});
const tableTop = new THREE.Mesh(topGeo, topMat);
tableTop.position.set(0, 0.85, -0.3);
tableTop.castShadow = true;
scene.add(tableTop);
// Legs
const legGeo = new THREE.BoxGeometry(0.08, 0.85, 0.08);
const legMat = new THREE.MeshStandardMaterial({
color: DESK_COLOR,
roughness: 0.7,
});
const offsets = [
[-0.8, -0.35],
[0.8, -0.35],
[-0.8, 0.05],
[0.8, 0.05],
];
for (const [x, z] of offsets) {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(x, 0.425, z - 0.3);
scene.add(leg);
}
// --- Scrolls / papers on desk (simple flat boxes) ---
const paperGeo = new THREE.BoxGeometry(0.3, 0.005, 0.2);
const paperMat = new THREE.MeshStandardMaterial({
color: 0xd4c5a0,
roughness: 0.9,
});
const paper1 = new THREE.Mesh(paperGeo, paperMat);
paper1.position.set(-0.4, 0.895, -0.35);
paper1.rotation.y = 0.15;
scene.add(paper1);
const paper2 = new THREE.Mesh(paperGeo, paperMat);
paper2.position.set(0.5, 0.895, -0.2);
paper2.rotation.y = -0.3;
scene.add(paper2);
// --- Crystal ball ---
const ballGeo = new THREE.SphereGeometry(0.12, 16, 14);
const ballMat = new THREE.MeshPhysicalMaterial({
color: 0x88ccff,
roughness: 0.05,
metalness: 0.0,
transmission: 0.9,
thickness: 0.3,
transparent: true,
opacity: 0.7,
});
const crystalBall = new THREE.Mesh(ballGeo, ballMat);
crystalBall.position.set(0.15, 1.01, -0.3);
scene.add(crystalBall);
// Crystal ball base
const baseGeo = new THREE.CylinderGeometry(0.08, 0.1, 0.04, 8);
const baseMat = new THREE.MeshStandardMaterial({
color: 0x444444,
roughness: 0.3,
metalness: 0.5,
});
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.set(0.15, 0.9, -0.3);
scene.add(base);
// Crystal ball inner glow
const innerLight = new THREE.PointLight(0x88ccff, 0.3, 2);
innerLight.position.copy(crystalBall.position);
scene.add(innerLight);
// --- Lighting ---
// Fireplace glow (warm, off-screen stage left)
const fireLight = new THREE.PointLight(0xff6622, 1.2, 8);
fireLight.position.set(-3.5, 1.2, -1.0);
fireLight.castShadow = true;
fireLight.shadow.mapSize.width = 512;
fireLight.shadow.mapSize.height = 512;
scene.add(fireLight);
// Secondary warm fill
const fillLight = new THREE.PointLight(0xff8844, 0.3, 6);
fillLight.position.set(-2.0, 0.5, 1.0);
scene.add(fillLight);
// Emerald ambient
const ambient = new THREE.AmbientLight(0x00b450, 0.15);
scene.add(ambient);
// Faint overhead to keep things readable
const overhead = new THREE.PointLight(0x887766, 0.2, 8);
overhead.position.set(0, 3.5, 0);
scene.add(overhead);
return { crystalBall, fireLight };
}

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

@@ -0,0 +1,95 @@
/**
* State reader — hardcoded JSON for Phase 2, WebSocket in Phase 3.
*
* Provides Timmy's current state to the scene. In Phase 2 this is a
* static default; the WebSocket path is stubbed for future use.
*/
const DEFAULTS = {
timmyState: {
mood: "focused",
activity: "Pondering the arcane arts",
energy: 0.6,
confidence: 0.7,
},
activeThreads: [],
recentEvents: [],
concerns: [],
visitorPresent: false,
updatedAt: new Date().toISOString(),
version: 1,
};
export class StateReader {
constructor() {
this.state = { ...DEFAULTS };
this.listeners = [];
this._ws = null;
}
/** Subscribe to state changes. */
onChange(fn) {
this.listeners.push(fn);
}
/** Notify all listeners. */
_notify() {
for (const fn of this.listeners) {
try {
fn(this.state);
} catch (e) {
console.warn("State listener error:", e);
}
}
}
/** Try to connect to the world WebSocket for live updates. */
connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/api/world/ws`;
try {
this._ws = new WebSocket(url);
this._ws.onopen = () => {
const dot = document.getElementById("connection-dot");
if (dot) dot.classList.add("connected");
};
this._ws.onclose = () => {
const dot = document.getElementById("connection-dot");
if (dot) dot.classList.remove("connected");
};
this._ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "world_state" || msg.type === "timmy_state") {
if (msg.timmyState) this.state.timmyState = msg.timmyState;
if (msg.mood) {
this.state.timmyState.mood = msg.mood;
this.state.timmyState.activity = msg.activity || "";
this.state.timmyState.energy = msg.energy ?? 0.5;
}
this._notify();
}
} catch (e) {
/* ignore parse errors */
}
};
} catch (e) {
console.warn("WebSocket unavailable — using static state");
}
}
/** Current mood string. */
get mood() {
return this.state.timmyState.mood;
}
/** Current activity string. */
get activity() {
return this.state.timmyState.activity;
}
/** Energy level 0-1. */
get energy() {
return this.state.timmyState.energy;
}
}

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

@@ -0,0 +1,89 @@
/* Workshop 3D scene overlay styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #0a0a14;
font-family: "Courier New", monospace;
color: #e0e0e0;
touch-action: none;
}
canvas {
display: block;
}
#overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
#status {
position: absolute;
top: 16px;
left: 16px;
font-size: 14px;
opacity: 0.8;
}
#status .name {
font-size: 18px;
font-weight: bold;
color: #daa520;
}
#status .mood {
font-size: 13px;
color: #aaa;
margin-top: 4px;
}
#speech-area {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
max-width: 480px;
width: 90%;
text-align: center;
font-size: 15px;
line-height: 1.5;
color: #ccc;
opacity: 0;
transition: opacity 0.4s ease;
}
#speech-area.visible {
opacity: 1;
}
#speech-area .bubble {
background: rgba(10, 10, 20, 0.85);
border: 1px solid rgba(218, 165, 32, 0.3);
border-radius: 8px;
padding: 12px 20px;
}
#connection-dot {
position: absolute;
top: 18px;
right: 16px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #555;
}
#connection-dot.connected {
background: #00b450;
}

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

@@ -0,0 +1,99 @@
/**
* Timmy the Wizard — geometric figure built from primitives.
*
* Phase 1: cone body (robe), sphere head, cylinder arms.
* Idle animation: gentle breathing (Y-scale oscillation), head tilt.
*/
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
const ROBE_COLOR = 0x2d1b4e;
const TRIM_COLOR = 0xdaa520;
/**
* Create the wizard group and return { group, update }.
* Call update(dt) each frame for idle animation.
*/
export function createWizard() {
const group = new THREE.Group();
// --- Robe (cone) ---
const robeGeo = new THREE.ConeGeometry(0.5, 1.6, 8);
const robeMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const robe = new THREE.Mesh(robeGeo, robeMat);
robe.position.y = 0.8;
group.add(robe);
// --- Trim ring at robe bottom ---
const trimGeo = new THREE.TorusGeometry(0.5, 0.03, 8, 24);
const trimMat = new THREE.MeshStandardMaterial({
color: TRIM_COLOR,
roughness: 0.4,
metalness: 0.3,
});
const trim = new THREE.Mesh(trimGeo, trimMat);
trim.rotation.x = Math.PI / 2;
trim.position.y = 0.02;
group.add(trim);
// --- Head (sphere) ---
const headGeo = new THREE.SphereGeometry(0.22, 12, 10);
const headMat = new THREE.MeshStandardMaterial({
color: 0xd4a574,
roughness: 0.7,
});
const head = new THREE.Mesh(headGeo, headMat);
head.position.y = 1.72;
group.add(head);
// --- Hood (cone behind head) ---
const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 8);
const hoodMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const hood = new THREE.Mesh(hoodGeo, hoodMat);
hood.position.y = 1.85;
hood.position.z = -0.08;
group.add(hood);
// --- Arms (cylinders) ---
const armGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.7, 6);
const armMat = new THREE.MeshStandardMaterial({
color: ROBE_COLOR,
roughness: 0.8,
});
const leftArm = new THREE.Mesh(armGeo, armMat);
leftArm.position.set(-0.45, 1.0, 0.15);
leftArm.rotation.z = 0.3;
leftArm.rotation.x = -0.4;
group.add(leftArm);
const rightArm = new THREE.Mesh(armGeo, armMat);
rightArm.position.set(0.45, 1.0, 0.15);
rightArm.rotation.z = -0.3;
rightArm.rotation.x = -0.4;
group.add(rightArm);
// Position behind the desk
group.position.set(0, 0, -0.8);
// Animation state
let elapsed = 0;
function update(dt) {
elapsed += dt;
// Breathing: subtle Y-scale oscillation
const breath = 1.0 + Math.sin(elapsed * 1.5) * 0.015;
robe.scale.y = breath;
// Head tilt
head.rotation.z = Math.sin(elapsed * 0.7) * 0.05;
head.rotation.x = Math.sin(elapsed * 0.5) * 0.03;
}
return { group, update };
}