From a70898e939d316e1066f8d22da3866fb4c383f4e Mon Sep 17 00:00:00 2001 From: replit Date: Wed, 18 Mar 2026 22:15:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(epic222):=20Workshop=20=E2=80=94=20Timmy?= =?UTF-8?q?=20as=20wizard=20presence,=20world=20state,=20WS=20bootstrap=20?= =?UTF-8?q?(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artifacts/api-server/src/lib/world-state.ts | 52 +++ artifacts/api-server/src/routes/events.ts | 109 ++++- artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/world.ts | 29 ++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/world-events.ts | 10 + the-matrix/index.html | 313 +++++++------- the-matrix/js/agents.js | 452 +++++++++++--------- the-matrix/js/effects.js | 116 +++-- the-matrix/js/main.js | 15 +- the-matrix/js/ui.js | 207 +++------ the-matrix/js/websocket.js | 100 ++--- the-matrix/js/world.js | 160 ++++--- 13 files changed, 863 insertions(+), 703 deletions(-) create mode 100644 artifacts/api-server/src/lib/world-state.ts create mode 100644 artifacts/api-server/src/routes/world.ts create mode 100644 lib/db/src/schema/world-events.ts diff --git a/artifacts/api-server/src/lib/world-state.ts b/artifacts/api-server/src/lib/world-state.ts new file mode 100644 index 0000000..5e869bc --- /dev/null +++ b/artifacts/api-server/src/lib/world-state.ts @@ -0,0 +1,52 @@ +export interface TimmyState { + mood: string; + activity: string; +} + +export interface WorldState { + timmyState: TimmyState; + agentStates: Record; + updatedAt: string; +} + +const DEFAULT_TIMMY: TimmyState = { + mood: "contemplative", + activity: "idle", +}; + +const _state: WorldState = { + timmyState: { ...DEFAULT_TIMMY }, + agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" }, + updatedAt: new Date().toISOString(), +}; + +export function getWorldState(): WorldState { + return { + timmyState: { ..._state.timmyState }, + agentStates: { ..._state.agentStates }, + updatedAt: _state.updatedAt, + }; +} + +export function setAgentStateInWorld(agentId: string, agentState: string): void { + _state.agentStates[agentId] = agentState; + _state.updatedAt = new Date().toISOString(); + _deriveTimmy(); +} + +function _deriveTimmy(): void { + const states = Object.values(_state.agentStates); + if (states.includes("working")) { + _state.timmyState.activity = "working"; + _state.timmyState.mood = "focused"; + } else if (states.includes("thinking") || states.includes("evaluating")) { + _state.timmyState.activity = "thinking"; + _state.timmyState.mood = "curious"; + } else if (states.some((s) => s !== "idle")) { + _state.timmyState.activity = "active"; + _state.timmyState.mood = "attentive"; + } else { + _state.timmyState.activity = "idle"; + _state.timmyState.mood = "contemplative"; + } +} diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index 7dfad42..c4481d8 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -1,40 +1,77 @@ /** - * /api/ws — WebSocket bridge from the internal EventBus to connected Matrix clients. + * /api/ws — WebSocket bridge from the internal EventBus to connected Workshop clients. * * Protocol (server → client): + * { type: "world_state", timmyState, agentStates, recentEvents, updatedAt } * { type: "agent_state", agentId: string, state: "idle"|"active"|"working"|"thinking" } * { type: "job_started", jobId: string, agentId: string } * { type: "job_completed", jobId: string, agentId: string } * { type: "chat", agentId: string, text: string } + * { type: "visitor_count", count: number } * { type: "ping" } * * Protocol (client → server): - * { type: "subscribe", channel: "agents", clientId: string } + * { type: "visitor_enter", visitorId: string, visitorName?: string } + * { type: "visitor_leave", visitorId: string } + * { type: "visitor_message", visitorId: string, text: string } * { type: "pong" } * - * Agent mapping (Matrix IDs → Timmy roles): + * Agent mapping (Workshop): * alpha — orchestrator (overall job lifecycle) * beta — eval (Haiku judge) * gamma — work (Sonnet executor) * delta — lightning (payment monitor / invoice watcher) */ +import { randomUUID } from "crypto"; import type { IncomingMessage } from "http"; import type { WebSocket } from "ws"; import { WebSocketServer } from "ws"; import type { Server } from "http"; import { eventBus, type BusEvent } from "../lib/event-bus.js"; import { makeLogger } from "../lib/logger.js"; +import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js"; +import { db, worldEvents } from "@workspace/db"; const logger = makeLogger("ws-events"); const PING_INTERVAL_MS = 30_000; +function updateAgentWorld(agentId: string, state: string): void { + try { + setAgentStateInWorld(agentId, state); + } catch { + /* non-fatal */ + } +} + +async function logWorldEvent( + type: string, + summary: string, + agentId?: string, + jobId?: string, +): Promise { + try { + await db.insert(worldEvents).values({ + id: randomUUID(), + type, + summary, + agentId: agentId ?? null, + jobId: jobId ?? null, + }); + } catch { + /* non-fatal — don't crash WS on DB error */ + } +} + function translateEvent(ev: BusEvent): object | null { switch (ev.type) { // ── Mode 1 job lifecycle ───────────────────────────────────────────────── case "job:state": { if (ev.state === "evaluating") { + updateAgentWorld("alpha", "active"); + updateAgentWorld("beta", "thinking"); + void logWorldEvent("job:evaluating", `Evaluating job ${ev.jobId.slice(0, 8)}`, "beta", ev.jobId); return [ { type: "agent_state", agentId: "alpha", state: "active" }, { type: "agent_state", agentId: "beta", state: "thinking" }, @@ -42,21 +79,30 @@ function translateEvent(ev: BusEvent): object | null { ]; } if (ev.state === "awaiting_eval_payment") { + updateAgentWorld("alpha", "active"); return { type: "agent_state", agentId: "alpha", state: "active" }; } if (ev.state === "awaiting_work_payment") { + updateAgentWorld("beta", "idle"); + updateAgentWorld("delta", "active"); return [ { type: "agent_state", agentId: "beta", state: "idle" }, { type: "agent_state", agentId: "delta", state: "active" }, ]; } if (ev.state === "executing") { + updateAgentWorld("delta", "idle"); + updateAgentWorld("gamma", "working"); + void logWorldEvent("job:executing", `Working on job ${ev.jobId.slice(0, 8)}`, "gamma", ev.jobId); return [ { type: "agent_state", agentId: "delta", state: "idle" }, { type: "agent_state", agentId: "gamma", state: "working" }, ]; } if (ev.state === "complete") { + updateAgentWorld("gamma", "idle"); + updateAgentWorld("alpha", "idle"); + void logWorldEvent("job:complete", `Completed job ${ev.jobId.slice(0, 8)}`, "alpha", ev.jobId); return [ { type: "agent_state", agentId: "gamma", state: "idle" }, { type: "agent_state", agentId: "alpha", state: "idle" }, @@ -64,6 +110,8 @@ function translateEvent(ev: BusEvent): object | null { ]; } if (ev.state === "rejected" || ev.state === "failed") { + ["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle")); + void logWorldEvent(`job:${ev.state}`, `Job ${ev.jobId.slice(0, 8)} ${ev.state}`, "alpha", ev.jobId); return [ { type: "agent_state", agentId: "beta", state: "idle" }, { type: "agent_state", agentId: "gamma", state: "idle" }, @@ -74,12 +122,15 @@ function translateEvent(ev: BusEvent): object | null { return null; } case "job:completed": + updateAgentWorld("gamma", "idle"); + updateAgentWorld("alpha", "idle"); return [ { type: "agent_state", agentId: "gamma", state: "idle" }, { type: "agent_state", agentId: "alpha", state: "idle" }, { type: "chat", agentId: "gamma", text: `Job ${ev.jobId.slice(0, 8)} complete` }, ]; case "job:failed": + ["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle")); return [ { type: "agent_state", agentId: "alpha", state: "idle" }, { type: "agent_state", agentId: "beta", state: "idle" }, @@ -89,6 +140,9 @@ function translateEvent(ev: BusEvent): object | null { ]; case "job:paid": if (ev.invoiceType === "eval") { + updateAgentWorld("delta", "idle"); + updateAgentWorld("beta", "thinking"); + void logWorldEvent("payment:eval", "Eval payment confirmed", "delta"); return [ { type: "agent_state", agentId: "delta", state: "idle" }, { type: "agent_state", agentId: "beta", state: "thinking" }, @@ -96,6 +150,9 @@ function translateEvent(ev: BusEvent): object | null { ]; } if (ev.invoiceType === "work") { + updateAgentWorld("delta", "idle"); + updateAgentWorld("gamma", "working"); + void logWorldEvent("payment:work", "Work payment confirmed", "delta"); return [ { type: "agent_state", agentId: "delta", state: "idle" }, { type: "agent_state", agentId: "gamma", state: "working" }, @@ -146,6 +203,25 @@ function broadcast(socket: WebSocket, ev: BusEvent): void { } } +async function sendWorldStateBootstrap(socket: WebSocket): Promise { + try { + const state = getWorldState(); + const { desc } = await import("drizzle-orm"); + const recent = await db + .select() + .from(worldEvents) + .orderBy(desc(worldEvents.createdAt)) + .limit(20); + send(socket, { + type: "world_state", + ...state, + recentEvents: recent.reverse(), + }); + } catch { + send(socket, { type: "world_state", ...getWorldState(), recentEvents: [] }); + } +} + export function attachWebSocketServer(server: Server): void { const wss = new WebSocketServer({ server, path: "/api/ws" }); @@ -153,6 +229,8 @@ export function attachWebSocketServer(server: Server): void { const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown"; logger.info("ws client connected", { ip, clients: wss.clients.size }); + void sendWorldStateBootstrap(socket); + const busHandler = (ev: BusEvent) => broadcast(socket, ev); eventBus.on("bus", busHandler); @@ -162,11 +240,34 @@ export function attachWebSocketServer(server: Server): void { socket.on("message", (raw) => { try { - const msg = JSON.parse(raw.toString()) as { type?: string }; + const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string }; if (msg.type === "pong") return; if (msg.type === "subscribe") { send(socket, { type: "agent_count", count: wss.clients.size }); } + if (msg.type === "visitor_enter") { + wss.clients.forEach(c => { + if (c !== socket && c.readyState === 1) { + c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size })); + } + }); + send(socket, { type: "visitor_count", count: wss.clients.size }); + } + if (msg.type === "visitor_leave") { + wss.clients.forEach(c => { + if (c !== socket && c.readyState === 1) { + c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) })); + } + }); + } + if (msg.type === "visitor_message" && msg.text) { + const text = String(msg.text).slice(0, 500); + wss.clients.forEach(c => { + if (c.readyState === 1) { + c.send(JSON.stringify({ type: "chat", agentId: "visitor", text })); + } + }); + } } catch { /* ignore malformed messages */ } diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index a3856a6..e0ba72f 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -9,6 +9,7 @@ import testkitRouter from "./testkit.js"; import uiRouter from "./ui.js"; import nodeDiagnosticsRouter from "./node-diagnostics.js"; import metricsRouter from "./metrics.js"; +import worldRouter from "./world.js"; const router: IRouter = Router(); @@ -21,6 +22,7 @@ router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter); router.use(nodeDiagnosticsRouter); +router.use(worldRouter); if (process.env.NODE_ENV !== "production") { router.use(devRouter); diff --git a/artifacts/api-server/src/routes/world.ts b/artifacts/api-server/src/routes/world.ts new file mode 100644 index 0000000..cbd254a --- /dev/null +++ b/artifacts/api-server/src/routes/world.ts @@ -0,0 +1,29 @@ +import { Router, type Request, type Response } from "express"; +import { db, worldEvents } from "@workspace/db"; +import { desc } from "drizzle-orm"; +import { getWorldState } from "../lib/world-state.js"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("world"); +const router = Router(); + +router.get("/world/state", async (_req: Request, res: Response) => { + try { + const state = getWorldState(); + const recent = await db + .select() + .from(worldEvents) + .orderBy(desc(worldEvents.createdAt)) + .limit(20); + + res.json({ + ...state, + recentEvents: recent.reverse(), + }); + } catch (err) { + logger.error("GET /api/world/state failed", { error: String(err) }); + res.status(500).json({ error: "world_state_error" }); + } +}); + +export default router; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 3043169..ded2293 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -3,4 +3,5 @@ export * from "./invoices"; export * from "./conversations"; export * from "./messages"; export * from "./bootstrap-jobs"; +export * from "./world-events"; export * from "./sessions"; diff --git a/lib/db/src/schema/world-events.ts b/lib/db/src/schema/world-events.ts new file mode 100644 index 0000000..a8a3603 --- /dev/null +++ b/lib/db/src/schema/world-events.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const worldEvents = pgTable("world_events", { + id: text("id").primaryKey(), + type: text("type").notNull(), + agentId: text("agent_id"), + summary: text("summary").notNull(), + jobId: text("job_id"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); diff --git a/the-matrix/index.html b/the-matrix/index.html index 210bb56..fdfd704 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -2,236 +2,256 @@ - - Timmy Tower World + + The Workshop — Timmy - + - + + -
-
-

TIMMY TOWER WORLD

-
AGENTS: 0
-
JOBS: 0
-
FPS: --
-
-
-
-
-
-
OFFLINE
+
+

THE WORKSHOP

+
FPS: --
+
JOBS: 0
- - +
OFFLINE
+
- + + + +
+ + +
+ +
-

⚡ TIMMY TOWER — JOB SUBMISSION

+

⚡ TIMMY — JOB SUBMISSION

-
YOUR REQUEST
- + Open full UI ↗
-
EVAL FEE
10 sats
@@ -244,9 +264,8 @@
-
-
WORK FEE (token-based)
+
WORK FEE
-- sats
LIGHTNING INVOICE
@@ -257,16 +276,14 @@
-
AI RESULT

       
     
- -
-
+
+
diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 4fbd8c0..2d6f176 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -1,237 +1,273 @@ import * as THREE from 'three'; -import { AGENT_DEFS, colorToCss } from './agent-defs.js'; -const agents = new Map(); -let scene; -let connectionLines = []; +const TIMMY_POS = new THREE.Vector3(0, 0, -2); +const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1); -class Agent { - constructor(def) { - this.id = def.id; - this.label = def.label; - this.color = def.color; - this.role = def.role; - this.position = new THREE.Vector3(def.x, 0, def.z); - this.state = 'idle'; - this.pulsePhase = Math.random() * Math.PI * 2; +const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' }; - this.group = new THREE.Group(); - this.group.position.copy(this.position); - - this._buildMeshes(); - this._buildLabel(); - } - - _buildMeshes() { - const mat = new THREE.MeshStandardMaterial({ - color: this.color, - emissive: this.color, - emissiveIntensity: 0.4, - roughness: 0.3, - metalness: 0.8, - }); - - const geo = new THREE.IcosahedronGeometry(0.7, 1); - this.core = new THREE.Mesh(geo, mat); - this.group.add(this.core); - - const ringGeo = new THREE.TorusGeometry(1.1, 0.04, 8, 32); - const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 }); - this.ring = new THREE.Mesh(ringGeo, ringMat); - this.ring.rotation.x = Math.PI / 2; - this.group.add(this.ring); - - const glowGeo = new THREE.SphereGeometry(1.3, 16, 16); - const glowMat = new THREE.MeshBasicMaterial({ - color: this.color, - transparent: true, - opacity: 0.05, - side: THREE.BackSide, - }); - this.glow = new THREE.Mesh(glowGeo, glowMat); - this.group.add(this.glow); - - const light = new THREE.PointLight(this.color, 1.5, 10); - this.group.add(light); - this.light = light; - } - - _buildLabel() { - const canvas = document.createElement('canvas'); - canvas.width = 256; canvas.height = 64; - const ctx = canvas.getContext('2d'); - ctx.fillStyle = 'rgba(0,0,0,0)'; - ctx.fillRect(0, 0, 256, 64); - ctx.font = 'bold 22px Courier New'; - ctx.fillStyle = colorToCss(this.color); - ctx.textAlign = 'center'; - ctx.fillText(this.label, 128, 28); - ctx.font = '14px Courier New'; - ctx.fillStyle = '#007722'; - ctx.fillText(this.role.toUpperCase(), 128, 50); - - const tex = new THREE.CanvasTexture(canvas); - const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true }); - this.sprite = new THREE.Sprite(spriteMat); - this.sprite.scale.set(2.4, 0.6, 1); - this.sprite.position.y = 2; - this.group.add(this.sprite); - } - - update(time) { - const pulse = Math.sin(time * 0.002 + this.pulsePhase); - const pulse2 = Math.sin(time * 0.005 + this.pulsePhase * 1.3); - - let intensity, lightIntensity, ringSpeed, glowOpacity, scaleAmp; - - switch (this.state) { - case 'working': - intensity = 1.0 + pulse * 0.3; - lightIntensity = 3.5 + pulse2 * 0.8; - ringSpeed = 0.07; - glowOpacity = 0.18 + pulse * 0.08; - scaleAmp = 0.14; - break; - case 'thinking': - intensity = 0.7 + pulse2 * 0.5; - lightIntensity = 2.2 + pulse * 0.5; - ringSpeed = 0.045; - glowOpacity = 0.12 + pulse2 * 0.06; - scaleAmp = 0.10; - break; - case 'active': - intensity = 0.6 + pulse * 0.4; - lightIntensity = 2.0 + pulse; - ringSpeed = 0.03; - glowOpacity = 0.08 + pulse * 0.04; - scaleAmp = 0.08; - break; - default: - intensity = 0.2 + pulse * 0.1; - lightIntensity = 0.8 + pulse * 0.3; - ringSpeed = 0.008; - glowOpacity = 0.03 + pulse * 0.02; - scaleAmp = 0.03; - } - - this.core.material.emissiveIntensity = intensity; - this.light.intensity = lightIntensity; - this.core.scale.setScalar(1 + pulse * scaleAmp); - this.ring.rotation.y += ringSpeed; - this.ring.material.opacity = 0.3 + pulse * 0.2; - this.glow.material.opacity = glowOpacity; - - this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15; - } - - setState(state) { - this.state = state; - } - - dispose() { - this.core.geometry.dispose(); - this.core.material.dispose(); - this.ring.geometry.dispose(); - this.ring.material.dispose(); - this.glow.geometry.dispose(); - this.glow.material.dispose(); - if (this.sprite.material.map) this.sprite.material.map.dispose(); - this.sprite.material.dispose(); - } +function deriveTimmyState() { + if (agentStates.gamma === 'working') return 'working'; + if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking'; + if (Object.values(agentStates).some(s => s !== 'idle')) return 'active'; + return 'idle'; } +let scene = null; +let timmy = null; + export function initAgents(sceneRef) { scene = sceneRef; - - AGENT_DEFS.forEach(def => { - const agent = new Agent(def); - agents.set(def.id, agent); - scene.add(agent.group); - }); - - buildConnectionLines(); + timmy = buildTimmy(scene); } -function buildConnectionLines() { - connectionLines.forEach(l => scene.remove(l)); - connectionLines = []; +function buildTimmy(sc) { + const group = new THREE.Group(); + group.position.copy(TIMMY_POS); - const agentList = [...agents.values()]; - const lineMat = new THREE.LineBasicMaterial({ - color: 0x003300, + const robeMat = new THREE.MeshStandardMaterial({ color: 0x2d1b4e, roughness: 0.82, metalness: 0.08 }); + const robe = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.72, 2.2, 8), robeMat); + robe.position.y = 1.1; + robe.castShadow = true; + group.add(robe); + + const headMat = new THREE.MeshStandardMaterial({ color: 0xf2d0a0, roughness: 0.7 }); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 16), headMat); + head.position.y = 2.6; + head.castShadow = true; + group.add(head); + + const hatMat = new THREE.MeshStandardMaterial({ color: 0x1a0a2e, roughness: 0.82 }); + const brim = new THREE.Mesh(new THREE.TorusGeometry(0.46, 0.07, 6, 24), hatMat); + brim.position.y = 2.94; + brim.rotation.x = Math.PI / 2; + group.add(brim); + + const hat = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.78, 8), hatMat.clone()); + hat.position.y = 3.33; + group.add(hat); + + const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 1.0 }); + const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.07, 0), starMat); + star.position.y = 3.76; + group.add(star); + + const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a3a5c }); + const eyeGeo = new THREE.SphereGeometry(0.07, 8, 8); + const eyeL = new THREE.Mesh(eyeGeo, eyeMat); + eyeL.position.set(-0.15, 2.65, 0.31); + group.add(eyeL); + const eyeR = new THREE.Mesh(eyeGeo, eyeMat.clone()); + eyeR.position.set(0.15, 2.65, 0.31); + group.add(eyeR); + + const beltMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xaa8800, emissiveIntensity: 0.4 }); + const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.04, 6, 24), beltMat); + belt.position.y = 0.72; + belt.rotation.x = Math.PI / 2; + group.add(belt); + + sc.add(group); + + const crystalGroup = new THREE.Group(); + crystalGroup.position.copy(CRYSTAL_POS); + + const cbMat = new THREE.MeshPhysicalMaterial({ + color: 0x88ddff, + emissive: 0x004466, + emissiveIntensity: 0.5, + roughness: 0.0, + metalness: 0.0, + transmission: 0.65, transparent: true, - opacity: 0.4, + opacity: 0.88, }); + const cb = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), cbMat); + crystalGroup.add(cb); - for (let i = 0; i < agentList.length; i++) { - for (let j = i + 1; j < agentList.length; j++) { - const a = agentList[i]; - const b = agentList[j]; - if (a.position.distanceTo(b.position) <= 8) { - const points = [a.position.clone(), b.position.clone()]; - const geo = new THREE.BufferGeometry().setFromPoints(points); - const line = new THREE.Line(geo, lineMat.clone()); - connectionLines.push(line); - scene.add(line); - } - } - } + const pedMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.85 }); + const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.19, 0.24, 8), pedMat); + ped.position.y = -0.3; + crystalGroup.add(ped); + + const crystalLight = new THREE.PointLight(0x44bbff, 0.8, 5); + crystalGroup.add(crystalLight); + + sc.add(crystalGroup); + + const pipMat = new THREE.MeshStandardMaterial({ + color: 0xffaa00, + emissive: 0xff6600, + emissiveIntensity: 0.85, + roughness: 0.3, + }); + const pip = new THREE.Mesh(new THREE.TetrahedronGeometry(0.17, 0), pipMat); + pip.position.set(2.5, 1.6, -1); + const pipLight = new THREE.PointLight(0xffaa00, 0.7, 5); + pip.add(pipLight); + sc.add(pip); + + const bubbleCanvas = document.createElement('canvas'); + bubbleCanvas.width = 512; + bubbleCanvas.height = 128; + const bubbleTex = new THREE.CanvasTexture(bubbleCanvas); + const bubbleMat = new THREE.SpriteMaterial({ map: bubbleTex, transparent: true, opacity: 0 }); + const bubble = new THREE.Sprite(bubbleMat); + bubble.scale.set(3.2, 0.8, 1); + bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z); + sc.add(bubble); + + return { + group, robe, head, hat, star, cb, cbMat, crystalGroup, crystalLight, + pip, pipLight, pipMat, + bubble, bubbleCanvas, bubbleTex, bubbleMat, + pulsePhase: Math.random() * Math.PI * 2, + speechTimer: 0, + }; } export function updateAgents(time) { - agents.forEach(agent => agent.update(time)); -} + if (!timmy) return; + const t = time * 0.001; + const vs = deriveTimmyState(); + const pulse = Math.sin(t * 1.8 + timmy.pulsePhase); + const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5); -export function getAgentCount() { - return agents.size; -} + let bodyBob, headTilt, crystalI, cbPulseSpeed, robeGlow; + switch (vs) { + case 'working': + bodyBob = Math.sin(t * 4.2) * 0.11; + headTilt = Math.sin(t * 2.8) * 0.09; + crystalI = 3.8 + pulse * 1.4; + cbPulseSpeed = 3.2; + robeGlow = 0.18; + break; + case 'thinking': + bodyBob = Math.sin(t * 1.6) * 0.06; + headTilt = Math.sin(t * 1.1) * 0.13; + crystalI = 1.9 + pulse2 * 0.7; + cbPulseSpeed = 1.6; + robeGlow = 0.09; + break; + case 'active': + bodyBob = Math.sin(t * 2.2) * 0.07; + headTilt = Math.sin(t * 0.9) * 0.05; + crystalI = 1.3 + pulse * 0.5; + cbPulseSpeed = 1.3; + robeGlow = 0.05; + break; + default: + bodyBob = Math.sin(t * 0.85) * 0.04; + headTilt = Math.sin(t * 0.42) * 0.025; + crystalI = 0.45 + pulse * 0.2; + cbPulseSpeed = 0.55; + robeGlow = 0.0; + } -export function setAgentState(agentId, state) { - const agent = agents.get(agentId); - if (agent) agent.setState(state); -} + timmy.group.position.y = bodyBob; + timmy.head.rotation.z = headTilt; -export function getAgentDefs() { - return [...agents.values()].map(a => ({ - id: a.id, label: a.label, role: a.role, color: a.color, state: a.state, - })); -} + timmy.crystalLight.intensity = crystalI; + timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72; + timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed; + const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022; + timmy.cb.scale.setScalar(cbScale); -/** - * Return a snapshot of each agent's current runtime state. - * Call before teardown so the state can be reapplied after reinit. - * @returns {Object.} — e.g. { alpha: 'active', beta: 'idle' } - */ -export function getAgentStates() { - const snapshot = {}; - agents.forEach((agent, id) => { snapshot[id] = agent.state; }); - return snapshot; -} + timmy.robe.material.emissive = new THREE.Color(0x4400aa); + timmy.robe.material.emissiveIntensity = robeGlow; -/** - * Apply a previously captured state snapshot to freshly-created agents. - * Call immediately after initAgents() during context-restore reinit. - * @param {Object.} snapshot - */ -export function applyAgentStates(snapshot) { - if (!snapshot) return; - for (const [id, state] of Object.entries(snapshot)) { - const agent = agents.get(id); - if (agent) agent.setState(state); + const pipX = Math.sin(t * 0.38 + 1.4) * 3.2; + const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8; + const pipY = 1.55 + Math.sin(t * 1.6) * 0.32; + timmy.pip.position.set(pipX, pipY, pipZ); + timmy.pip.rotation.x += 0.022; + timmy.pip.rotation.y += 0.031; + timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2; + + if (timmy.speechTimer > 0) { + timmy.speechTimer -= 0.016; + const fadeStart = 1.5; + timmy.bubbleMat.opacity = timmy.speechTimer > fadeStart ? 1.0 : Math.max(0, timmy.speechTimer / fadeStart); + if (timmy.speechTimer <= 0) timmy.bubbleMat.opacity = 0; } } -/** - * Dispose all agent GPU resources (geometries, materials, textures). - * Called before context-loss teardown. - */ +export function setAgentState(agentId, state) { + if (agentId in agentStates) agentStates[agentId] = state; +} + +export function setSpeechBubble(text) { + if (!timmy) return; + const canvas = timmy.bubbleCanvas; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = 'rgba(8, 6, 16, 0.9)'; + _roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14); + ctx.fill(); + ctx.strokeStyle = '#5599dd'; + ctx.lineWidth = 2; + _roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14); + ctx.stroke(); + + ctx.fillStyle = '#aaddff'; + ctx.font = 'bold 21px Courier New'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const words = text.split(' '); + const lines = []; + let line = ''; + for (const w of words) { + const test = line ? `${line} ${w}` : w; + if (test.length > 42) { lines.push(line); line = w; } else line = test; + } + if (line) lines.push(line); + + const lineH = 30; + const startY = canvas.height / 2 - ((lines.length - 1) * lineH) / 2; + lines.slice(0, 3).forEach((l, i) => ctx.fillText(l, canvas.width / 2, startY + i * lineH)); + + timmy.bubbleTex.needsUpdate = true; + timmy.bubbleMat.opacity = 1; + timmy.speechTimer = 9; +} + +function _roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +export function getAgentCount() { return 1; } + +export function getAgentStates() { return { ...agentStates }; } + +export function applyAgentStates(snapshot) { + if (!snapshot) return; + for (const [k, v] of Object.entries(snapshot)) { + if (k in agentStates) agentStates[k] = v; + } +} + +export function getAgentDefs() { + return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }]; +} + export function disposeAgents() { - agents.forEach(agent => agent.dispose()); - agents.clear(); - connectionLines.forEach(l => { - l.geometry.dispose(); - l.material.dispose(); + if (!timmy) return; + [timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.ped, timmy.pip].forEach(m => { + if (m) { m.geometry?.dispose(); if (Array.isArray(m.material)) m.material.forEach(x => x.dispose()); else m.material?.dispose(); } }); - connectionLines = []; + timmy.bubbleTex?.dispose(); + timmy.bubbleMat?.dispose(); + timmy = null; scene = null; } diff --git a/the-matrix/js/effects.js b/the-matrix/js/effects.js index 4b84fdc..501f218 100644 --- a/the-matrix/js/effects.js +++ b/the-matrix/js/effects.js @@ -1,99 +1,79 @@ import * as THREE from 'three'; -let rainParticles; -let rainPositions; -let rainVelocities; -const RAIN_COUNT = 2000; +let dustParticles = null; +let dustPositions = null; +let dustVelocities = null; +const DUST_COUNT = 600; export function initEffects(scene) { - initMatrixRain(scene); - initStarfield(scene); + initDustMotes(scene); } -function initMatrixRain(scene) { +function initDustMotes(scene) { const geo = new THREE.BufferGeometry(); - const positions = new Float32Array(RAIN_COUNT * 3); - const velocities = new Float32Array(RAIN_COUNT); - const colors = new Float32Array(RAIN_COUNT * 3); + const positions = new Float32Array(DUST_COUNT * 3); + const colors = new Float32Array(DUST_COUNT * 3); + const velocities = new Float32Array(DUST_COUNT); - for (let i = 0; i < RAIN_COUNT; i++) { - positions[i * 3] = (Math.random() - 0.5) * 100; - positions[i * 3 + 1] = Math.random() * 50 + 5; - positions[i * 3 + 2] = (Math.random() - 0.5) * 100; - velocities[i] = 0.05 + Math.random() * 0.15; + for (let i = 0; i < DUST_COUNT; i++) { + positions[i * 3] = (Math.random() - 0.5) * 22; + positions[i * 3 + 1] = Math.random() * 10; + positions[i * 3 + 2] = (Math.random() - 0.5) * 16 - 2; + velocities[i] = 0.008 + Math.random() * 0.012; - const brightness = 0.3 + Math.random() * 0.7; - colors[i * 3] = 0; - colors[i * 3 + 1] = brightness; - colors[i * 3 + 2] = 0; + const roll = Math.random(); + if (roll < 0.6) { + colors[i * 3] = 0.9 + Math.random() * 0.1; + colors[i * 3 + 1] = 0.7 + Math.random() * 0.2; + colors[i * 3 + 2] = 0.3 + Math.random() * 0.3; + } else { + const b = 0.3 + Math.random() * 0.5; + colors[i * 3] = 0; + colors[i * 3 + 1] = b; + colors[i * 3 + 2] = 0; + } } geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - rainPositions = positions; - rainVelocities = velocities; + dustPositions = positions; + dustVelocities = velocities; const mat = new THREE.PointsMaterial({ - size: 0.12, + size: 0.06, vertexColors: true, transparent: true, - opacity: 0.7, + opacity: 0.55, sizeAttenuation: true, }); - rainParticles = new THREE.Points(geo, mat); - scene.add(rainParticles); + dustParticles = new THREE.Points(geo, mat); + scene.add(dustParticles); } -function initStarfield(scene) { - const count = 500; - const geo = new THREE.BufferGeometry(); - const positions = new Float32Array(count * 3); +export function updateEffects(time) { + if (!dustParticles) return; + const t = time * 0.001; - for (let i = 0; i < count; i++) { - positions[i * 3] = (Math.random() - 0.5) * 300; - positions[i * 3 + 1] = Math.random() * 80 + 10; - positions[i * 3 + 2] = (Math.random() - 0.5) * 300; - } + for (let i = 0; i < DUST_COUNT; i++) { + dustPositions[i * 3 + 1] += dustVelocities[i]; + dustPositions[i * 3] += Math.sin(t * 0.5 + i * 0.1) * 0.002; - geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - - const mat = new THREE.PointsMaterial({ - color: 0x003300, - size: 0.08, - transparent: true, - opacity: 0.5, - }); - - const stars = new THREE.Points(geo, mat); - scene.add(stars); -} - -export function updateEffects(_time) { - if (!rainParticles) return; - - for (let i = 0; i < RAIN_COUNT; i++) { - rainPositions[i * 3 + 1] -= rainVelocities[i]; - if (rainPositions[i * 3 + 1] < -1) { - rainPositions[i * 3 + 1] = 40 + Math.random() * 20; - rainPositions[i * 3] = (Math.random() - 0.5) * 100; - rainPositions[i * 3 + 2] = (Math.random() - 0.5) * 100; + if (dustPositions[i * 3 + 1] > 10) { + dustPositions[i * 3 + 1] = 0; + dustPositions[i * 3] = (Math.random() - 0.5) * 22; + dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 16 - 2; } } - - rainParticles.geometry.attributes.position.needsUpdate = true; + dustParticles.geometry.attributes.position.needsUpdate = true; } -/** - * Release GPU resources held by rain and starfield. - * Called before context-loss teardown. - */ export function disposeEffects() { - if (rainParticles) { - rainParticles.geometry.dispose(); - rainParticles.material.dispose(); - rainParticles = null; + if (dustParticles) { + dustParticles.geometry.dispose(); + dustParticles.material.dispose(); + dustParticles = null; } - rainPositions = null; - rainVelocities = null; + dustPositions = null; + dustVelocities = null; } diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 487a8c3..5c5fbca 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -12,15 +12,6 @@ import { initPaymentPanel } from './payment.js'; let running = false; let canvas = null; -/** - * Build (or rebuild) the Three.js world. - * - * @param {boolean} firstInit - * true — first page load: also starts UI and WebSocket - * false — context-restore reinit: skips UI/WS (they survive context loss) - * @param {Object.|null} stateSnapshot - * Agent state map captured just before teardown; reapplied after initAgents. - */ function buildWorld(firstInit, stateSnapshot) { const { scene, camera, renderer } = initWorld(canvas); canvas = renderer.domElement; @@ -28,9 +19,7 @@ function buildWorld(firstInit, stateSnapshot) { initEffects(scene); initAgents(scene); - if (stateSnapshot) { - applyAgentStates(stateSnapshot); - } + if (stateSnapshot) applyAgentStates(stateSnapshot); initInteraction(camera, renderer); @@ -74,7 +63,6 @@ function buildWorld(firstInit, stateSnapshot) { } animate(); - return { scene, renderer, ac }; } @@ -89,7 +77,6 @@ function teardown({ scene, renderer, ac }) { function main() { const $overlay = document.getElementById('webgl-recovery-overlay'); - let handle = buildWorld(true, null); canvas.addEventListener('webglcontextlost', event => { diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 898ee96..a495dee 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -1,178 +1,75 @@ -import { getAgentDefs } from './agents.js'; -import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { sendVisitorMessage } from './websocket.js'; -const $agentCount = document.getElementById('agent-count'); -const $activeJobs = document.getElementById('active-jobs'); const $fps = document.getElementById('fps'); -const $agentList = document.getElementById('agent-list'); +const $activeJobs = document.getElementById('active-jobs'); const $connStatus = document.getElementById('connection-status'); -const $chatPanel = document.getElementById('chat-panel'); -const $clearBtn = document.getElementById('chat-clear-btn'); +const $log = document.getElementById('event-log'); -const MAX_CHAT_ENTRIES = 12; -const MAX_STORED = 100; -const STORAGE_PREFIX = 'matrix:chat:'; - -const chatEntries = []; -const chatHistory = {}; +const MAX_LOG = 6; +const logEntries = []; let uiInitialized = false; -function storageKey(agentId) { - return STORAGE_PREFIX + agentId; -} - -export function loadChatHistory(agentId) { - try { - const raw = localStorage.getItem(storageKey(agentId)); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter(m => - m && typeof m.agentLabel === 'string' && typeof m.text === 'string' - ); - } catch { - return []; - } -} - -export function saveChatHistory(agentId, messages) { - try { - localStorage.setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED))); - } catch { - } -} - -function formatTimestamp(ts) { - const d = new Date(ts); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - return `${hh}:${mm}`; -} - -function buildChatEntry(agentLabel, message, cssColor, timestamp) { - const color = cssColor || '#00ff41'; - const entry = document.createElement('div'); - entry.className = 'chat-entry'; - const ts = timestamp ? `[${formatTimestamp(timestamp)}] ` : ''; - entry.innerHTML = `${ts}${agentLabel}: ${escapeHtml(message)}`; - return entry; -} - -function loadAllHistories() { - const all = []; - - const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; - for (const id of agentIds) { - const msgs = loadChatHistory(id); - chatHistory[id] = msgs; - all.push(...msgs); - } - - all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); - - for (const msg of all.slice(-MAX_CHAT_ENTRIES)) { - const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp); - chatEntries.push(entry); - $chatPanel.appendChild(entry); - } - - $chatPanel.scrollTop = $chatPanel.scrollHeight; -} - -function clearAllHistories() { - const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; - for (const id of agentIds) { - localStorage.removeItem(storageKey(id)); - chatHistory[id] = []; - } - while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild); - chatEntries.length = 0; -} - export function initUI() { if (uiInitialized) return; uiInitialized = true; - - renderAgentList(); - loadAllHistories(); - - if ($clearBtn) { - $clearBtn.addEventListener('click', clearAllHistories); - } + initInputBar(); } -function renderAgentList() { - const defs = getAgentDefs(); - $agentList.innerHTML = defs.map(a => { - const css = colorToCss(a.color); - return `
- [ - ${a.label} - ] - IDLE -
`; - }).join(''); -} +function initInputBar() { + const $input = document.getElementById('visitor-input'); + const $sendBtn = document.getElementById('send-btn'); + if (!$input || !$sendBtn) return; -export function updateUI({ fps, agentCount, jobCount, connectionState }) { - $fps.textContent = `FPS: ${fps}`; - $agentCount.textContent = `AGENTS: ${agentCount}`; - $activeJobs.textContent = `JOBS: ${jobCount}`; - - if (connectionState === 'connected') { - $connStatus.textContent = '● CONNECTED'; - $connStatus.className = 'connected'; - } else if (connectionState === 'connecting') { - $connStatus.textContent = '◌ CONNECTING...'; - $connStatus.className = ''; - } else { - $connStatus.textContent = '○ OFFLINE'; - $connStatus.className = ''; + function send() { + const text = $input.value.trim(); + if (!text) return; + $input.value = ''; + sendVisitorMessage(text); + appendSystemMessage(`you: ${text}`); } - const defs = getAgentDefs(); - defs.forEach(a => { - const el = document.getElementById(`agent-state-${a.id}`); - if (el) { - el.textContent = ` ${a.state.toUpperCase()}`; - el.style.color = a.state === 'active' ? '#00ff41' : '#003300'; - } + $sendBtn.addEventListener('click', send); + $input.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); } -/** - * Append a message to the chat panel and optionally persist it. - * @param {string} agentLabel — display name - * @param {string} message — raw text (HTML-escaped before insertion) - * @param {string} cssColor — CSS color string e.g. '#00ff88' - * @param {string} [agentId] — storage key; omit to skip persistence - */ -export function appendChatMessage(agentLabel, message, cssColor, agentId) { - const timestamp = Date.now(); - const entry = buildChatEntry(agentLabel, message, cssColor, timestamp); - chatEntries.push(entry); +export function updateUI({ fps, jobCount, connectionState }) { + if ($fps) $fps.textContent = `FPS: ${fps}`; + if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`; - if (chatEntries.length > MAX_CHAT_ENTRIES) { - const removed = chatEntries.shift(); - $chatPanel.removeChild(removed); - } - - $chatPanel.appendChild(entry); - $chatPanel.scrollTop = $chatPanel.scrollHeight; - - if (agentId) { - if (!chatHistory[agentId]) chatHistory[agentId] = []; - chatHistory[agentId].push({ agentLabel, text: message, cssColor, agentId, timestamp }); - if (chatHistory[agentId].length > MAX_STORED) { - chatHistory[agentId] = chatHistory[agentId].slice(-MAX_STORED); + if ($connStatus) { + if (connectionState === 'connected') { + $connStatus.textContent = '● CONNECTED'; + $connStatus.className = 'connected'; + } else if (connectionState === 'connecting') { + $connStatus.textContent = '◌ CONNECTING...'; + $connStatus.className = ''; + } else { + $connStatus.textContent = '○ OFFLINE'; + $connStatus.className = ''; } - saveChatHistory(agentId, chatHistory[agentId]); } } -function escapeHtml(str) { - return str - .replace(/&/g, '&') - .replace(//g, '>'); +export function appendSystemMessage(text) { + if (!$log) return; + const el = document.createElement('div'); + el.className = 'log-entry'; + el.textContent = text; + logEntries.push(el); + if (logEntries.length > MAX_LOG) { + const removed = logEntries.shift(); + $log.removeChild(removed); + } + $log.appendChild(el); + $log.scrollTop = $log.scrollHeight; } + +export function appendChatMessage(agentLabel, message, cssColor, agentId) { + void agentLabel; void cssColor; void agentId; + appendSystemMessage(message); +} + +export function loadChatHistory() { return []; } +export function saveChatHistory() {} diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index 778120d..bea35e8 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,13 +1,6 @@ -import { AGENT_DEFS, colorToCss } from './agent-defs.js'; -import { setAgentState } from './agents.js'; -import { appendChatMessage } from './ui.js'; +import { setAgentState, setSpeechBubble, applyAgentStates } from './agents.js'; +import { appendSystemMessage } from './ui.js'; -/** - * WebSocket URL resolution order: - * 1. VITE_WS_URL env var (set at build time for custom deployments) - * 2. Auto-derived from window.location — works when the Matrix is served from - * the same host as the API (the default: /tower served by the API server) - */ function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; if (explicit) return explicit; @@ -17,24 +10,20 @@ function resolveWsUrl() { const WS_URL = resolveWsUrl(); -const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d])); - let ws = null; let connectionState = 'disconnected'; let jobCount = 0; let reconnectTimer = null; +let visitorId = null; const RECONNECT_DELAY_MS = 5000; export function initWebSocket(_scene) { + visitorId = crypto.randomUUID(); connect(); } function connect() { - if (ws) { - ws.onclose = null; - ws.close(); - } - + if (ws) { ws.onclose = null; ws.close(); } connectionState = 'connecting'; try { @@ -48,28 +37,15 @@ function connect() { ws.onopen = () => { connectionState = 'connected'; clearTimeout(reconnectTimer); - ws.send(JSON.stringify({ - type: 'subscribe', - channel: 'agents', - clientId: crypto.randomUUID(), - })); + send({ type: 'visitor_enter', visitorId, visitorName: 'visitor' }); }; - ws.onmessage = (event) => { - try { - handleMessage(JSON.parse(event.data)); - } catch { - } + ws.onmessage = event => { + try { handleMessage(JSON.parse(event.data)); } catch { /* ignore */ } }; - ws.onerror = () => { - connectionState = 'disconnected'; - }; - - ws.onclose = () => { - connectionState = 'disconnected'; - scheduleReconnect(); - }; + ws.onerror = () => { connectionState = 'disconnected'; }; + ws.onclose = () => { connectionState = 'disconnected'; scheduleReconnect(); }; } function scheduleReconnect() { @@ -77,53 +53,67 @@ function scheduleReconnect() { reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); } +function send(payload) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(payload)); + } +} + function handleMessage(msg) { switch (msg.type) { case 'ping': - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'pong' })); - } + send({ type: 'pong' }); break; - case 'agent_state': { - if (msg.agentId && msg.state) { - setAgentState(msg.agentId, msg.state); + + case 'world_state': { + if (msg.agentStates) applyAgentStates(msg.agentStates); + if (msg.recentEvents) { + const last = msg.recentEvents.slice(-3); + last.forEach(ev => appendSystemMessage(ev.summary || ev.type)); } break; } + + case 'timmy_state': { + break; + } + + case 'agent_state': { + if (msg.agentId && msg.state) setAgentState(msg.agentId, msg.state); + break; + } + case 'job_started': { jobCount++; if (msg.agentId) setAgentState(msg.agentId, 'active'); - logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ▶ started`); + appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`); break; } + case 'job_completed': { if (jobCount > 0) jobCount--; if (msg.agentId) setAgentState(msg.agentId, 'idle'); - logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ✓ complete`); + appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`); break; } + case 'chat': { - const def = agentById[msg.agentId]; - if (def && msg.text) { - appendChatMessage(def.label, msg.text, colorToCss(def.color), def.id); - } + if (msg.text) setSpeechBubble(msg.text); break; } + case 'agent_count': + case 'visitor_count': break; + default: break; } } -function logEvent(text) { - appendChatMessage('SYS', text, '#003300', 'sys'); +export function sendVisitorMessage(text) { + send({ type: 'visitor_message', visitorId, text }); } -export function getConnectionState() { - return connectionState; -} - -export function getJobCount() { - return jobCount; -} +export function getConnectionState() { return connectionState; } +export function getJobCount() { return jobCount; } diff --git a/the-matrix/js/world.js b/the-matrix/js/world.js index 5cb5bb9..62f67da 100644 --- a/the-matrix/js/world.js +++ b/the-matrix/js/world.js @@ -1,23 +1,18 @@ import * as THREE from 'three'; let scene, camera, renderer; - const _worldObjects = []; -/** - * @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on - * re-init so Three.js reuses the same DOM element instead of creating a new one - */ export function initWorld(existingCanvas) { _worldObjects.length = 0; scene = new THREE.Scene(); - scene.background = new THREE.Color(0x000000); - scene.fog = new THREE.FogExp2(0x000000, 0.035); + scene.background = new THREE.Color(0x080610); + scene.fog = new THREE.FogExp2(0x080610, 0.038); - camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500); - camera.position.set(0, 12, 28); - camera.lookAt(0, 0, 0); + camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 200); + camera.position.set(0, 7, 14); + camera.lookAt(0, 2, -2); renderer = new THREE.WebGLRenderer({ antialias: true, @@ -26,68 +21,131 @@ export function initWorld(existingCanvas) { renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; if (!existingCanvas) { document.body.prepend(renderer.domElement); } addLights(scene); - addGrid(scene); + buildRoom(scene); return { scene, camera, renderer }; } -/** - * Dispose only world-owned geometries, materials, and the renderer. - * Agent and effect objects are disposed by their own modules before this runs. - */ +function addLights(scene) { + const ambient = new THREE.AmbientLight(0x0a2010, 0.45); + scene.add(ambient); + + const fireplace = new THREE.PointLight(0xff7722, 4.5, 32); + fireplace.position.set(-9, 3.5, 6); + fireplace.castShadow = true; + scene.add(fireplace); + + const candle = new THREE.PointLight(0xffcc77, 1.8, 8); + candle.position.set(0.7, 1.9, -3.6); + scene.add(candle); + + const fill = new THREE.DirectionalLight(0x001a00, 0.2); + fill.position.set(0, 12, 8); + scene.add(fill); +} + +function buildRoom(scene) { + const stoneMat = new THREE.MeshStandardMaterial({ color: 0x181820, roughness: 0.95, metalness: 0.0 }); + const woodMat = new THREE.MeshStandardMaterial({ color: 0x3d2506, roughness: 0.92 }); + + const floor = new THREE.Mesh(new THREE.PlaneGeometry(28, 28), stoneMat.clone()); + floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; + scene.add(floor); + _worldObjects.push(floor); + + const grid = new THREE.GridHelper(28, 14, 0x28283a, 0x1c1c28); + grid.position.y = 0.005; + scene.add(grid); + _worldObjects.push(grid); + + buildDesk(scene, woodMat); + buildShelves(scene, woodMat); + buildFireplaceGlow(scene); +} + +function buildDesk(scene, woodMat) { + const top = new THREE.Mesh(new THREE.BoxGeometry(2.8, 0.1, 1.3), woodMat.clone()); + top.position.set(0, 1.05, -4.1); + top.castShadow = true; + top.receiveShadow = true; + scene.add(top); + _worldObjects.push(top); + + const legGeo = new THREE.CylinderGeometry(0.055, 0.055, 1.05, 8); + [[-1.25, -3.55], [1.25, -3.55], [-1.25, -4.65], [1.25, -4.65]].forEach(([x, z]) => { + const leg = new THREE.Mesh(legGeo, woodMat.clone()); + leg.position.set(x, 0.525, z); + leg.castShadow = true; + scene.add(leg); + _worldObjects.push(leg); + }); + + const scrollMat = new THREE.MeshStandardMaterial({ color: 0xe8d5a0, roughness: 1.0 }); + const scroll = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.02, 0.65), scrollMat); + scroll.position.set(-0.65, 1.12, -4.1); + scroll.rotation.y = 0.18; + scene.add(scroll); + _worldObjects.push(scroll); + + const scroll2 = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.015, 0.4), scrollMat.clone()); + scroll2.position.set(0.3, 1.12, -4.3); + scroll2.rotation.y = -0.25; + scene.add(scroll2); + _worldObjects.push(scroll2); +} + +function buildShelves(scene, woodMat) { + const bookColors = [0x8b0000, 0x00008b, 0x004400, 0x6b3300, 0x440066, 0x884400, 0x005566, 0x663300]; + [2.6, 3.7, 4.8].forEach(y => { + const shelf = new THREE.Mesh(new THREE.BoxGeometry(3.8, 0.07, 0.48), woodMat.clone()); + shelf.position.set(-8.5, y, -2.2); + shelf.rotation.y = 0.12; + shelf.castShadow = true; + scene.add(shelf); + _worldObjects.push(shelf); + + for (let i = 0; i < 7; i++) { + const h = 0.38 + Math.random() * 0.22; + const bookMat = new THREE.MeshStandardMaterial({ color: bookColors[i % bookColors.length], roughness: 0.95 }); + const book = new THREE.Mesh(new THREE.BoxGeometry(0.11, h, 0.34), bookMat); + book.position.set(-10.0 + i * 0.43, y + h / 2 + 0.04, -2.2 + Math.random() * 0.04); + book.rotation.y = 0.12 + (Math.random() - 0.5) * 0.08; + scene.add(book); + _worldObjects.push(book); + } + }); +} + +function buildFireplaceGlow(scene) { + const glowMat = new THREE.MeshBasicMaterial({ color: 0xff5500, transparent: true, opacity: 0.1 }); + const glow = new THREE.Mesh(new THREE.PlaneGeometry(3.5, 3), glowMat); + glow.position.set(-11.5, 2.5, 4); + glow.rotation.y = Math.PI / 2; + scene.add(glow); + _worldObjects.push(glow); +} + export function disposeWorld(renderer, _scene) { for (const obj of _worldObjects) { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; - mats.forEach(m => { - if (m.map) m.map.dispose(); - m.dispose(); - }); + mats.forEach(m => { if (m.map) m.map.dispose(); m.dispose(); }); } } _worldObjects.length = 0; renderer.dispose(); } -function addLights(scene) { - const ambient = new THREE.AmbientLight(0x001a00, 0.6); - scene.add(ambient); - - const point = new THREE.PointLight(0x00ff41, 2, 80); - point.position.set(0, 20, 0); - scene.add(point); - - const fill = new THREE.DirectionalLight(0x003300, 0.4); - fill.position.set(-10, 10, 10); - scene.add(fill); -} - -function addGrid(scene) { - const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00); - grid.position.y = -0.01; - scene.add(grid); - _worldObjects.push(grid); - - const planeGeo = new THREE.PlaneGeometry(100, 100); - const planeMat = new THREE.MeshBasicMaterial({ - color: 0x000a00, - transparent: true, - opacity: 0.5, - }); - const plane = new THREE.Mesh(planeGeo, planeMat); - plane.rotation.x = -Math.PI / 2; - plane.position.y = -0.02; - scene.add(plane); - _worldObjects.push(plane); -} - export function onWindowResize(camera, renderer) { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();