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