diff --git a/static/world/controls.js b/static/world/controls.js new file mode 100644 index 0000000..9282285 --- /dev/null +++ b/static/world/controls.js @@ -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; +} diff --git a/static/world/familiar.js b/static/world/familiar.js new file mode 100644 index 0000000..e862d30 --- /dev/null +++ b/static/world/familiar.js @@ -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 }; +} diff --git a/static/world/index.html b/static/world/index.html new file mode 100644 index 0000000..cd3a99a --- /dev/null +++ b/static/world/index.html @@ -0,0 +1,108 @@ + + + + + + Timmy's Workshop + + + +
+
+
Timmy
+
focused
+
+
+
+
+
+
+ + + + + diff --git a/static/world/scene.js b/static/world/scene.js new file mode 100644 index 0000000..8dbf318 --- /dev/null +++ b/static/world/scene.js @@ -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 }; +} diff --git a/static/world/state.js b/static/world/state.js new file mode 100644 index 0000000..a24e6ad --- /dev/null +++ b/static/world/state.js @@ -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; + } +} diff --git a/static/world/style.css b/static/world/style.css new file mode 100644 index 0000000..e65e69d --- /dev/null +++ b/static/world/style.css @@ -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; +} diff --git a/static/world/wizard.js b/static/world/wizard.js new file mode 100644 index 0000000..d6f08d0 --- /dev/null +++ b/static/world/wizard.js @@ -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 }; +}