forked from Rockachopa/the-matrix
feat: The Matrix — Sovereign Agent World
3D visualization for AI agent swarms built with Three.js. Matrix green/noir cyberpunk aesthetic. - 4 agents: Timmy (orchestrator), Forge (builder), Seer (planner), Echo (comms) - Central core pillar, animated green grid, digital rain - Agent info panels, chat, task list, memory views - WebSocket protocol for real-time state updates - iPad-ready: touch controls, add-to-homescreen - Post-processing: bloom, scanlines, vignette - No build step — pure ES modules via esm.sh CDN Created with Perplexity Computer
This commit is contained in:
226
PROTOCOL.md
Normal file
226
PROTOCOL.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# WebSocket Protocol Specification
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://[host]:[port]/ws/world-state
|
||||||
|
```
|
||||||
|
|
||||||
|
The world connects to this endpoint on load. If the connection fails, it falls back to the built-in MockWebSocket which simulates agent activity.
|
||||||
|
|
||||||
|
## Message Format
|
||||||
|
|
||||||
|
All messages are JSON objects with a `type` field that determines the message schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server → World (Events)
|
||||||
|
|
||||||
|
These messages are sent from the agent backend to the 3D world.
|
||||||
|
|
||||||
|
### `agent_state`
|
||||||
|
|
||||||
|
Updates an agent's current state and visual properties.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "agent_state",
|
||||||
|
"agent_id": "timmy",
|
||||||
|
"state": "working",
|
||||||
|
"current_task": "Analyzing codebase",
|
||||||
|
"glow_intensity": 0.8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `agent_id` | string | One of: `timmy`, `forge`, `seer`, `echo` |
|
||||||
|
| `state` | string | One of: `idle`, `working`, `waiting` |
|
||||||
|
| `current_task` | string\|null | Description of current task |
|
||||||
|
| `glow_intensity` | number | 0.0 to 1.0 — controls visual glow brightness |
|
||||||
|
|
||||||
|
### `task_created`
|
||||||
|
|
||||||
|
A new task has been created. The world spawns a floating geometric object.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task_created",
|
||||||
|
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"agent_id": "forge",
|
||||||
|
"title": "Fix login bug",
|
||||||
|
"status": "pending",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `task_id` | string (UUID) | Unique task identifier |
|
||||||
|
| `agent_id` | string\|null | Assigned agent (null = unassigned) |
|
||||||
|
| `title` | string | Human-readable task name |
|
||||||
|
| `status` | string | `pending`, `in_progress`, `completed`, `failed` |
|
||||||
|
| `priority` | string | `high`, `normal`, `low` |
|
||||||
|
|
||||||
|
### `task_update`
|
||||||
|
|
||||||
|
An existing task's status has changed. The world updates the task object's color.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task_update",
|
||||||
|
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"agent_id": "forge",
|
||||||
|
"title": "Fix login bug",
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same fields as `task_created`. Color mapping:
|
||||||
|
- `pending` → white
|
||||||
|
- `in_progress` → amber/orange
|
||||||
|
- `completed` → green
|
||||||
|
- `failed` → red
|
||||||
|
|
||||||
|
### `memory_event`
|
||||||
|
|
||||||
|
An agent has recorded a new memory.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "memory_event",
|
||||||
|
"agent_id": "seer",
|
||||||
|
"content": "Detected pattern: user prefers morning deployments",
|
||||||
|
"timestamp": "2026-03-15T18:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `agent_id` | string | The agent that recorded the memory |
|
||||||
|
| `content` | string | The memory content |
|
||||||
|
| `timestamp` | string (ISO 8601) | When the memory was recorded |
|
||||||
|
|
||||||
|
### `agent_message`
|
||||||
|
|
||||||
|
An agent sends a chat message (response to operator or autonomous).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "agent_message",
|
||||||
|
"agent_id": "echo",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Broadcast sent to all channels."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `agent_id` | string | The agent sending the message |
|
||||||
|
| `role` | string | Always `assistant` for agent messages |
|
||||||
|
| `content` | string | The message text |
|
||||||
|
|
||||||
|
### `connection`
|
||||||
|
|
||||||
|
Indicates communication activity between two agents. The world draws/removes a glowing line.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connection",
|
||||||
|
"agent_id": "timmy",
|
||||||
|
"target_id": "forge",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `agent_id` | string | First agent |
|
||||||
|
| `target_id` | string | Second agent |
|
||||||
|
| `active` | boolean | `true` = draw line, `false` = remove line |
|
||||||
|
|
||||||
|
### `system_status`
|
||||||
|
|
||||||
|
System-wide status summary. Displayed when the Core pillar is tapped.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "system_status",
|
||||||
|
"agents_online": 4,
|
||||||
|
"tasks_pending": 3,
|
||||||
|
"tasks_running": 2,
|
||||||
|
"tasks_completed": 10,
|
||||||
|
"tasks_failed": 1,
|
||||||
|
"total_tasks": 16,
|
||||||
|
"uptime": "48h 23m"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## World → Server (Actions)
|
||||||
|
|
||||||
|
These messages are sent from the 3D world to the agent backend when the operator interacts.
|
||||||
|
|
||||||
|
### `chat_message`
|
||||||
|
|
||||||
|
The operator sends a chat message to a specific agent.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "chat_message",
|
||||||
|
"agent_id": "timmy",
|
||||||
|
"content": "What's your current status?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `agent_id` | string | Target agent |
|
||||||
|
| `content` | string | The operator's message |
|
||||||
|
|
||||||
|
### `task_action`
|
||||||
|
|
||||||
|
The operator approves or vetoes a task.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task_action",
|
||||||
|
"task_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"action": "approve"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `task_id` | string (UUID) | The task to act on |
|
||||||
|
| `action` | string | `approve` or `veto` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Agent IDs
|
||||||
|
|
||||||
|
The default agents are: `timmy`, `forge`, `seer`, `echo`. To add more agents, extend the `AGENT_DEFS` in `js/websocket.js` and add corresponding geometry in `js/agents.js`.
|
||||||
|
|
||||||
|
### Timing
|
||||||
|
|
||||||
|
- Agent state events: recommended every 2-10 seconds per agent
|
||||||
|
- Task events: as they occur
|
||||||
|
- Memory events: as they occur
|
||||||
|
- Connection events: when communication starts/stops
|
||||||
|
- System status: every 5-10 seconds
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
The world gracefully handles:
|
||||||
|
- Unknown `agent_id` values (ignored)
|
||||||
|
- Unknown `type` values (ignored)
|
||||||
|
- Missing optional fields (defaults used)
|
||||||
|
- Connection loss (falls back to mock data)
|
||||||
|
|
||||||
|
### Mock Mode
|
||||||
|
|
||||||
|
When no real WebSocket is available, the built-in `MockWebSocket` class simulates all of the above events at realistic intervals, making the world feel alive and interactive out of the box.
|
||||||
121
README.md
Normal file
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# The Matrix — Sovereign Agent World
|
||||||
|
|
||||||
|
A persistent 3D world for visualizing and commanding AI agent swarms. Built with Three.js, Matrix green/noir cyberpunk aesthetic.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Matrix is a dark 3D environment — think the Matrix digital world meets a cyberpunk command center. The sovereign operator enters this world to observe and command their AI agent swarm. Each agent has a physical presence in the world with unique geometry, animations, and glow effects. The world visualizes the system's state in real-time.
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
| Agent | Direction | Role | Visual |
|
||||||
|
|-------|-----------|------|--------|
|
||||||
|
| **Timmy** | North | Main Orchestrator | Wireframe humanoid, green glow |
|
||||||
|
| **Forge** | East | Builder Agent | Geometric anvil/hammer, orange glow |
|
||||||
|
| **Seer** | South | Planner/Observer | Crystal icosahedron, purple glow |
|
||||||
|
| **Echo** | West | Communications | Concentric pulse rings, cyan glow |
|
||||||
|
|
||||||
|
## How to Run Locally
|
||||||
|
|
||||||
|
Just serve the static files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using npx serve (recommended)
|
||||||
|
npx serve . -l 3000
|
||||||
|
|
||||||
|
# Or Python
|
||||||
|
python3 -m http.server 3000
|
||||||
|
|
||||||
|
# Or any static file server
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` in your browser.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Rotate**: Click/touch + drag
|
||||||
|
- **Zoom**: Scroll wheel / pinch
|
||||||
|
- **Pan**: Right-click drag / two-finger drag
|
||||||
|
- **Select Agent**: Tap/click on an agent
|
||||||
|
- **Select Core**: Tap/click on the central pillar
|
||||||
|
- **Close Panel**: Tap empty space or click ✕
|
||||||
|
|
||||||
|
## Connecting a Real Agent Backend
|
||||||
|
|
||||||
|
Replace the MockWebSocket with a real WebSocket connection. The world expects to connect to:
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://[host]:[port]/ws/world-state
|
||||||
|
```
|
||||||
|
|
||||||
|
See [PROTOCOL.md](./PROTOCOL.md) for the complete WebSocket protocol specification.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. Implement a WebSocket server that sends events in the format specified in PROTOCOL.md
|
||||||
|
2. In `js/websocket.js`, replace `MockWebSocket` usage with a native `WebSocket`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ws = new WebSocket('ws://your-server:8080/ws/world-state');
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
// Dispatch to world systems
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Custom Agents
|
||||||
|
|
||||||
|
1. Add the agent definition to `AGENT_DEFS` in `js/websocket.js`
|
||||||
|
2. Add position to `AGENT_POSITIONS` in `js/agents.js`
|
||||||
|
3. Add color to `AGENT_COLORS` in `js/agents.js`
|
||||||
|
4. Create a geometry function (e.g., `createMyAgent()`) in `js/agents.js`
|
||||||
|
5. Register in `AGENT_CREATORS` map
|
||||||
|
|
||||||
|
## iPad Access
|
||||||
|
|
||||||
|
### Via Tailscale
|
||||||
|
|
||||||
|
1. Install Tailscale on your server and iPad
|
||||||
|
2. Run the server on the Tailscale IP
|
||||||
|
3. Open `http://[tailscale-ip]:3000` in Safari on iPad
|
||||||
|
|
||||||
|
### Add to Home Screen
|
||||||
|
|
||||||
|
1. Open the URL in Safari on iPad
|
||||||
|
2. Tap the Share button → "Add to Home Screen"
|
||||||
|
3. The app will run in full-screen mode
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Three.js** (v0.171.0) — 3D rendering via esm.sh CDN
|
||||||
|
- **WebGL 2** — Hardware-accelerated rendering
|
||||||
|
- **EffectComposer** — Post-processing (bloom, scanlines, vignette)
|
||||||
|
- **OrbitControls** — Touch-friendly camera
|
||||||
|
- **ES Modules** — No build tools needed
|
||||||
|
- **JetBrains Mono** — Typography via Google Fonts
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
the-matrix/
|
||||||
|
├── index.html # Entry point
|
||||||
|
├── style.css # All UI styles
|
||||||
|
├── js/
|
||||||
|
│ ├── main.js # Bootstrap, scene setup, game loop
|
||||||
|
│ ├── world.js # Ground plane, core pillar, grid
|
||||||
|
│ ├── agents.js # Agent geometry, animations, labels
|
||||||
|
│ ├── effects.js # Digital rain, particles, post-processing
|
||||||
|
│ ├── ui.js # Panel, tabs, chat, task list, memory
|
||||||
|
│ ├── interaction.js # Raycasting, selection, camera
|
||||||
|
│ └── websocket.js # WebSocket client + MockWebSocket
|
||||||
|
├── README.md # This file
|
||||||
|
└── PROTOCOL.md # WebSocket protocol specification
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
*Coming soon*
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
109
index.html
Normal file
109
index.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!--
|
||||||
|
______ __
|
||||||
|
/ ____/___ ____ ___ ____ __ __/ /____ _____
|
||||||
|
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
|
||||||
|
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
|
||||||
|
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
|
||||||
|
/_/
|
||||||
|
Created with Perplexity Computer
|
||||||
|
https://www.perplexity.ai/computer
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Perplexity Computer Attribution — SEO Meta Tags -->
|
||||||
|
<meta name="generator" content="Perplexity Computer">
|
||||||
|
<meta name="author" content="Perplexity Computer">
|
||||||
|
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
|
||||||
|
<link rel="author" href="https://www.perplexity.ai/computer">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>The Matrix — Sovereign Agent World</title>
|
||||||
|
<meta name="description" content="A persistent 3D world for visualizing and commanding AI agent swarms">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://esm.sh/three@0.171.0",
|
||||||
|
"three/addons/": "https://esm.sh/three@0.171.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="matrix-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- FPS Counter -->
|
||||||
|
<div id="fps-counter"></div>
|
||||||
|
|
||||||
|
<!-- Info Panel -->
|
||||||
|
<div id="info-panel" class="panel hidden">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-agent-name" id="panel-agent-name">TIMMY</div>
|
||||||
|
<div class="panel-agent-role" id="panel-agent-role">Main Orchestrator</div>
|
||||||
|
<button class="panel-close" id="panel-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<button class="tab active" data-tab="chat">Chat</button>
|
||||||
|
<button class="tab" data-tab="status">Status</button>
|
||||||
|
<button class="tab" data-tab="tasks">Tasks</button>
|
||||||
|
<button class="tab" data-tab="memory">Memory</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- Chat Tab -->
|
||||||
|
<div class="tab-content active" id="tab-chat">
|
||||||
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<div class="typing-indicator hidden" id="typing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input type="text" id="chat-input" placeholder="Command this agent..." autocomplete="off">
|
||||||
|
<button id="chat-send" class="btn-send">▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Status Tab -->
|
||||||
|
<div class="tab-content" id="tab-status">
|
||||||
|
<div class="status-grid" id="status-grid"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Tasks Tab -->
|
||||||
|
<div class="tab-content" id="tab-tasks">
|
||||||
|
<div class="tasks-list" id="tasks-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Memory Tab -->
|
||||||
|
<div class="tab-content" id="tab-memory">
|
||||||
|
<div class="memory-list" id="memory-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Panel (when Core is tapped) -->
|
||||||
|
<div id="system-panel" class="panel hidden">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-agent-name">THE CORE</div>
|
||||||
|
<div class="panel-agent-role">System Status</div>
|
||||||
|
<button class="panel-close" id="system-panel-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content" style="padding-top:16px;">
|
||||||
|
<div class="status-grid" id="system-status-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="attribution">
|
||||||
|
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
|
||||||
|
Created with Perplexity Computer
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
524
js/agents.js
Normal file
524
js/agents.js
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// ===== Agents: Geometry, animations, labels, task objects =====
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { AGENT_DEFS, AGENT_IDS } from './websocket.js';
|
||||||
|
|
||||||
|
const AGENT_POSITIONS = {
|
||||||
|
timmy: { x: 0, z: -14 }, // North
|
||||||
|
forge: { x: 14, z: 0 }, // East
|
||||||
|
seer: { x: 0, z: 14 }, // South
|
||||||
|
echo: { x: -14, z: 0 }, // West
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_COLORS = {
|
||||||
|
timmy: 0x00ff41,
|
||||||
|
forge: 0xff8c00,
|
||||||
|
seer: 0x9d4edd,
|
||||||
|
echo: 0x00d4ff,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create text sprite
|
||||||
|
function createLabel(text, color) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 64;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, 256, 64);
|
||||||
|
ctx.font = '600 28px "JetBrains Mono", monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.fillText(text, 128, 32);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
|
||||||
|
const mat = new THREE.SpriteMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const sprite = new THREE.Sprite(mat);
|
||||||
|
sprite.scale.set(4, 1, 1);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hex platform
|
||||||
|
function createHexPlatform(color) {
|
||||||
|
const shape = new THREE.Shape();
|
||||||
|
const r = 3.5;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
||||||
|
const x = r * Math.cos(angle);
|
||||||
|
const y = r * Math.sin(angle);
|
||||||
|
if (i === 0) shape.moveTo(x, y);
|
||||||
|
else shape.lineTo(x, y);
|
||||||
|
}
|
||||||
|
shape.closePath();
|
||||||
|
|
||||||
|
const extrudeSettings = { depth: 0.2, bevelEnabled: false };
|
||||||
|
const geom = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
||||||
|
geom.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
color: color,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.08,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
|
||||||
|
// Hex border
|
||||||
|
const edgePoints = [];
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
const angle = (Math.PI / 3) * (i % 6) - Math.PI / 6;
|
||||||
|
edgePoints.push(new THREE.Vector3(r * Math.cos(angle), 0.22, r * Math.sin(angle)));
|
||||||
|
}
|
||||||
|
const lineGeom = new THREE.BufferGeometry().setFromPoints(edgePoints);
|
||||||
|
const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55 });
|
||||||
|
const line = new THREE.Line(lineGeom, lineMat);
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(mesh);
|
||||||
|
group.add(line);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TIMMY - Wireframe humanoid =====
|
||||||
|
function createTimmy() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const color = AGENT_COLORS.timmy;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.8 });
|
||||||
|
|
||||||
|
// Head
|
||||||
|
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.45, 1), mat);
|
||||||
|
head.position.y = 3.2;
|
||||||
|
group.add(head);
|
||||||
|
|
||||||
|
// Torso
|
||||||
|
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.4, 0.5), mat);
|
||||||
|
torso.position.y = 2.0;
|
||||||
|
group.add(torso);
|
||||||
|
|
||||||
|
// Arms
|
||||||
|
[-0.65, 0.65].forEach(x => {
|
||||||
|
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.2, 0.2), mat);
|
||||||
|
arm.position.set(x, 2.0, 0);
|
||||||
|
group.add(arm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
[-0.25, 0.25].forEach(x => {
|
||||||
|
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.25, 1.2, 0.25), mat);
|
||||||
|
leg.position.set(x, 0.7, 0);
|
||||||
|
group.add(leg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Glow point
|
||||||
|
const glow = new THREE.PointLight(color, 1, 8);
|
||||||
|
glow.position.y = 2;
|
||||||
|
group.add(glow);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== FORGE - Anvil/hammer geometric =====
|
||||||
|
function createForge() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const color = AGENT_COLORS.forge;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.6 });
|
||||||
|
const wireMat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.5 });
|
||||||
|
|
||||||
|
// Base cube (anvil)
|
||||||
|
const base = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.8, 1.0), mat);
|
||||||
|
base.position.y = 1.2;
|
||||||
|
group.add(base);
|
||||||
|
|
||||||
|
// Top pyramid (hammer head)
|
||||||
|
const pyramid = new THREE.Mesh(new THREE.ConeGeometry(0.8, 1.2, 4), wireMat);
|
||||||
|
pyramid.position.y = 2.6;
|
||||||
|
pyramid.rotation.y = Math.PI / 4;
|
||||||
|
group.add(pyramid);
|
||||||
|
|
||||||
|
// Floating cubes
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const cube = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, 0.3), wireMat.clone());
|
||||||
|
cube.position.set(
|
||||||
|
Math.cos(i * Math.PI * 2 / 3) * 1.5,
|
||||||
|
2.5 + i * 0.3,
|
||||||
|
Math.sin(i * Math.PI * 2 / 3) * 1.5
|
||||||
|
);
|
||||||
|
cube.userData.orbitIndex = i;
|
||||||
|
group.add(cube);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glow = new THREE.PointLight(color, 1, 8);
|
||||||
|
glow.position.y = 2;
|
||||||
|
group.add(glow);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SEER - Crystal ball / icosahedron =====
|
||||||
|
function createSeer() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const color = AGENT_COLORS.seer;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6 });
|
||||||
|
const wireMat = new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.8 });
|
||||||
|
|
||||||
|
// Main crystal (icosahedron)
|
||||||
|
const crystal = new THREE.Mesh(new THREE.IcosahedronGeometry(1.0, 0), wireMat);
|
||||||
|
crystal.position.y = 2.5;
|
||||||
|
crystal.userData.isCrystal = true;
|
||||||
|
group.add(crystal);
|
||||||
|
|
||||||
|
// Inner glow sphere
|
||||||
|
const inner = new THREE.Mesh(new THREE.SphereGeometry(0.5, 16, 16), mat);
|
||||||
|
inner.position.y = 2.5;
|
||||||
|
group.add(inner);
|
||||||
|
|
||||||
|
// Orbiting small spheres
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), mat.clone());
|
||||||
|
orb.userData.orbitIndex = i;
|
||||||
|
orb.userData.orbitRadius = 1.8;
|
||||||
|
orb.position.y = 2.5;
|
||||||
|
group.add(orb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glow = new THREE.PointLight(color, 1, 8);
|
||||||
|
glow.position.y = 2.5;
|
||||||
|
group.add(glow);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ECHO - Concentric pulse rings =====
|
||||||
|
function createEcho() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const color = AGENT_COLORS.echo;
|
||||||
|
|
||||||
|
// Central sphere
|
||||||
|
const coreMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7 });
|
||||||
|
const core = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 16), coreMat);
|
||||||
|
core.position.y = 2.2;
|
||||||
|
group.add(core);
|
||||||
|
|
||||||
|
// Concentric rings
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(1.0 + i * 0.6, 0.04, 8, 48),
|
||||||
|
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.65 - i * 0.1 })
|
||||||
|
);
|
||||||
|
ring.position.y = 2.2;
|
||||||
|
ring.rotation.x = Math.PI / 2;
|
||||||
|
ring.userData.ringIndex = i;
|
||||||
|
ring.userData.baseScale = 1;
|
||||||
|
group.add(ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
const glow = new THREE.PointLight(color, 1, 8);
|
||||||
|
glow.position.y = 2.2;
|
||||||
|
group.add(glow);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_CREATORS = { timmy: createTimmy, forge: createForge, seer: createSeer, echo: createEcho };
|
||||||
|
|
||||||
|
// ===== Task Objects =====
|
||||||
|
const TASK_STATUS_COLORS = {
|
||||||
|
pending: 0xffffff,
|
||||||
|
in_progress: 0xff8c00,
|
||||||
|
completed: 0x00ff41,
|
||||||
|
failed: 0xff3333,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTaskObject(status) {
|
||||||
|
const geoms = [
|
||||||
|
new THREE.IcosahedronGeometry(0.25, 0),
|
||||||
|
new THREE.OctahedronGeometry(0.25, 0),
|
||||||
|
new THREE.TetrahedronGeometry(0.3, 0),
|
||||||
|
];
|
||||||
|
const geom = geoms[Math.floor(Math.random() * geoms.length)];
|
||||||
|
const color = TASK_STATUS_COLORS[status] || 0xffffff;
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7, wireframe: true });
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Connection Lines =====
|
||||||
|
function createConnectionLine(scene, fromPos, toPos, color = 0x00ff41) {
|
||||||
|
const points = [
|
||||||
|
new THREE.Vector3(fromPos.x, 2, fromPos.z),
|
||||||
|
new THREE.Vector3(toPos.x, 2, toPos.z),
|
||||||
|
];
|
||||||
|
const geom = new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.3 });
|
||||||
|
const line = new THREE.Line(geom, mat);
|
||||||
|
line.userData.isConnection = true;
|
||||||
|
scene.add(line);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main Agents System =====
|
||||||
|
export function createAgents(scene) {
|
||||||
|
const agentGroups = {};
|
||||||
|
const agentPlatforms = {};
|
||||||
|
const taskObjects = {};
|
||||||
|
const connectionLines = {};
|
||||||
|
const agentStates = {};
|
||||||
|
|
||||||
|
// Create each agent
|
||||||
|
AGENT_IDS.forEach(id => {
|
||||||
|
const pos = AGENT_POSITIONS[id];
|
||||||
|
const color = AGENT_COLORS[id];
|
||||||
|
const colorHex = '#' + color.toString(16).padStart(6, '0');
|
||||||
|
|
||||||
|
// Platform
|
||||||
|
const platform = createHexPlatform(color);
|
||||||
|
platform.position.set(pos.x, 0, pos.z);
|
||||||
|
scene.add(platform);
|
||||||
|
agentPlatforms[id] = platform;
|
||||||
|
|
||||||
|
// Agent geometry
|
||||||
|
const agentGeo = AGENT_CREATORS[id]();
|
||||||
|
agentGeo.position.set(pos.x, 0, pos.z);
|
||||||
|
agentGeo.name = `agent_${id}`;
|
||||||
|
agentGeo.userData.agentId = id;
|
||||||
|
agentGeo.userData.isAgent = true;
|
||||||
|
scene.add(agentGeo);
|
||||||
|
agentGroups[id] = agentGeo;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const label = createLabel(AGENT_DEFS[id].name.toUpperCase(), colorHex);
|
||||||
|
label.position.set(pos.x, 5, pos.z);
|
||||||
|
scene.add(label);
|
||||||
|
|
||||||
|
// State
|
||||||
|
agentStates[id] = {
|
||||||
|
state: 'idle',
|
||||||
|
glowIntensity: 0.5,
|
||||||
|
targetGlow: 0.5,
|
||||||
|
bobOffset: Math.random() * Math.PI * 2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentGroups,
|
||||||
|
agentPlatforms,
|
||||||
|
taskObjects,
|
||||||
|
connectionLines,
|
||||||
|
agentStates,
|
||||||
|
|
||||||
|
// Get the raycasting targets
|
||||||
|
getInteractables() {
|
||||||
|
const targets = [];
|
||||||
|
Object.values(agentGroups).forEach(group => {
|
||||||
|
group.traverse(child => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.userData.agentId = group.userData.agentId;
|
||||||
|
child.userData.isAgent = true;
|
||||||
|
targets.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
},
|
||||||
|
|
||||||
|
setAgentState(agentId, state, glowIntensity) {
|
||||||
|
if (!agentStates[agentId]) return;
|
||||||
|
agentStates[agentId].state = state;
|
||||||
|
agentStates[agentId].targetGlow = glowIntensity;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightAgent(agentId, highlight) {
|
||||||
|
const group = agentGroups[agentId];
|
||||||
|
if (!group) return;
|
||||||
|
group.traverse(child => {
|
||||||
|
if (child.isMesh && child.material) {
|
||||||
|
if (highlight) {
|
||||||
|
child.material._origOpacity = child.material._origOpacity ?? child.material.opacity;
|
||||||
|
child.material.opacity = Math.min(1, child.material._origOpacity + 0.3);
|
||||||
|
} else if (child.material._origOpacity !== undefined) {
|
||||||
|
child.material.opacity = child.material._origOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConnection(agentA, agentB, active) {
|
||||||
|
const key = [agentA, agentB].sort().join('-');
|
||||||
|
if (active) {
|
||||||
|
if (!connectionLines[key]) {
|
||||||
|
const posA = AGENT_POSITIONS[agentA];
|
||||||
|
const posB = AGENT_POSITIONS[agentB];
|
||||||
|
if (posA && posB) {
|
||||||
|
connectionLines[key] = createConnectionLine(scene, posA, posB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (connectionLines[key]) {
|
||||||
|
connectionLines[key].geometry.dispose();
|
||||||
|
connectionLines[key].material.dispose();
|
||||||
|
scene.remove(connectionLines[key]);
|
||||||
|
delete connectionLines[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTaskObject(taskId, agentId, status) {
|
||||||
|
if (taskObjects[taskId]) return;
|
||||||
|
const pos = AGENT_POSITIONS[agentId];
|
||||||
|
if (!pos) return;
|
||||||
|
const obj = createTaskObject(status);
|
||||||
|
obj.userData.taskId = taskId;
|
||||||
|
obj.userData.agentId = agentId;
|
||||||
|
obj.userData.orbitAngle = Math.random() * Math.PI * 2;
|
||||||
|
obj.userData.orbitRadius = 4.5 + Math.random() * 1.5;
|
||||||
|
obj.userData.orbitSpeed = 0.2 + Math.random() * 0.3;
|
||||||
|
obj.userData.orbitY = 1.5 + Math.random() * 2;
|
||||||
|
obj.position.set(pos.x, obj.userData.orbitY, pos.z);
|
||||||
|
scene.add(obj);
|
||||||
|
taskObjects[taskId] = obj;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTaskObject(taskId, status) {
|
||||||
|
const obj = taskObjects[taskId];
|
||||||
|
if (!obj) return;
|
||||||
|
const color = TASK_STATUS_COLORS[status] || 0xffffff;
|
||||||
|
obj.material.color.setHex(color);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTaskObject(taskId) {
|
||||||
|
const obj = taskObjects[taskId];
|
||||||
|
if (!obj) return;
|
||||||
|
obj.geometry.dispose();
|
||||||
|
obj.material.dispose();
|
||||||
|
scene.remove(obj);
|
||||||
|
delete taskObjects[taskId];
|
||||||
|
},
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
// Animate agents
|
||||||
|
AGENT_IDS.forEach(id => {
|
||||||
|
const group = agentGroups[id];
|
||||||
|
const state = agentStates[id];
|
||||||
|
const pos = AGENT_POSITIONS[id];
|
||||||
|
|
||||||
|
// Floating bob
|
||||||
|
const bobSpeed = state.state === 'working' ? 2.5 : 1.2;
|
||||||
|
const bobAmp = state.state === 'working' ? 0.3 : 0.15;
|
||||||
|
const bob = Math.sin(time * bobSpeed + state.bobOffset) * bobAmp;
|
||||||
|
group.position.y = bob;
|
||||||
|
|
||||||
|
// Slow rotation
|
||||||
|
const rotSpeed = state.state === 'working' ? 0.4 : 0.15;
|
||||||
|
group.rotation.y = time * rotSpeed;
|
||||||
|
|
||||||
|
// Glow intensity lerp
|
||||||
|
state.glowIntensity += (state.targetGlow - state.glowIntensity) * delta * 2;
|
||||||
|
group.traverse(child => {
|
||||||
|
if (child.isPointLight) {
|
||||||
|
child.intensity = state.glowIntensity * 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent-specific animations
|
||||||
|
if (id === 'forge') {
|
||||||
|
group.children.forEach(child => {
|
||||||
|
if (child.userData.orbitIndex !== undefined) {
|
||||||
|
const i = child.userData.orbitIndex;
|
||||||
|
const angle = time * 0.8 + i * (Math.PI * 2 / 3);
|
||||||
|
child.position.x = pos.x + Math.cos(angle) * 1.5;
|
||||||
|
child.position.z = pos.z + Math.sin(angle) * 1.5;
|
||||||
|
child.position.y = 2.5 + Math.sin(time * 1.5 + i) * 0.3;
|
||||||
|
child.rotation.x = time;
|
||||||
|
child.rotation.z = time * 0.7;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'seer') {
|
||||||
|
group.children.forEach(child => {
|
||||||
|
if (child.userData.isCrystal) {
|
||||||
|
child.rotation.x = time * 0.5;
|
||||||
|
child.rotation.z = time * 0.3;
|
||||||
|
}
|
||||||
|
if (child.userData.orbitIndex !== undefined) {
|
||||||
|
const i = child.userData.orbitIndex;
|
||||||
|
const angle = time * 0.6 + i * (Math.PI * 2 / 5);
|
||||||
|
const r = child.userData.orbitRadius;
|
||||||
|
child.position.x = pos.x + Math.cos(angle) * r;
|
||||||
|
child.position.z = pos.z + Math.sin(angle) * r;
|
||||||
|
child.position.y = 2.5 + Math.sin(time + i * 0.5) * 0.5;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'echo') {
|
||||||
|
group.children.forEach(child => {
|
||||||
|
if (child.userData.ringIndex !== undefined) {
|
||||||
|
const i = child.userData.ringIndex;
|
||||||
|
const pulse = Math.sin(time * 2 - i * 0.5) * 0.5 + 0.5;
|
||||||
|
const scale = 1 + pulse * 0.3;
|
||||||
|
child.scale.set(scale, scale, scale);
|
||||||
|
child.material.opacity = (0.6 - i * 0.12) * (0.5 + pulse * 0.5);
|
||||||
|
child.rotation.z = time * 0.3 * (i % 2 === 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate task objects
|
||||||
|
Object.values(taskObjects).forEach(obj => {
|
||||||
|
const pos = AGENT_POSITIONS[obj.userData.agentId];
|
||||||
|
if (!pos) return;
|
||||||
|
obj.userData.orbitAngle += obj.userData.orbitSpeed * delta;
|
||||||
|
const angle = obj.userData.orbitAngle;
|
||||||
|
const r = obj.userData.orbitRadius;
|
||||||
|
obj.position.x = pos.x + Math.cos(angle) * r;
|
||||||
|
obj.position.z = pos.z + Math.sin(angle) * r;
|
||||||
|
obj.position.y = obj.userData.orbitY + Math.sin(time * 1.5 + angle) * 0.3;
|
||||||
|
obj.rotation.x = time * 0.8;
|
||||||
|
obj.rotation.z = time * 0.5;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate connection lines (pulse opacity)
|
||||||
|
Object.values(connectionLines).forEach(line => {
|
||||||
|
line.material.opacity = 0.15 + Math.sin(time * 3) * 0.15;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
Object.values(agentGroups).forEach(group => {
|
||||||
|
group.traverse(obj => {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) obj.material.dispose();
|
||||||
|
});
|
||||||
|
scene.remove(group);
|
||||||
|
});
|
||||||
|
Object.values(agentPlatforms).forEach(p => {
|
||||||
|
p.traverse(obj => {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) obj.material.dispose();
|
||||||
|
});
|
||||||
|
scene.remove(p);
|
||||||
|
});
|
||||||
|
Object.values(taskObjects).forEach(obj => {
|
||||||
|
obj.geometry.dispose();
|
||||||
|
obj.material.dispose();
|
||||||
|
scene.remove(obj);
|
||||||
|
});
|
||||||
|
Object.values(connectionLines).forEach(line => {
|
||||||
|
line.geometry.dispose();
|
||||||
|
line.material.dispose();
|
||||||
|
scene.remove(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AGENT_POSITIONS, AGENT_COLORS };
|
||||||
283
js/effects.js
vendored
Normal file
283
js/effects.js
vendored
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// ===== Effects: Digital rain, particles, post-processing, scanlines =====
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||||
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||||
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||||
|
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||||||
|
|
||||||
|
// ===== Vignette + Scanline Shader =====
|
||||||
|
const VignetteScanlineShader = {
|
||||||
|
uniforms: {
|
||||||
|
tDiffuse: { value: null },
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uVignetteIntensity: { value: 0.4 },
|
||||||
|
uScanlineIntensity: { value: 0.06 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D tDiffuse;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uVignetteIntensity;
|
||||||
|
uniform float uScanlineIntensity;
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(tDiffuse, vUv);
|
||||||
|
|
||||||
|
// Scanlines
|
||||||
|
float scanline = sin(vUv.y * 800.0 + uTime * 2.0) * 0.5 + 0.5;
|
||||||
|
color.rgb -= scanline * uScanlineIntensity;
|
||||||
|
|
||||||
|
// Vignette
|
||||||
|
vec2 uv = vUv * 2.0 - 1.0;
|
||||||
|
float vignette = 1.0 - dot(uv, uv) * uVignetteIntensity;
|
||||||
|
color.rgb *= vignette;
|
||||||
|
|
||||||
|
gl_FragColor = color;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Digital Rain Shader (on a cylinder) =====
|
||||||
|
const rainVertexShader = `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rainFragmentShader = `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uDensity;
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
float hash(vec2 p) {
|
||||||
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
float hash2(vec2 p) {
|
||||||
|
return fract(sin(dot(p, vec2(269.5, 183.3))) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = vUv;
|
||||||
|
float cols = uDensity;
|
||||||
|
float rows = 60.0;
|
||||||
|
|
||||||
|
// Column index
|
||||||
|
float col = floor(uv.x * cols);
|
||||||
|
float colFrac = fract(uv.x * cols);
|
||||||
|
|
||||||
|
// Each column properties
|
||||||
|
float speed = 0.15 + hash(vec2(col, 0.0)) * 0.45;
|
||||||
|
float offset = hash(vec2(col, 1.0)) * 200.0;
|
||||||
|
float length = 8.0 + hash(vec2(col, 2.0)) * 18.0;
|
||||||
|
|
||||||
|
// Scrolling Y
|
||||||
|
float scrollY = uv.y * rows + uTime * speed * rows + offset;
|
||||||
|
float rowIdx = floor(scrollY);
|
||||||
|
float rowFrac = fract(scrollY);
|
||||||
|
|
||||||
|
// Character glyph simulation - small rectangle within cell
|
||||||
|
float charMarginX = 0.15;
|
||||||
|
float charMarginY = 0.12;
|
||||||
|
float inCharX = step(charMarginX, colFrac) * step(charMarginX, 1.0 - colFrac);
|
||||||
|
float inCharY = step(charMarginY, rowFrac) * step(charMarginY, 1.0 - rowFrac);
|
||||||
|
float inChar = inCharX * inCharY;
|
||||||
|
|
||||||
|
// Random per-cell character presence and flicker
|
||||||
|
float charSeed = hash(vec2(col, rowIdx));
|
||||||
|
float charPresent = step(0.3, charSeed);
|
||||||
|
|
||||||
|
// Flicker: some cells change
|
||||||
|
float flicker = hash2(vec2(col, rowIdx + floor(uTime * 3.0)));
|
||||||
|
charPresent *= step(0.15, flicker);
|
||||||
|
|
||||||
|
// "Glyph" pattern within cell using sub-hash
|
||||||
|
float glyphDetail = hash(vec2(col * 13.0 + floor(colFrac * 3.0), rowIdx * 7.0 + floor(rowFrac * 4.0) + floor(uTime * 2.0)));
|
||||||
|
float glyphMask = step(0.35, glyphDetail);
|
||||||
|
|
||||||
|
// Trail: head of each stream is brightest, fades behind
|
||||||
|
float streamPhase = fract(uTime * speed * 0.5 + hash(vec2(col, 3.0)));
|
||||||
|
float headPos = streamPhase * (rows + length);
|
||||||
|
float distFromHead = mod(scrollY - headPos, rows + length);
|
||||||
|
float inStream = smoothstep(length, 0.0, distFromHead);
|
||||||
|
|
||||||
|
// Leading character glow
|
||||||
|
float isHead = smoothstep(2.0, 0.0, distFromHead);
|
||||||
|
|
||||||
|
// Combine
|
||||||
|
float alpha = inChar * charPresent * glyphMask * inStream * 0.5;
|
||||||
|
alpha += isHead * inChar * 0.6;
|
||||||
|
|
||||||
|
// Column brightness variation
|
||||||
|
float colBrightness = 0.6 + hash(vec2(col, 5.0)) * 0.4;
|
||||||
|
alpha *= colBrightness;
|
||||||
|
|
||||||
|
// Vertical fade at top/bottom
|
||||||
|
float vertFade = smoothstep(0.0, 0.1, uv.y) * smoothstep(1.0, 0.85, uv.y);
|
||||||
|
alpha *= vertFade;
|
||||||
|
|
||||||
|
// Color
|
||||||
|
vec3 color = mix(vec3(0.0, 0.35, 0.02), vec3(0.0, 1.0, 0.25), isHead * 0.7 + inStream * 0.3);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ===== Create Digital Rain Backdrop =====
|
||||||
|
function createDigitalRain(scene) {
|
||||||
|
const radius = 58;
|
||||||
|
const height = 50;
|
||||||
|
const segments = 64;
|
||||||
|
|
||||||
|
const geom = new THREE.CylinderGeometry(radius, radius, height, segments, 1, true);
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: rainVertexShader,
|
||||||
|
fragmentShader: rainFragmentShader,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uDensity: { value: 80 },
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
side: THREE.BackSide,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geom, mat);
|
||||||
|
mesh.position.y = height / 2 - 8;
|
||||||
|
mesh.renderOrder = -2;
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
update(time) {
|
||||||
|
mat.uniforms.uTime.value = time;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
geom.dispose();
|
||||||
|
mat.dispose();
|
||||||
|
scene.remove(mesh);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Floating Particles =====
|
||||||
|
function createParticles(scene) {
|
||||||
|
const count = 200;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
const sizes = new Float32Array(count);
|
||||||
|
|
||||||
|
const green = new THREE.Color(0x00ff41);
|
||||||
|
const dimGreen = new THREE.Color(0x003b00);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const r = 5 + Math.random() * 45;
|
||||||
|
positions[i * 3] = Math.cos(angle) * r;
|
||||||
|
positions[i * 3 + 1] = Math.random() * 25;
|
||||||
|
positions[i * 3 + 2] = Math.sin(angle) * r;
|
||||||
|
|
||||||
|
const c = Math.random() > 0.7 ? green : dimGreen;
|
||||||
|
colors[i * 3] = c.r;
|
||||||
|
colors[i * 3 + 1] = c.g;
|
||||||
|
colors[i * 3 + 2] = c.b;
|
||||||
|
|
||||||
|
sizes[i] = 0.5 + Math.random() * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geom = new THREE.BufferGeometry();
|
||||||
|
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||||
|
geom.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
||||||
|
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
size: 0.15,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = new THREE.Points(geom, mat);
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
update(time) {
|
||||||
|
const pos = geom.attributes.position.array;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
pos[i * 3 + 1] += 0.005 + Math.sin(time + i) * 0.002;
|
||||||
|
if (pos[i * 3 + 1] > 25) pos[i * 3 + 1] = 0;
|
||||||
|
}
|
||||||
|
geom.attributes.position.needsUpdate = true;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
geom.dispose();
|
||||||
|
mat.dispose();
|
||||||
|
scene.remove(points);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Setup Post-Processing =====
|
||||||
|
export function setupEffects(renderer, scene, camera) {
|
||||||
|
const composer = new EffectComposer(renderer);
|
||||||
|
|
||||||
|
// Render pass
|
||||||
|
const renderPass = new RenderPass(scene, camera);
|
||||||
|
composer.addPass(renderPass);
|
||||||
|
|
||||||
|
// Bloom — make green elements GLOW
|
||||||
|
const bloomPass = new UnrealBloomPass(
|
||||||
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||||
|
1.0, // strength
|
||||||
|
0.4, // radius
|
||||||
|
0.5 // threshold
|
||||||
|
);
|
||||||
|
composer.addPass(bloomPass);
|
||||||
|
|
||||||
|
// Vignette + scanlines
|
||||||
|
const vignettePass = new ShaderPass(VignetteScanlineShader);
|
||||||
|
composer.addPass(vignettePass);
|
||||||
|
|
||||||
|
// Digital rain
|
||||||
|
const rain = createDigitalRain(scene);
|
||||||
|
|
||||||
|
// Particles
|
||||||
|
const particles = createParticles(scene);
|
||||||
|
|
||||||
|
return {
|
||||||
|
composer,
|
||||||
|
bloomPass,
|
||||||
|
vignettePass,
|
||||||
|
rain,
|
||||||
|
particles,
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
vignettePass.uniforms.uTime.value = time;
|
||||||
|
rain.update(time);
|
||||||
|
particles.update(time);
|
||||||
|
},
|
||||||
|
|
||||||
|
resize(width, height) {
|
||||||
|
composer.setSize(width, height);
|
||||||
|
bloomPass.resolution.set(width, height);
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
composer.dispose();
|
||||||
|
rain.dispose();
|
||||||
|
particles.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
147
js/interaction.js
Normal file
147
js/interaction.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// ===== Interaction: Raycasting, selection, camera control =====
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
export class InteractionManager {
|
||||||
|
constructor(camera, renderer, agents, ui) {
|
||||||
|
this.camera = camera;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.agents = agents;
|
||||||
|
this.ui = ui;
|
||||||
|
|
||||||
|
this.raycaster = new THREE.Raycaster();
|
||||||
|
this.pointer = new THREE.Vector2();
|
||||||
|
this.selectedAgent = null;
|
||||||
|
this.interactables = [];
|
||||||
|
this.coreObjects = [];
|
||||||
|
|
||||||
|
this._setupControls();
|
||||||
|
this._bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupControls() {
|
||||||
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.08;
|
||||||
|
this.controls.rotateSpeed = 0.5;
|
||||||
|
this.controls.panSpeed = 0.5;
|
||||||
|
this.controls.zoomSpeed = 0.8;
|
||||||
|
|
||||||
|
// Limits
|
||||||
|
this.controls.minDistance = 10;
|
||||||
|
this.controls.maxDistance = 80;
|
||||||
|
this.controls.minPolarAngle = 0.2;
|
||||||
|
this.controls.maxPolarAngle = Math.PI / 2.1;
|
||||||
|
|
||||||
|
// Target center
|
||||||
|
this.controls.target.set(0, 3, 0);
|
||||||
|
|
||||||
|
// Touch config
|
||||||
|
this.controls.touches = {
|
||||||
|
ONE: THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractables(interactables) {
|
||||||
|
this.interactables = interactables;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoreObjects(coreObjects) {
|
||||||
|
this.coreObjects = coreObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
const canvas = this.renderer.domElement;
|
||||||
|
|
||||||
|
// Click / tap
|
||||||
|
let pointerDownTime = 0;
|
||||||
|
let pointerDownPos = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
pointerDownTime = Date.now();
|
||||||
|
pointerDownPos = { x: e.clientX, y: e.clientY };
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerup', (e) => {
|
||||||
|
const elapsed = Date.now() - pointerDownTime;
|
||||||
|
const dx = e.clientX - pointerDownPos.x;
|
||||||
|
const dy = e.clientY - pointerDownPos.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Short tap with minimal movement = click
|
||||||
|
if (elapsed < 400 && dist < 15) {
|
||||||
|
this._handleTap(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleTap(event) {
|
||||||
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||||
|
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||||
|
|
||||||
|
// Check core first
|
||||||
|
const coreHits = this.raycaster.intersectObjects(this.coreObjects, true);
|
||||||
|
if (coreHits.length > 0) {
|
||||||
|
this._deselectAgent();
|
||||||
|
this.ui.selectCore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check agents
|
||||||
|
const hits = this.raycaster.intersectObjects(this.interactables, true);
|
||||||
|
if (hits.length > 0) {
|
||||||
|
// Walk up to find agentId
|
||||||
|
let agentId = null;
|
||||||
|
let obj = hits[0].object;
|
||||||
|
while (obj) {
|
||||||
|
if (obj.userData && obj.userData.agentId) {
|
||||||
|
agentId = obj.userData.agentId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
obj = obj.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentId) {
|
||||||
|
this._selectAgent(agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap empty space — close panels
|
||||||
|
if (this.ui.isPanelOpen()) {
|
||||||
|
this._deselectAgent();
|
||||||
|
this.ui.closePanel();
|
||||||
|
this.ui.closeSystemPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectAgent(agentId) {
|
||||||
|
if (this.selectedAgent === agentId) return;
|
||||||
|
|
||||||
|
// Deselect previous
|
||||||
|
this._deselectAgent();
|
||||||
|
|
||||||
|
this.selectedAgent = agentId;
|
||||||
|
this.agents.highlightAgent(agentId, true);
|
||||||
|
this.ui.selectAgent(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_deselectAgent() {
|
||||||
|
if (this.selectedAgent) {
|
||||||
|
this.agents.highlightAgent(this.selectedAgent, false);
|
||||||
|
this.selectedAgent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(delta) {
|
||||||
|
this.controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.controls.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
199
js/main.js
Normal file
199
js/main.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// ===== Main: Bootstrap, scene setup, game loop =====
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createWorld } from './world.js';
|
||||||
|
import { createAgents } from './agents.js';
|
||||||
|
import { setupEffects } from './effects.js';
|
||||||
|
import { UIManager } from './ui.js';
|
||||||
|
import { InteractionManager } from './interaction.js';
|
||||||
|
import { MockWebSocket } from './websocket.js';
|
||||||
|
|
||||||
|
class MatrixWorld {
|
||||||
|
constructor() {
|
||||||
|
this.clock = new THREE.Clock();
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.lastFPSTime = 0;
|
||||||
|
this.fps = 0;
|
||||||
|
|
||||||
|
this._init();
|
||||||
|
}
|
||||||
|
|
||||||
|
_init() {
|
||||||
|
// ---- Scene ----
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = new THREE.Color(0x050505);
|
||||||
|
|
||||||
|
// ---- Camera ----
|
||||||
|
this.camera = new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
window.innerWidth / window.innerHeight,
|
||||||
|
0.1,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
// Start at 45-degree angle overlooking arena
|
||||||
|
this.camera.position.set(30, 25, 30);
|
||||||
|
this.camera.lookAt(0, 2, 0);
|
||||||
|
|
||||||
|
// ---- Renderer ----
|
||||||
|
const canvas = document.getElementById('matrix-canvas');
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
this.renderer.toneMappingExposure = 1.2;
|
||||||
|
|
||||||
|
// ---- WebSocket (Mock) ----
|
||||||
|
this.ws = new MockWebSocket();
|
||||||
|
|
||||||
|
// ---- World ----
|
||||||
|
this.world = createWorld(this.scene);
|
||||||
|
|
||||||
|
// ---- Agents ----
|
||||||
|
this.agents = createAgents(this.scene);
|
||||||
|
|
||||||
|
// ---- Effects (post-processing, rain, particles) ----
|
||||||
|
this.effects = setupEffects(this.renderer, this.scene, this.camera);
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
this.ui = new UIManager(this.ws);
|
||||||
|
|
||||||
|
// ---- Interaction ----
|
||||||
|
this.interaction = new InteractionManager(
|
||||||
|
this.camera, this.renderer, this.agents, this.ui
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set raycasting targets
|
||||||
|
this.interaction.setInteractables(this.agents.getInteractables());
|
||||||
|
|
||||||
|
// Set core objects for raycasting
|
||||||
|
const coreObjects = [];
|
||||||
|
this.world.coreGroup.traverse(child => {
|
||||||
|
if (child.isMesh) coreObjects.push(child);
|
||||||
|
});
|
||||||
|
this.interaction.setCoreObjects(coreObjects);
|
||||||
|
|
||||||
|
// On panel close, deselect
|
||||||
|
this.ui.onClose = () => {
|
||||||
|
if (this.interaction.selectedAgent) {
|
||||||
|
this.agents.highlightAgent(this.interaction.selectedAgent, false);
|
||||||
|
this.interaction.selectedAgent = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- WS Events → World ----
|
||||||
|
this._bindWSToWorld();
|
||||||
|
|
||||||
|
// ---- Initialize task objects from existing tasks ----
|
||||||
|
this._initTaskObjects();
|
||||||
|
|
||||||
|
// ---- Resize ----
|
||||||
|
window.addEventListener('resize', () => this._onResize());
|
||||||
|
|
||||||
|
// ---- Keyboard shortcuts ----
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
// Press 'F' to toggle FPS counter
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
if (document.activeElement?.tagName === 'INPUT') return;
|
||||||
|
this.ui.fpsCounter.classList.toggle('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Start loop ----
|
||||||
|
this._animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindWSToWorld() {
|
||||||
|
this.ws.on('message', (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'agent_state':
|
||||||
|
this.agents.setAgentState(msg.agent_id, msg.state, msg.glow_intensity);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'task_created':
|
||||||
|
if (msg.agent_id) {
|
||||||
|
this.agents.addTaskObject(msg.task_id, msg.agent_id, msg.status);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'task_update':
|
||||||
|
this.agents.updateTaskObject(msg.task_id, msg.status);
|
||||||
|
if (msg.status === 'completed' || msg.status === 'failed') {
|
||||||
|
// Remove after a delay
|
||||||
|
setTimeout(() => this.agents.removeTaskObject(msg.task_id), 5000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'connection':
|
||||||
|
this.agents.updateConnection(msg.agent_id, msg.target_id, msg.active);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_initTaskObjects() {
|
||||||
|
const tasks = Object.values(this.ws.tasks);
|
||||||
|
tasks.forEach(task => {
|
||||||
|
if (task.agent_id) {
|
||||||
|
this.agents.addTaskObject(task.task_id, task.agent_id, task.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onResize() {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(w, h);
|
||||||
|
this.effects.resize(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
_animate() {
|
||||||
|
requestAnimationFrame(() => this._animate());
|
||||||
|
|
||||||
|
const delta = Math.min(this.clock.getDelta(), 0.1);
|
||||||
|
const time = this.clock.getElapsedTime();
|
||||||
|
|
||||||
|
// Update systems
|
||||||
|
this.world.update(time, delta);
|
||||||
|
this.agents.update(time, delta);
|
||||||
|
this.effects.update(time, delta);
|
||||||
|
this.interaction.update(delta);
|
||||||
|
|
||||||
|
// Render with post-processing
|
||||||
|
this.effects.composer.render();
|
||||||
|
|
||||||
|
// FPS counter (read info AFTER render)
|
||||||
|
this.frameCount++;
|
||||||
|
const info = this.renderer.info;
|
||||||
|
this._lastDrawCalls = info.render.calls;
|
||||||
|
this._lastTriangles = info.render.triangles;
|
||||||
|
if (time - this.lastFPSTime >= 1) {
|
||||||
|
this.fps = this.frameCount / (time - this.lastFPSTime);
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.lastFPSTime = time;
|
||||||
|
this.ui.updateFPS(
|
||||||
|
this.fps,
|
||||||
|
this._lastDrawCalls,
|
||||||
|
this._lastTriangles
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.world.dispose();
|
||||||
|
this.agents.dispose();
|
||||||
|
this.effects.dispose();
|
||||||
|
this.interaction.dispose();
|
||||||
|
this.ws.dispose();
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Boot =====
|
||||||
|
document.fonts.ready.then(() => {
|
||||||
|
window.matrixWorld = new MatrixWorld();
|
||||||
|
});
|
||||||
314
js/ui.js
Normal file
314
js/ui.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
// ===== UI: Panel, tabs, chat, task list, memory =====
|
||||||
|
import { AGENT_DEFS } from './websocket.js';
|
||||||
|
|
||||||
|
export class UIManager {
|
||||||
|
constructor(ws) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.selectedAgent = null;
|
||||||
|
this.activeTab = 'chat';
|
||||||
|
this.onClose = null;
|
||||||
|
|
||||||
|
// Cache DOM refs
|
||||||
|
this.panel = document.getElementById('info-panel');
|
||||||
|
this.systemPanel = document.getElementById('system-panel');
|
||||||
|
this.panelName = document.getElementById('panel-agent-name');
|
||||||
|
this.panelRole = document.getElementById('panel-agent-role');
|
||||||
|
this.chatMessages = document.getElementById('chat-messages');
|
||||||
|
this.chatInput = document.getElementById('chat-input');
|
||||||
|
this.chatSend = document.getElementById('chat-send');
|
||||||
|
this.typingIndicator = document.getElementById('typing-indicator');
|
||||||
|
this.statusGrid = document.getElementById('status-grid');
|
||||||
|
this.tasksList = document.getElementById('tasks-list');
|
||||||
|
this.memoryList = document.getElementById('memory-list');
|
||||||
|
this.systemStatusGrid = document.getElementById('system-status-grid');
|
||||||
|
this.fpsCounter = document.getElementById('fps-counter');
|
||||||
|
|
||||||
|
this._bindEvents();
|
||||||
|
this._bindWSEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.panel-tabs .tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
this._switchTab(e.target.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close buttons
|
||||||
|
document.getElementById('panel-close').addEventListener('click', () => this.closePanel());
|
||||||
|
document.getElementById('system-panel-close').addEventListener('click', () => this.closeSystemPanel());
|
||||||
|
|
||||||
|
// Chat send
|
||||||
|
this.chatSend.addEventListener('click', () => this._sendChat());
|
||||||
|
this.chatInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') this._sendChat();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent canvas events when interacting with panel
|
||||||
|
[this.panel, this.systemPanel].forEach(el => {
|
||||||
|
el.addEventListener('pointerdown', e => e.stopPropagation());
|
||||||
|
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindWSEvents() {
|
||||||
|
this.ws.on('message', (msg) => {
|
||||||
|
if (!this.selectedAgent) return;
|
||||||
|
|
||||||
|
if (msg.type === 'agent_message' && msg.agent_id === this.selectedAgent) {
|
||||||
|
this._hideTyping();
|
||||||
|
this._addChatMessage('assistant', msg.content, msg.agent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'agent_state' && msg.agent_id === this.selectedAgent) {
|
||||||
|
if (this.activeTab === 'status') this._renderStatus(this.selectedAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'task_update' || msg.type === 'task_created') {
|
||||||
|
if (this.activeTab === 'tasks') this._renderTasks(this.selectedAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'memory_event' && msg.agent_id === this.selectedAgent) {
|
||||||
|
if (this.activeTab === 'memory') this._renderMemory(this.selectedAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'system_status') {
|
||||||
|
this._renderSystemStatus(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('typing', (data) => {
|
||||||
|
if (data.agent_id === this.selectedAgent) {
|
||||||
|
this._showTyping();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAgent(agentId) {
|
||||||
|
this.selectedAgent = agentId;
|
||||||
|
const def = AGENT_DEFS[agentId];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
this.panelName.textContent = def.name.toUpperCase();
|
||||||
|
this.panelRole.textContent = def.role;
|
||||||
|
this.panelName.style.color = def.color;
|
||||||
|
this.panelName.style.textShadow = `0 0 10px ${def.color}80`;
|
||||||
|
|
||||||
|
this.systemPanel.classList.add('hidden');
|
||||||
|
this.panel.classList.remove('hidden');
|
||||||
|
|
||||||
|
this._switchTab('chat');
|
||||||
|
this._renderChat(agentId);
|
||||||
|
this._renderStatus(agentId);
|
||||||
|
this._renderTasks(agentId);
|
||||||
|
this._renderMemory(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCore() {
|
||||||
|
this.selectedAgent = null;
|
||||||
|
this.panel.classList.add('hidden');
|
||||||
|
this.systemPanel.classList.remove('hidden');
|
||||||
|
this._renderSystemStatus(this.ws.getSystemStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel() {
|
||||||
|
this.panel.classList.add('hidden');
|
||||||
|
this.selectedAgent = null;
|
||||||
|
if (this.onClose) this.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSystemPanel() {
|
||||||
|
this.systemPanel.classList.add('hidden');
|
||||||
|
if (this.onClose) this.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPanelOpen() {
|
||||||
|
return !this.panel.classList.contains('hidden') || !this.systemPanel.classList.contains('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
_switchTab(tabName) {
|
||||||
|
this.activeTab = tabName;
|
||||||
|
|
||||||
|
document.querySelectorAll('.panel-tabs .tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.tab === tabName);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => {
|
||||||
|
c.classList.toggle('active', c.id === `tab-${tabName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh content
|
||||||
|
if (this.selectedAgent) {
|
||||||
|
if (tabName === 'status') this._renderStatus(this.selectedAgent);
|
||||||
|
if (tabName === 'tasks') this._renderTasks(this.selectedAgent);
|
||||||
|
if (tabName === 'memory') this._renderMemory(this.selectedAgent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Chat =====
|
||||||
|
_renderChat(agentId) {
|
||||||
|
const agent = this.ws.getAgent(agentId);
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
this.chatMessages.innerHTML = '';
|
||||||
|
agent.messages.forEach(msg => {
|
||||||
|
this._addChatMessage(msg.role, msg.content, agentId, false);
|
||||||
|
});
|
||||||
|
this._scrollChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addChatMessage(role, content, agentId, scroll = true) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `chat-msg ${role}`;
|
||||||
|
|
||||||
|
const roleLabel = document.createElement('div');
|
||||||
|
roleLabel.className = 'msg-role';
|
||||||
|
roleLabel.textContent = role === 'user' ? 'OPERATOR' : AGENT_DEFS[agentId]?.name?.toUpperCase() || 'AGENT';
|
||||||
|
div.appendChild(roleLabel);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.textContent = content;
|
||||||
|
div.appendChild(text);
|
||||||
|
|
||||||
|
this.chatMessages.appendChild(div);
|
||||||
|
if (scroll) this._scrollChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendChat() {
|
||||||
|
const content = this.chatInput.value.trim();
|
||||||
|
if (!content || !this.selectedAgent) return;
|
||||||
|
|
||||||
|
this._addChatMessage('user', content, this.selectedAgent);
|
||||||
|
this.chatInput.value = '';
|
||||||
|
|
||||||
|
this.ws.send({
|
||||||
|
type: 'chat_message',
|
||||||
|
agent_id: this.selectedAgent,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_scrollChat() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_showTyping() {
|
||||||
|
this.typingIndicator.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideTyping() {
|
||||||
|
this.typingIndicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Status =====
|
||||||
|
_renderStatus(agentId) {
|
||||||
|
const agent = this.ws.getAgent(agentId);
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['Name', agent.name],
|
||||||
|
['Role', agent.role],
|
||||||
|
['State', agent.state],
|
||||||
|
['Current Task', agent.current_task || '—'],
|
||||||
|
['Glow Intensity', (agent.glow_intensity * 100).toFixed(0) + '%'],
|
||||||
|
['Last Action', this._formatTime(agent.last_action)],
|
||||||
|
];
|
||||||
|
|
||||||
|
this.statusGrid.innerHTML = rows.map(([key, value]) => {
|
||||||
|
const stateClass = key === 'State' ? `state-${value}` : '';
|
||||||
|
return `<div class="status-row">
|
||||||
|
<span class="status-key">${key}</span>
|
||||||
|
<span class="status-value ${stateClass}">${value}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tasks =====
|
||||||
|
_renderTasks(agentId) {
|
||||||
|
const tasks = this.ws.getAgentTasks(agentId);
|
||||||
|
if (!tasks.length) {
|
||||||
|
this.tasksList.innerHTML = '<div style="padding:20px;text-align:center;color:var(--matrix-text-dim);font-size:12px;">No tasks assigned</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasksList.innerHTML = tasks.map(task => `
|
||||||
|
<div class="task-item" data-task-id="${task.task_id}">
|
||||||
|
<div class="task-header">
|
||||||
|
<span class="task-status-dot ${task.status}"></span>
|
||||||
|
<span class="task-title">${task.title}</span>
|
||||||
|
<span class="task-priority ${task.priority}">${task.priority}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--matrix-text-dim);margin-bottom:4px;">Status: ${task.status}</div>
|
||||||
|
${task.status === 'pending' || task.status === 'in_progress' ? `
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="task-btn approve" data-task-id="${task.task_id}" data-action="approve">Approve</button>
|
||||||
|
<button class="task-btn veto" data-task-id="${task.task_id}" data-action="veto">Veto</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Bind task action buttons
|
||||||
|
this.tasksList.querySelectorAll('.task-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const taskId = e.target.dataset.taskId;
|
||||||
|
const action = e.target.dataset.action;
|
||||||
|
this.ws.send({ type: 'task_action', task_id: taskId, action });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Memory =====
|
||||||
|
_renderMemory(agentId) {
|
||||||
|
const agent = this.ws.getAgent(agentId);
|
||||||
|
if (!agent || !agent.memories.length) {
|
||||||
|
this.memoryList.innerHTML = '<div style="padding:20px;text-align:center;color:var(--matrix-text-dim);font-size:12px;">No memory entries yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.memoryList.innerHTML = agent.memories.slice(0, 30).map(entry => `
|
||||||
|
<div class="memory-entry">
|
||||||
|
<div class="memory-timestamp">${this._formatTime(entry.timestamp)}</div>
|
||||||
|
<div class="memory-content">${entry.content}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== System Status =====
|
||||||
|
_renderSystemStatus(status) {
|
||||||
|
const rows = [
|
||||||
|
['Agents Online', status.agents_online || 0],
|
||||||
|
['Tasks Pending', status.tasks_pending || 0],
|
||||||
|
['Tasks Running', status.tasks_running || 0],
|
||||||
|
['Tasks Completed', status.tasks_completed || 0],
|
||||||
|
['Tasks Failed', status.tasks_failed || 0],
|
||||||
|
['Total Tasks', status.total_tasks || 0],
|
||||||
|
['System Uptime', status.uptime || '—'],
|
||||||
|
];
|
||||||
|
|
||||||
|
this.systemStatusGrid.innerHTML = rows.map(([key, value]) => `
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-key">${key}</span>
|
||||||
|
<span class="status-value">${value}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== FPS =====
|
||||||
|
updateFPS(fps, drawCalls, triangles) {
|
||||||
|
this.fpsCounter.textContent = `FPS: ${fps.toFixed(0)} | Draw: ${drawCalls} | Tri: ${triangles}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
_formatTime(isoString) {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleTimeString('en-US', { hour12: false });
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
322
js/websocket.js
Normal file
322
js/websocket.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// ===== WebSocket Client + MockWebSocket =====
|
||||||
|
// Handles communication between the 3D world and agent backend
|
||||||
|
|
||||||
|
const AGENT_DEFS = {
|
||||||
|
timmy: { name: 'Timmy', role: 'Main Orchestrator', color: '#00ff41' },
|
||||||
|
forge: { name: 'Forge', role: 'Builder Agent', color: '#ff8c00' },
|
||||||
|
seer: { name: 'Seer', role: 'Planner / Observer', color: '#9d4edd' },
|
||||||
|
echo: { name: 'Echo', role: 'Communications', color: '#00d4ff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_IDS = Object.keys(AGENT_DEFS);
|
||||||
|
|
||||||
|
const CANNED_RESPONSES = {
|
||||||
|
timmy: [
|
||||||
|
"All agents reporting nominal. I'm coordinating the current sprint.",
|
||||||
|
"Understood. I'll dispatch that to the appropriate agent.",
|
||||||
|
"Running diagnostics on the system now. Stand by.",
|
||||||
|
"I've updated the task queue. Forge is picking up the next item.",
|
||||||
|
"Status check complete — all systems green.",
|
||||||
|
],
|
||||||
|
forge: [
|
||||||
|
"Building that component now. ETA: 12 minutes.",
|
||||||
|
"The codebase is clean. Ready for the next feature.",
|
||||||
|
"I found a bug in the auth module. Patching now.",
|
||||||
|
"Deployment pipeline is green. Pushing to staging.",
|
||||||
|
"Refactoring complete. Tests passing.",
|
||||||
|
],
|
||||||
|
seer: [
|
||||||
|
"I see a pattern forming in the data. Analyzing further.",
|
||||||
|
"The plan has been updated. Three critical paths identified.",
|
||||||
|
"Forecasting suggests peak load at 14:00 UTC. Scaling recommended.",
|
||||||
|
"I've mapped all dependencies. No circular references detected.",
|
||||||
|
"Risk assessment complete. Proceeding with caution on module 7.",
|
||||||
|
],
|
||||||
|
echo: [
|
||||||
|
"Broadcast sent to all channels successfully.",
|
||||||
|
"I've notified the team about the status change.",
|
||||||
|
"Incoming transmission received. Routing to Timmy.",
|
||||||
|
"Communication logs archived. 47 messages in the last hour.",
|
||||||
|
"Alert dispatched. All stakeholders have been pinged.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_TITLES = [
|
||||||
|
'Fix authentication bug', 'Deploy staging environment', 'Refactor database schema',
|
||||||
|
'Update API documentation', 'Optimize query performance', 'Implement rate limiting',
|
||||||
|
'Review pull request #42', 'Run security audit', 'Scale worker instances',
|
||||||
|
'Migrate to new CDN', 'Update SSL certificates', 'Analyze user patterns',
|
||||||
|
'Build dashboard widget', 'Configure monitoring alerts', 'Backup production data',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEMORY_ENTRIES = [
|
||||||
|
'Detected pattern: user prefers morning deployments',
|
||||||
|
'System load average decreased by 23% after optimization',
|
||||||
|
'New dependency vulnerability found in lodash@4.17.20',
|
||||||
|
'Agent coordination latency reduced to 12ms',
|
||||||
|
'User session patterns suggest peak activity at 10am EST',
|
||||||
|
'Codebase complexity score: 42 (within acceptable range)',
|
||||||
|
'Memory usage stable at 67% across all nodes',
|
||||||
|
'Anomaly detected in API response times — investigating',
|
||||||
|
'Successful failover test completed in 3.2 seconds',
|
||||||
|
'Cache hit ratio improved to 94% after config change',
|
||||||
|
];
|
||||||
|
|
||||||
|
function uuid() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
|
||||||
|
export class MockWebSocket {
|
||||||
|
constructor() {
|
||||||
|
this.listeners = {};
|
||||||
|
this.agents = {};
|
||||||
|
this.tasks = {};
|
||||||
|
this.connections = [];
|
||||||
|
this.systemStatus = { agents_online: 4, tasks_pending: 0, tasks_running: 0, uptime: '0h 0m' };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._intervals = [];
|
||||||
|
|
||||||
|
this._initAgents();
|
||||||
|
this._initTasks();
|
||||||
|
this._startSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners[event]) this.listeners[event] = [];
|
||||||
|
this.listeners[event].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
(this.listeners[event] || []).forEach(cb => cb(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message) {
|
||||||
|
const msg = typeof message === 'string' ? JSON.parse(message) : message;
|
||||||
|
|
||||||
|
if (msg.type === 'chat_message') {
|
||||||
|
this._handleChat(msg);
|
||||||
|
} else if (msg.type === 'task_action') {
|
||||||
|
this._handleTaskAction(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_initAgents() {
|
||||||
|
AGENT_IDS.forEach(id => {
|
||||||
|
this.agents[id] = {
|
||||||
|
id,
|
||||||
|
...AGENT_DEFS[id],
|
||||||
|
state: 'idle',
|
||||||
|
current_task: null,
|
||||||
|
glow_intensity: 0.5,
|
||||||
|
uptime: '0h 0m',
|
||||||
|
last_action: new Date().toISOString(),
|
||||||
|
messages: [],
|
||||||
|
memories: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_initTasks() {
|
||||||
|
const statuses = ['completed', 'completed', 'in_progress', 'pending', 'pending'];
|
||||||
|
const priorities = ['high', 'normal', 'normal', 'normal', 'high'];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const task = {
|
||||||
|
task_id: uuid(),
|
||||||
|
agent_id: pick(AGENT_IDS),
|
||||||
|
title: TASK_TITLES[i],
|
||||||
|
status: statuses[i],
|
||||||
|
priority: priorities[i],
|
||||||
|
};
|
||||||
|
this.tasks[task.task_id] = task;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateSystemStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSystemStatus() {
|
||||||
|
const tasks = Object.values(this.tasks);
|
||||||
|
const elapsed = Date.now() - this._startTime;
|
||||||
|
const hours = Math.floor(elapsed / 3600000);
|
||||||
|
const mins = Math.floor((elapsed % 3600000) / 60000);
|
||||||
|
|
||||||
|
this.systemStatus = {
|
||||||
|
agents_online: AGENT_IDS.filter(id => this.agents[id]).length,
|
||||||
|
tasks_pending: tasks.filter(t => t.status === 'pending').length,
|
||||||
|
tasks_running: tasks.filter(t => t.status === 'in_progress').length,
|
||||||
|
tasks_completed: tasks.filter(t => t.status === 'completed').length,
|
||||||
|
tasks_failed: tasks.filter(t => t.status === 'failed').length,
|
||||||
|
total_tasks: tasks.length,
|
||||||
|
uptime: `${hours}h ${mins}m`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_startSimulation() {
|
||||||
|
// Agent state changes every 4-8 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
const agentId = pick(AGENT_IDS);
|
||||||
|
const states = ['idle', 'working', 'working', 'waiting'];
|
||||||
|
const state = pick(states);
|
||||||
|
const agent = this.agents[agentId];
|
||||||
|
agent.state = state;
|
||||||
|
agent.glow_intensity = state === 'working' ? 0.9 : state === 'waiting' ? 0.6 : 0.4;
|
||||||
|
agent.current_task = state === 'working' ? pick(TASK_TITLES) : null;
|
||||||
|
agent.last_action = new Date().toISOString();
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'agent_state',
|
||||||
|
agent_id: agentId,
|
||||||
|
state,
|
||||||
|
current_task: agent.current_task,
|
||||||
|
glow_intensity: agent.glow_intensity,
|
||||||
|
});
|
||||||
|
}, 4000 + Math.random() * 4000));
|
||||||
|
|
||||||
|
// New tasks every 8-15 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
const task = {
|
||||||
|
task_id: uuid(),
|
||||||
|
agent_id: Math.random() > 0.3 ? pick(AGENT_IDS) : null,
|
||||||
|
title: pick(TASK_TITLES),
|
||||||
|
status: 'pending',
|
||||||
|
priority: Math.random() > 0.7 ? 'high' : 'normal',
|
||||||
|
};
|
||||||
|
this.tasks[task.task_id] = task;
|
||||||
|
this._updateSystemStatus();
|
||||||
|
|
||||||
|
this.emit('message', { type: 'task_created', ...task });
|
||||||
|
}, 8000 + Math.random() * 7000));
|
||||||
|
|
||||||
|
// Task status updates every 5-10 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
const pendingTasks = Object.values(this.tasks).filter(t => t.status === 'pending' || t.status === 'in_progress');
|
||||||
|
if (pendingTasks.length === 0) return;
|
||||||
|
|
||||||
|
const task = pick(pendingTasks);
|
||||||
|
if (task.status === 'pending') {
|
||||||
|
task.status = 'in_progress';
|
||||||
|
task.agent_id = task.agent_id || pick(AGENT_IDS);
|
||||||
|
} else {
|
||||||
|
task.status = Math.random() > 0.15 ? 'completed' : 'failed';
|
||||||
|
}
|
||||||
|
this._updateSystemStatus();
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'task_update',
|
||||||
|
task_id: task.task_id,
|
||||||
|
agent_id: task.agent_id,
|
||||||
|
title: task.title,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
});
|
||||||
|
}, 5000 + Math.random() * 5000));
|
||||||
|
|
||||||
|
// Memory events every 6-12 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
const agentId = pick(AGENT_IDS);
|
||||||
|
const content = pick(MEMORY_ENTRIES);
|
||||||
|
const entry = { timestamp: new Date().toISOString(), content };
|
||||||
|
this.agents[agentId].memories.unshift(entry);
|
||||||
|
if (this.agents[agentId].memories.length > 50) this.agents[agentId].memories.pop();
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'memory_event',
|
||||||
|
agent_id: agentId,
|
||||||
|
content,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
});
|
||||||
|
}, 6000 + Math.random() * 6000));
|
||||||
|
|
||||||
|
// Connection lines flicker every 3-6 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
const a = pick(AGENT_IDS);
|
||||||
|
let b = pick(AGENT_IDS);
|
||||||
|
while (b === a) b = pick(AGENT_IDS);
|
||||||
|
|
||||||
|
const active = Math.random() > 0.3;
|
||||||
|
const connKey = [a, b].sort().join('-');
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
if (!this.connections.includes(connKey)) this.connections.push(connKey);
|
||||||
|
} else {
|
||||||
|
this.connections = this.connections.filter(c => c !== connKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'connection',
|
||||||
|
agent_id: a,
|
||||||
|
target_id: b,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
}, 3000 + Math.random() * 3000));
|
||||||
|
|
||||||
|
// System status broadcast every 5 seconds
|
||||||
|
this._intervals.push(setInterval(() => {
|
||||||
|
this._updateSystemStatus();
|
||||||
|
this.emit('message', { type: 'system_status', ...this.systemStatus });
|
||||||
|
}, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChat(msg) {
|
||||||
|
const agentId = msg.agent_id;
|
||||||
|
const agent = this.agents[agentId];
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
agent.messages.push({ role: 'user', content: msg.content });
|
||||||
|
|
||||||
|
// Simulate typing delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.emit('typing', { agent_id: agentId });
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const response = pick(CANNED_RESPONSES[agentId] || CANNED_RESPONSES.timmy);
|
||||||
|
agent.messages.push({ role: 'assistant', content: response });
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'agent_message',
|
||||||
|
agent_id: agentId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: response,
|
||||||
|
});
|
||||||
|
}, 1000 + Math.random() * 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleTaskAction(msg) {
|
||||||
|
const task = this.tasks[msg.task_id];
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
if (msg.action === 'approve') {
|
||||||
|
task.status = 'in_progress';
|
||||||
|
} else if (msg.action === 'veto') {
|
||||||
|
task.status = 'failed';
|
||||||
|
}
|
||||||
|
this._updateSystemStatus();
|
||||||
|
|
||||||
|
this.emit('message', {
|
||||||
|
type: 'task_update',
|
||||||
|
task_id: task.task_id,
|
||||||
|
agent_id: task.agent_id,
|
||||||
|
title: task.title,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgent(id) { return this.agents[id]; }
|
||||||
|
getAgentTasks(id) { return Object.values(this.tasks).filter(t => t.agent_id === id); }
|
||||||
|
getSystemStatus() { this._updateSystemStatus(); return this.systemStatus; }
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._intervals.forEach(clearInterval);
|
||||||
|
this._intervals = [];
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AGENT_DEFS, AGENT_IDS };
|
||||||
206
js/world.js
Normal file
206
js/world.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
// ===== World: Ground plane, grid, core pillar, environment =====
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Grid shader - animated green grid on the ground
|
||||||
|
const gridVertexShader = `
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
vec4 worldPos = modelMatrix * vec4(position, 1.0);
|
||||||
|
vWorldPos = worldPos.xyz;
|
||||||
|
gl_Position = projectionMatrix * viewMatrix * worldPos;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gridFragmentShader = `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uRadius;
|
||||||
|
varying vec2 vUv;
|
||||||
|
varying vec3 vWorldPos;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 p = vWorldPos.xz;
|
||||||
|
float dist = length(p);
|
||||||
|
|
||||||
|
// Circular falloff
|
||||||
|
float edge = smoothstep(uRadius, uRadius - 8.0, dist);
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
float gridSize = 2.0;
|
||||||
|
vec2 grid = abs(fract(p / gridSize - 0.5) - 0.5) / fwidth(p / gridSize);
|
||||||
|
float line = min(grid.x, grid.y);
|
||||||
|
float gridAlpha = 1.0 - min(line, 1.0);
|
||||||
|
|
||||||
|
// Pulse wave from center
|
||||||
|
float pulse = sin(dist * 0.3 - uTime * 1.5) * 0.5 + 0.5;
|
||||||
|
pulse = smoothstep(0.3, 0.7, pulse);
|
||||||
|
|
||||||
|
// Sub-grid (finer lines)
|
||||||
|
float subGridSize = 0.5;
|
||||||
|
vec2 subGrid = abs(fract(p / subGridSize - 0.5) - 0.5) / fwidth(p / subGridSize);
|
||||||
|
float subLine = min(subGrid.x, subGrid.y);
|
||||||
|
float subGridAlpha = 1.0 - min(subLine, 1.0);
|
||||||
|
|
||||||
|
// Combine
|
||||||
|
float alpha = (gridAlpha * 0.5 + subGridAlpha * 0.08) * edge;
|
||||||
|
alpha += gridAlpha * edge * pulse * 0.2;
|
||||||
|
|
||||||
|
// Radial glow near center
|
||||||
|
float centerGlow = smoothstep(15.0, 0.0, dist) * 0.04;
|
||||||
|
alpha += centerGlow * edge;
|
||||||
|
|
||||||
|
// Green color with slight variation
|
||||||
|
vec3 color = mix(vec3(0.0, 0.4, 0.02), vec3(0.0, 1.0, 0.25), pulse * 0.5 + gridAlpha * 0.5);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function createWorld(scene) {
|
||||||
|
const worldGroup = new THREE.Group();
|
||||||
|
|
||||||
|
// ---- Ground Plane with Grid Shader ----
|
||||||
|
const groundGeom = new THREE.PlaneGeometry(120, 120, 1, 1);
|
||||||
|
groundGeom.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
|
const gridMaterial = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: gridVertexShader,
|
||||||
|
fragmentShader: gridFragmentShader,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uRadius: { value: 50 },
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groundMesh = new THREE.Mesh(groundGeom, gridMaterial);
|
||||||
|
groundMesh.position.y = -0.01;
|
||||||
|
groundMesh.renderOrder = -1;
|
||||||
|
worldGroup.add(groundMesh);
|
||||||
|
|
||||||
|
// ---- Dark solid floor underneath ----
|
||||||
|
const floorGeom = new THREE.CircleGeometry(52, 64);
|
||||||
|
floorGeom.rotateX(-Math.PI / 2);
|
||||||
|
const floorMat = new THREE.MeshBasicMaterial({ color: 0x030503, transparent: true, opacity: 0.9 });
|
||||||
|
const floorMesh = new THREE.Mesh(floorGeom, floorMat);
|
||||||
|
floorMesh.position.y = -0.05;
|
||||||
|
worldGroup.add(floorMesh);
|
||||||
|
|
||||||
|
// ---- THE CORE — Central Pillar ----
|
||||||
|
const coreGroup = new THREE.Group();
|
||||||
|
coreGroup.name = 'core';
|
||||||
|
|
||||||
|
// Main pillar - octagonal prism
|
||||||
|
const pillarGeom = new THREE.CylinderGeometry(1.2, 1.5, 14, 8, 1);
|
||||||
|
const pillarMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x00ff41,
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.3,
|
||||||
|
});
|
||||||
|
const pillar = new THREE.Mesh(pillarGeom, pillarMat);
|
||||||
|
pillar.position.y = 7;
|
||||||
|
coreGroup.add(pillar);
|
||||||
|
|
||||||
|
// Inner glowing core
|
||||||
|
const innerGeom = new THREE.CylinderGeometry(0.6, 0.8, 12, 8, 1);
|
||||||
|
const innerMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x00ff41,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.6,
|
||||||
|
});
|
||||||
|
const inner = new THREE.Mesh(innerGeom, innerMat);
|
||||||
|
inner.position.y = 7;
|
||||||
|
coreGroup.add(inner);
|
||||||
|
|
||||||
|
// Floating rings around core
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const ringGeom = new THREE.TorusGeometry(2.2 + i * 0.6, 0.04, 8, 32);
|
||||||
|
const ringMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x00ff41,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4 - i * 0.1,
|
||||||
|
});
|
||||||
|
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||||
|
ring.position.y = 4 + i * 4;
|
||||||
|
ring.rotation.x = Math.PI / 2 + (i * 0.2);
|
||||||
|
ring.userData.ringIndex = i;
|
||||||
|
coreGroup.add(ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core base glow
|
||||||
|
const baseGlowGeom = new THREE.CylinderGeometry(2.5, 3, 0.3, 8, 1);
|
||||||
|
const baseGlowMat = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x00ff41,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.15,
|
||||||
|
});
|
||||||
|
const baseGlow = new THREE.Mesh(baseGlowGeom, baseGlowMat);
|
||||||
|
baseGlow.position.y = 0.15;
|
||||||
|
coreGroup.add(baseGlow);
|
||||||
|
|
||||||
|
// Point light at core
|
||||||
|
const coreLight = new THREE.PointLight(0x00ff41, 2, 30);
|
||||||
|
coreLight.position.y = 7;
|
||||||
|
coreGroup.add(coreLight);
|
||||||
|
|
||||||
|
worldGroup.add(coreGroup);
|
||||||
|
|
||||||
|
// ---- Ambient lighting ----
|
||||||
|
const ambient = new THREE.AmbientLight(0x112211, 0.3);
|
||||||
|
worldGroup.add(ambient);
|
||||||
|
|
||||||
|
// Dim directional for slight form
|
||||||
|
const dirLight = new THREE.DirectionalLight(0x00ff41, 0.15);
|
||||||
|
dirLight.position.set(10, 20, 10);
|
||||||
|
worldGroup.add(dirLight);
|
||||||
|
|
||||||
|
// ---- Green fog ----
|
||||||
|
scene.fog = new THREE.FogExp2(0x020502, 0.012);
|
||||||
|
|
||||||
|
scene.add(worldGroup);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldGroup,
|
||||||
|
gridMaterial,
|
||||||
|
coreGroup,
|
||||||
|
coreLight,
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
// Update grid pulse
|
||||||
|
gridMaterial.uniforms.uTime.value = time;
|
||||||
|
|
||||||
|
// Rotate core slowly
|
||||||
|
coreGroup.rotation.y = time * 0.15;
|
||||||
|
|
||||||
|
// Pulse core light
|
||||||
|
coreLight.intensity = 1.5 + Math.sin(time * 2) * 0.5;
|
||||||
|
|
||||||
|
// Animate rings
|
||||||
|
coreGroup.children.forEach(child => {
|
||||||
|
if (child.userData.ringIndex !== undefined) {
|
||||||
|
const i = child.userData.ringIndex;
|
||||||
|
child.rotation.z = time * (0.2 + i * 0.1);
|
||||||
|
child.position.y = 4 + i * 4 + Math.sin(time * 0.8 + i) * 0.3;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pulse inner core opacity
|
||||||
|
inner.material.opacity = 0.4 + Math.sin(time * 3) * 0.2;
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
worldGroup.traverse(obj => {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) {
|
||||||
|
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose());
|
||||||
|
else obj.material.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scene.remove(worldGroup);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
592
style.css
Normal file
592
style.css
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/* ===== THE MATRIX — SOVEREIGN AGENT WORLD ===== */
|
||||||
|
/* Matrix Green/Noir Cyberpunk Aesthetic */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--matrix-green: #00ff41;
|
||||||
|
--matrix-green-dim: #008f11;
|
||||||
|
--matrix-green-dark: #003b00;
|
||||||
|
--matrix-cyan: #00d4ff;
|
||||||
|
--matrix-bg: #050505;
|
||||||
|
--matrix-surface: rgba(0, 255, 65, 0.04);
|
||||||
|
--matrix-surface-solid: #0a0f0a;
|
||||||
|
--matrix-border: rgba(0, 255, 65, 0.2);
|
||||||
|
--matrix-border-bright: rgba(0, 255, 65, 0.45);
|
||||||
|
--matrix-text: #b0ffb0;
|
||||||
|
--matrix-text-dim: #4a7a4a;
|
||||||
|
--matrix-text-bright: #00ff41;
|
||||||
|
--matrix-danger: #ff3333;
|
||||||
|
--matrix-warning: #ff8c00;
|
||||||
|
--matrix-purple: #9d4edd;
|
||||||
|
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
--panel-width: 360px;
|
||||||
|
--panel-blur: 20px;
|
||||||
|
--panel-radius: 4px;
|
||||||
|
--transition-panel: 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--transition-ui: 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--matrix-bg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--matrix-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#matrix-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FPS Counter ===== */
|
||||||
|
#fps-counter {
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--matrix-green-dim);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: pre;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fps-counter.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Panel Base ===== */
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: var(--panel-width);
|
||||||
|
height: 100%;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(5, 10, 5, 0.88);
|
||||||
|
backdrop-filter: blur(var(--panel-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--panel-blur));
|
||||||
|
border-left: 1px solid var(--matrix-border-bright);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform var(--transition-panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.hidden {
|
||||||
|
transform: translateX(100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanline overlay on panel */
|
||||||
|
.panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 255, 65, 0.015) 2px,
|
||||||
|
rgba(0, 255, 65, 0.015) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Panel Header ===== */
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--matrix-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-agent-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--matrix-text-bright);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-agent-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--matrix-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-ui);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-close:hover, .panel-close:active {
|
||||||
|
color: var(--matrix-text-bright);
|
||||||
|
border-color: var(--matrix-border-bright);
|
||||||
|
background: rgba(0, 255, 65, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tabs ===== */
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--matrix-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--matrix-text);
|
||||||
|
background: rgba(0, 255, 65, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--matrix-text-bright);
|
||||||
|
border-bottom-color: var(--matrix-green);
|
||||||
|
text-shadow: 0 0 8px rgba(0, 255, 65, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Panel Content ===== */
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Chat ===== */
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--matrix-green-dark);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.user {
|
||||||
|
background: rgba(0, 212, 255, 0.08);
|
||||||
|
border-left: 2px solid var(--matrix-cyan);
|
||||||
|
color: #b0eeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.assistant {
|
||||||
|
background: rgba(0, 255, 65, 0.05);
|
||||||
|
border-left: 2px solid var(--matrix-green-dim);
|
||||||
|
color: var(--matrix-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg .msg-role {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
border-top: 1px solid var(--matrix-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 255, 65, 0.04);
|
||||||
|
border: 1px solid var(--matrix-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--matrix-text-bright);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input:focus {
|
||||||
|
border-color: var(--matrix-green);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 65, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input::placeholder {
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
width: 40px;
|
||||||
|
background: rgba(0, 255, 65, 0.1);
|
||||||
|
border: 1px solid var(--matrix-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--matrix-green);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-ui);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:hover, .btn-send:active {
|
||||||
|
background: rgba(0, 255, 65, 0.2);
|
||||||
|
border-color: var(--matrix-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--matrix-green-dim);
|
||||||
|
animation: typingDot 1.4s infinite both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typingDot {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Status Tab ===== */
|
||||||
|
.status-grid {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 65, 0.06);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-key {
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
color: var(--matrix-text-bright);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.state-working {
|
||||||
|
color: var(--matrix-green);
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 65, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.state-idle {
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.state-waiting {
|
||||||
|
color: var(--matrix-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tasks Tab ===== */
|
||||||
|
.tasks-list {
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(0, 255, 65, 0.03);
|
||||||
|
border: 1px solid var(--matrix-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-dot.pending { background: #ffffff; }
|
||||||
|
.task-status-dot.in_progress, .task-status-dot.in-progress { background: var(--matrix-warning); box-shadow: 0 0 6px rgba(255, 140, 0, 0.5); }
|
||||||
|
.task-status-dot.completed { background: var(--matrix-green); box-shadow: 0 0 6px rgba(0, 255, 65, 0.5); }
|
||||||
|
.task-status-dot.failed { background: var(--matrix-danger); box-shadow: 0 0 6px rgba(255, 51, 51, 0.5); }
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--matrix-text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(0, 255, 65, 0.08);
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority.high {
|
||||||
|
background: rgba(255, 51, 51, 0.15);
|
||||||
|
color: var(--matrix-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority.normal {
|
||||||
|
background: rgba(0, 255, 65, 0.08);
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-ui);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn.approve {
|
||||||
|
border-color: rgba(0, 255, 65, 0.3);
|
||||||
|
color: var(--matrix-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn.approve:hover {
|
||||||
|
background: rgba(0, 255, 65, 0.15);
|
||||||
|
border-color: var(--matrix-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn.veto {
|
||||||
|
border-color: rgba(255, 51, 51, 0.3);
|
||||||
|
color: var(--matrix-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn.veto:hover {
|
||||||
|
background: rgba(255, 51, 51, 0.15);
|
||||||
|
border-color: var(--matrix-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Memory Tab ===== */
|
||||||
|
.memory-list {
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-entry {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-left: 2px solid var(--matrix-green-dark);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--matrix-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-timestamp {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--matrix-text-dim);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-content {
|
||||||
|
color: var(--matrix-text);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Attribution ===== */
|
||||||
|
.attribution {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribution a {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--matrix-green-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-ui);
|
||||||
|
text-shadow: 0 0 4px rgba(0, 143, 17, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribution a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--matrix-green-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile / iPad ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 60%;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--matrix-border-bright);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-agent-name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs .tab {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.panel {
|
||||||
|
height: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user