forked from rockachopa/Timmy-time-dashboard
feat: Workshop Phase 2 — Scene MVP (Three.js room) (#401)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
50
static/world/controls.js
vendored
Normal file
50
static/world/controls.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Camera + touch controls for the Workshop scene.
|
||||
*
|
||||
* Uses Three.js OrbitControls with constrained range — the visitor
|
||||
* can look around the room but not leave it.
|
||||
*/
|
||||
|
||||
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
|
||||
|
||||
/**
|
||||
* Set up camera controls.
|
||||
* @param {THREE.PerspectiveCamera} camera
|
||||
* @param {HTMLCanvasElement} domElement
|
||||
* @returns {OrbitControls}
|
||||
*/
|
||||
export function setupControls(camera, domElement) {
|
||||
const controls = new OrbitControls(camera, domElement);
|
||||
|
||||
// Smooth damping
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
|
||||
// Limit zoom range
|
||||
controls.minDistance = 3;
|
||||
controls.maxDistance = 12;
|
||||
|
||||
// Limit vertical angle (don't look below floor or straight up)
|
||||
controls.minPolarAngle = Math.PI * 0.2;
|
||||
controls.maxPolarAngle = Math.PI * 0.6;
|
||||
|
||||
// Limit horizontal rotation range (stay facing the desk area)
|
||||
controls.minAzimuthAngle = -Math.PI * 0.4;
|
||||
controls.maxAzimuthAngle = Math.PI * 0.4;
|
||||
|
||||
// Target: roughly the desk area
|
||||
controls.target.set(0, 1.2, 0);
|
||||
|
||||
// Touch settings
|
||||
controls.touches = {
|
||||
ONE: 0, // ROTATE
|
||||
TWO: 2, // DOLLY
|
||||
};
|
||||
|
||||
// Disable panning (visitor stays in place)
|
||||
controls.enablePan = false;
|
||||
|
||||
controls.update();
|
||||
|
||||
return controls;
|
||||
}
|
||||
150
static/world/familiar.js
Normal file
150
static/world/familiar.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Pip the Familiar — a small glowing orb that floats around the room.
|
||||
*
|
||||
* Emerald green core with a gold particle trail.
|
||||
* Wanders on a randomized path, occasionally pauses near Timmy.
|
||||
*/
|
||||
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
const CORE_COLOR = 0x00b450;
|
||||
const GLOW_COLOR = 0x00b450;
|
||||
const TRAIL_COLOR = 0xdaa520;
|
||||
|
||||
/**
|
||||
* Create the familiar and return { group, update }.
|
||||
* Call update(dt) each frame.
|
||||
*/
|
||||
export function createFamiliar() {
|
||||
const group = new THREE.Group();
|
||||
|
||||
// --- Core orb ---
|
||||
const coreGeo = new THREE.SphereGeometry(0.08, 12, 10);
|
||||
const coreMat = new THREE.MeshStandardMaterial({
|
||||
color: CORE_COLOR,
|
||||
emissive: GLOW_COLOR,
|
||||
emissiveIntensity: 1.5,
|
||||
roughness: 0.2,
|
||||
});
|
||||
const core = new THREE.Mesh(coreGeo, coreMat);
|
||||
group.add(core);
|
||||
|
||||
// --- Glow (larger transparent sphere) ---
|
||||
const glowGeo = new THREE.SphereGeometry(0.15, 10, 8);
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: GLOW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
});
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||||
group.add(glow);
|
||||
|
||||
// --- Point light from Pip ---
|
||||
const light = new THREE.PointLight(CORE_COLOR, 0.4, 4);
|
||||
group.add(light);
|
||||
|
||||
// --- Trail particles (simple small spheres) ---
|
||||
const trailCount = 6;
|
||||
const trails = [];
|
||||
const trailGeo = new THREE.SphereGeometry(0.02, 4, 4);
|
||||
const trailMat = new THREE.MeshBasicMaterial({
|
||||
color: TRAIL_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
});
|
||||
for (let i = 0; i < trailCount; i++) {
|
||||
const t = new THREE.Mesh(trailGeo, trailMat.clone());
|
||||
t.visible = false;
|
||||
group.add(t);
|
||||
trails.push({ mesh: t, age: 0, maxAge: 0.3 + Math.random() * 0.3 });
|
||||
}
|
||||
|
||||
// Starting position
|
||||
group.position.set(1.5, 1.8, -0.5);
|
||||
|
||||
// Wandering state
|
||||
let elapsed = 0;
|
||||
let trailTimer = 0;
|
||||
let trailIndex = 0;
|
||||
|
||||
// Waypoints for random wandering
|
||||
const waypoints = [
|
||||
new THREE.Vector3(1.5, 1.8, -0.5),
|
||||
new THREE.Vector3(-1.0, 2.0, 0.5),
|
||||
new THREE.Vector3(0.0, 1.5, -0.3), // near Timmy
|
||||
new THREE.Vector3(1.2, 2.2, 0.8),
|
||||
new THREE.Vector3(-0.5, 1.3, -0.2), // near desk
|
||||
new THREE.Vector3(0.3, 2.5, 0.3),
|
||||
];
|
||||
let waypointIndex = 0;
|
||||
let target = waypoints[0].clone();
|
||||
let pauseTimer = 0;
|
||||
|
||||
function pickNextTarget() {
|
||||
waypointIndex = (waypointIndex + 1) % waypoints.length;
|
||||
target.copy(waypoints[waypointIndex]);
|
||||
// Add randomness
|
||||
target.x += (Math.random() - 0.5) * 0.6;
|
||||
target.y += (Math.random() - 0.5) * 0.3;
|
||||
target.z += (Math.random() - 0.5) * 0.6;
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
elapsed += dt;
|
||||
|
||||
// Move toward target
|
||||
if (pauseTimer > 0) {
|
||||
pauseTimer -= dt;
|
||||
} else {
|
||||
const dir = target.clone().sub(group.position);
|
||||
const dist = dir.length();
|
||||
if (dist < 0.15) {
|
||||
pickNextTarget();
|
||||
// Occasionally pause
|
||||
if (Math.random() < 0.3) {
|
||||
pauseTimer = 1.0 + Math.random() * 2.0;
|
||||
}
|
||||
} else {
|
||||
dir.normalize();
|
||||
const speed = 0.4;
|
||||
group.position.add(dir.multiplyScalar(speed * dt));
|
||||
}
|
||||
}
|
||||
|
||||
// Bob up and down
|
||||
group.position.y += Math.sin(elapsed * 3.0) * 0.002;
|
||||
|
||||
// Pulse glow
|
||||
const pulse = 0.12 + Math.sin(elapsed * 4.0) * 0.05;
|
||||
glowMat.opacity = pulse;
|
||||
coreMat.emissiveIntensity = 1.2 + Math.sin(elapsed * 3.5) * 0.4;
|
||||
|
||||
// Trail particles
|
||||
trailTimer += dt;
|
||||
if (trailTimer > 0.1) {
|
||||
trailTimer = 0;
|
||||
const t = trails[trailIndex];
|
||||
t.mesh.position.copy(group.position);
|
||||
t.mesh.position.x += (Math.random() - 0.5) * 0.1;
|
||||
t.mesh.position.y += (Math.random() - 0.5) * 0.1;
|
||||
t.mesh.visible = true;
|
||||
t.age = 0;
|
||||
// Convert to local space
|
||||
group.worldToLocal(t.mesh.position);
|
||||
trailIndex = (trailIndex + 1) % trailCount;
|
||||
}
|
||||
|
||||
// Age and fade trail particles
|
||||
for (const t of trails) {
|
||||
if (!t.mesh.visible) continue;
|
||||
t.age += dt;
|
||||
if (t.age >= t.maxAge) {
|
||||
t.mesh.visible = false;
|
||||
} else {
|
||||
t.mesh.material.opacity = 0.6 * (1.0 - t.age / t.maxAge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { group, update };
|
||||
}
|
||||
108
static/world/index.html
Normal file
108
static/world/index.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Timmy's Workshop</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="overlay">
|
||||
<div id="status">
|
||||
<div class="name">Timmy</div>
|
||||
<div class="mood" id="mood-text">focused</div>
|
||||
</div>
|
||||
<div id="connection-dot"></div>
|
||||
<div id="speech-area">
|
||||
<div class="bubble" id="speech-bubble"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { buildRoom } from "./scene.js";
|
||||
import { createWizard } from "./wizard.js";
|
||||
import { createFamiliar } from "./familiar.js";
|
||||
import { setupControls } from "./controls.js";
|
||||
import { StateReader } from "./state.js";
|
||||
|
||||
// --- Renderer ---
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.8;
|
||||
document.body.prepend(renderer.domElement);
|
||||
|
||||
// --- Scene ---
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a14);
|
||||
scene.fog = new THREE.Fog(0x0a0a14, 5, 12);
|
||||
|
||||
// --- Camera (visitor at the door) ---
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55, window.innerWidth / window.innerHeight, 0.1, 50
|
||||
);
|
||||
camera.position.set(0, 2.0, 4.5);
|
||||
|
||||
// --- Build scene elements ---
|
||||
const { crystalBall, fireLight } = buildRoom(scene);
|
||||
const wizard = createWizard();
|
||||
scene.add(wizard.group);
|
||||
const familiar = createFamiliar();
|
||||
scene.add(familiar.group);
|
||||
|
||||
// --- Controls ---
|
||||
const controls = setupControls(camera, renderer.domElement);
|
||||
|
||||
// --- State ---
|
||||
const stateReader = new StateReader();
|
||||
const moodEl = document.getElementById("mood-text");
|
||||
stateReader.onChange((state) => {
|
||||
if (moodEl) {
|
||||
moodEl.textContent = state.timmyState.mood;
|
||||
}
|
||||
});
|
||||
stateReader.connect();
|
||||
|
||||
// --- Resize ---
|
||||
window.addEventListener("resize", () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// --- Animation loop ---
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
const dt = clock.getDelta();
|
||||
|
||||
// Update scene elements
|
||||
wizard.update(dt);
|
||||
familiar.update(dt);
|
||||
controls.update();
|
||||
|
||||
// Crystal ball subtle rotation
|
||||
crystalBall.rotation.y += dt * 0.3;
|
||||
|
||||
// Fireplace flicker
|
||||
fireLight.intensity = 1.2 + Math.sin(Date.now() * 0.005) * 0.15
|
||||
+ Math.sin(Date.now() * 0.013) * 0.1;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
154
static/world/scene.js
Normal file
154
static/world/scene.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Workshop scene — room geometry, lighting, materials.
|
||||
*
|
||||
* A dark stone room with a wooden desk, crystal ball, fireplace glow,
|
||||
* and faint emerald ambient light. This is Timmy's Workshop.
|
||||
*/
|
||||
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
const WALL_COLOR = 0x1a1a2e;
|
||||
const FLOOR_COLOR = 0x1a1a1a;
|
||||
const DESK_COLOR = 0x3e2723;
|
||||
const DESK_TOP_COLOR = 0x4e342e;
|
||||
|
||||
/**
|
||||
* Build the room and add it to the given scene.
|
||||
* Returns { crystalBall } for animation.
|
||||
*/
|
||||
export function buildRoom(scene) {
|
||||
// --- Floor ---
|
||||
const floorGeo = new THREE.PlaneGeometry(8, 8);
|
||||
const floorMat = new THREE.MeshStandardMaterial({
|
||||
color: FLOOR_COLOR,
|
||||
roughness: 0.9,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
scene.add(floor);
|
||||
|
||||
// --- Back wall ---
|
||||
const wallGeo = new THREE.PlaneGeometry(8, 4);
|
||||
const wallMat = new THREE.MeshStandardMaterial({
|
||||
color: WALL_COLOR,
|
||||
roughness: 0.95,
|
||||
});
|
||||
const backWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
backWall.position.set(0, 2, -4);
|
||||
scene.add(backWall);
|
||||
|
||||
// --- Side walls ---
|
||||
const leftWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
leftWall.position.set(-4, 2, 0);
|
||||
leftWall.rotation.y = Math.PI / 2;
|
||||
scene.add(leftWall);
|
||||
|
||||
const rightWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
rightWall.position.set(4, 2, 0);
|
||||
rightWall.rotation.y = -Math.PI / 2;
|
||||
scene.add(rightWall);
|
||||
|
||||
// --- Desk ---
|
||||
// Table top
|
||||
const topGeo = new THREE.BoxGeometry(1.8, 0.08, 0.9);
|
||||
const topMat = new THREE.MeshStandardMaterial({
|
||||
color: DESK_TOP_COLOR,
|
||||
roughness: 0.6,
|
||||
});
|
||||
const tableTop = new THREE.Mesh(topGeo, topMat);
|
||||
tableTop.position.set(0, 0.85, -0.3);
|
||||
tableTop.castShadow = true;
|
||||
scene.add(tableTop);
|
||||
|
||||
// Legs
|
||||
const legGeo = new THREE.BoxGeometry(0.08, 0.85, 0.08);
|
||||
const legMat = new THREE.MeshStandardMaterial({
|
||||
color: DESK_COLOR,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const offsets = [
|
||||
[-0.8, -0.35],
|
||||
[0.8, -0.35],
|
||||
[-0.8, 0.05],
|
||||
[0.8, 0.05],
|
||||
];
|
||||
for (const [x, z] of offsets) {
|
||||
const leg = new THREE.Mesh(legGeo, legMat);
|
||||
leg.position.set(x, 0.425, z - 0.3);
|
||||
scene.add(leg);
|
||||
}
|
||||
|
||||
// --- Scrolls / papers on desk (simple flat boxes) ---
|
||||
const paperGeo = new THREE.BoxGeometry(0.3, 0.005, 0.2);
|
||||
const paperMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd4c5a0,
|
||||
roughness: 0.9,
|
||||
});
|
||||
const paper1 = new THREE.Mesh(paperGeo, paperMat);
|
||||
paper1.position.set(-0.4, 0.895, -0.35);
|
||||
paper1.rotation.y = 0.15;
|
||||
scene.add(paper1);
|
||||
|
||||
const paper2 = new THREE.Mesh(paperGeo, paperMat);
|
||||
paper2.position.set(0.5, 0.895, -0.2);
|
||||
paper2.rotation.y = -0.3;
|
||||
scene.add(paper2);
|
||||
|
||||
// --- Crystal ball ---
|
||||
const ballGeo = new THREE.SphereGeometry(0.12, 16, 14);
|
||||
const ballMat = new THREE.MeshPhysicalMaterial({
|
||||
color: 0x88ccff,
|
||||
roughness: 0.05,
|
||||
metalness: 0.0,
|
||||
transmission: 0.9,
|
||||
thickness: 0.3,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
const crystalBall = new THREE.Mesh(ballGeo, ballMat);
|
||||
crystalBall.position.set(0.15, 1.01, -0.3);
|
||||
scene.add(crystalBall);
|
||||
|
||||
// Crystal ball base
|
||||
const baseGeo = new THREE.CylinderGeometry(0.08, 0.1, 0.04, 8);
|
||||
const baseMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x444444,
|
||||
roughness: 0.3,
|
||||
metalness: 0.5,
|
||||
});
|
||||
const base = new THREE.Mesh(baseGeo, baseMat);
|
||||
base.position.set(0.15, 0.9, -0.3);
|
||||
scene.add(base);
|
||||
|
||||
// Crystal ball inner glow
|
||||
const innerLight = new THREE.PointLight(0x88ccff, 0.3, 2);
|
||||
innerLight.position.copy(crystalBall.position);
|
||||
scene.add(innerLight);
|
||||
|
||||
// --- Lighting ---
|
||||
|
||||
// Fireplace glow (warm, off-screen stage left)
|
||||
const fireLight = new THREE.PointLight(0xff6622, 1.2, 8);
|
||||
fireLight.position.set(-3.5, 1.2, -1.0);
|
||||
fireLight.castShadow = true;
|
||||
fireLight.shadow.mapSize.width = 512;
|
||||
fireLight.shadow.mapSize.height = 512;
|
||||
scene.add(fireLight);
|
||||
|
||||
// Secondary warm fill
|
||||
const fillLight = new THREE.PointLight(0xff8844, 0.3, 6);
|
||||
fillLight.position.set(-2.0, 0.5, 1.0);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Emerald ambient
|
||||
const ambient = new THREE.AmbientLight(0x00b450, 0.15);
|
||||
scene.add(ambient);
|
||||
|
||||
// Faint overhead to keep things readable
|
||||
const overhead = new THREE.PointLight(0x887766, 0.2, 8);
|
||||
overhead.position.set(0, 3.5, 0);
|
||||
scene.add(overhead);
|
||||
|
||||
return { crystalBall, fireLight };
|
||||
}
|
||||
95
static/world/state.js
Normal file
95
static/world/state.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* State reader — hardcoded JSON for Phase 2, WebSocket in Phase 3.
|
||||
*
|
||||
* Provides Timmy's current state to the scene. In Phase 2 this is a
|
||||
* static default; the WebSocket path is stubbed for future use.
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
timmyState: {
|
||||
mood: "focused",
|
||||
activity: "Pondering the arcane arts",
|
||||
energy: 0.6,
|
||||
confidence: 0.7,
|
||||
},
|
||||
activeThreads: [],
|
||||
recentEvents: [],
|
||||
concerns: [],
|
||||
visitorPresent: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
};
|
||||
|
||||
export class StateReader {
|
||||
constructor() {
|
||||
this.state = { ...DEFAULTS };
|
||||
this.listeners = [];
|
||||
this._ws = null;
|
||||
}
|
||||
|
||||
/** Subscribe to state changes. */
|
||||
onChange(fn) {
|
||||
this.listeners.push(fn);
|
||||
}
|
||||
|
||||
/** Notify all listeners. */
|
||||
_notify() {
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
fn(this.state);
|
||||
} catch (e) {
|
||||
console.warn("State listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Try to connect to the world WebSocket for live updates. */
|
||||
connect() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${location.host}/api/world/ws`;
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws.onopen = () => {
|
||||
const dot = document.getElementById("connection-dot");
|
||||
if (dot) dot.classList.add("connected");
|
||||
};
|
||||
this._ws.onclose = () => {
|
||||
const dot = document.getElementById("connection-dot");
|
||||
if (dot) dot.classList.remove("connected");
|
||||
};
|
||||
this._ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "world_state" || msg.type === "timmy_state") {
|
||||
if (msg.timmyState) this.state.timmyState = msg.timmyState;
|
||||
if (msg.mood) {
|
||||
this.state.timmyState.mood = msg.mood;
|
||||
this.state.timmyState.activity = msg.activity || "";
|
||||
this.state.timmyState.energy = msg.energy ?? 0.5;
|
||||
}
|
||||
this._notify();
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("WebSocket unavailable — using static state");
|
||||
}
|
||||
}
|
||||
|
||||
/** Current mood string. */
|
||||
get mood() {
|
||||
return this.state.timmyState.mood;
|
||||
}
|
||||
|
||||
/** Current activity string. */
|
||||
get activity() {
|
||||
return this.state.timmyState.activity;
|
||||
}
|
||||
|
||||
/** Energy level 0-1. */
|
||||
get energy() {
|
||||
return this.state.timmyState.energy;
|
||||
}
|
||||
}
|
||||
89
static/world/style.css
Normal file
89
static/world/style.css
Normal file
@@ -0,0 +1,89 @@
|
||||
/* Workshop 3D scene overlay styles */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #0a0a14;
|
||||
font-family: "Courier New", monospace;
|
||||
color: #e0e0e0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#status {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#status .name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #daa520;
|
||||
}
|
||||
|
||||
#status .mood {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#speech-area {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #ccc;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
#speech-area.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#speech-area .bubble {
|
||||
background: rgba(10, 10, 20, 0.85);
|
||||
border: 1px solid rgba(218, 165, 32, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
#connection-dot {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 16px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
#connection-dot.connected {
|
||||
background: #00b450;
|
||||
}
|
||||
99
static/world/wizard.js
Normal file
99
static/world/wizard.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Timmy the Wizard — geometric figure built from primitives.
|
||||
*
|
||||
* Phase 1: cone body (robe), sphere head, cylinder arms.
|
||||
* Idle animation: gentle breathing (Y-scale oscillation), head tilt.
|
||||
*/
|
||||
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
const ROBE_COLOR = 0x2d1b4e;
|
||||
const TRIM_COLOR = 0xdaa520;
|
||||
|
||||
/**
|
||||
* Create the wizard group and return { group, update }.
|
||||
* Call update(dt) each frame for idle animation.
|
||||
*/
|
||||
export function createWizard() {
|
||||
const group = new THREE.Group();
|
||||
|
||||
// --- Robe (cone) ---
|
||||
const robeGeo = new THREE.ConeGeometry(0.5, 1.6, 8);
|
||||
const robeMat = new THREE.MeshStandardMaterial({
|
||||
color: ROBE_COLOR,
|
||||
roughness: 0.8,
|
||||
});
|
||||
const robe = new THREE.Mesh(robeGeo, robeMat);
|
||||
robe.position.y = 0.8;
|
||||
group.add(robe);
|
||||
|
||||
// --- Trim ring at robe bottom ---
|
||||
const trimGeo = new THREE.TorusGeometry(0.5, 0.03, 8, 24);
|
||||
const trimMat = new THREE.MeshStandardMaterial({
|
||||
color: TRIM_COLOR,
|
||||
roughness: 0.4,
|
||||
metalness: 0.3,
|
||||
});
|
||||
const trim = new THREE.Mesh(trimGeo, trimMat);
|
||||
trim.rotation.x = Math.PI / 2;
|
||||
trim.position.y = 0.02;
|
||||
group.add(trim);
|
||||
|
||||
// --- Head (sphere) ---
|
||||
const headGeo = new THREE.SphereGeometry(0.22, 12, 10);
|
||||
const headMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd4a574,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const head = new THREE.Mesh(headGeo, headMat);
|
||||
head.position.y = 1.72;
|
||||
group.add(head);
|
||||
|
||||
// --- Hood (cone behind head) ---
|
||||
const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 8);
|
||||
const hoodMat = new THREE.MeshStandardMaterial({
|
||||
color: ROBE_COLOR,
|
||||
roughness: 0.8,
|
||||
});
|
||||
const hood = new THREE.Mesh(hoodGeo, hoodMat);
|
||||
hood.position.y = 1.85;
|
||||
hood.position.z = -0.08;
|
||||
group.add(hood);
|
||||
|
||||
// --- Arms (cylinders) ---
|
||||
const armGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.7, 6);
|
||||
const armMat = new THREE.MeshStandardMaterial({
|
||||
color: ROBE_COLOR,
|
||||
roughness: 0.8,
|
||||
});
|
||||
|
||||
const leftArm = new THREE.Mesh(armGeo, armMat);
|
||||
leftArm.position.set(-0.45, 1.0, 0.15);
|
||||
leftArm.rotation.z = 0.3;
|
||||
leftArm.rotation.x = -0.4;
|
||||
group.add(leftArm);
|
||||
|
||||
const rightArm = new THREE.Mesh(armGeo, armMat);
|
||||
rightArm.position.set(0.45, 1.0, 0.15);
|
||||
rightArm.rotation.z = -0.3;
|
||||
rightArm.rotation.x = -0.4;
|
||||
group.add(rightArm);
|
||||
|
||||
// Position behind the desk
|
||||
group.position.set(0, 0, -0.8);
|
||||
|
||||
// Animation state
|
||||
let elapsed = 0;
|
||||
|
||||
function update(dt) {
|
||||
elapsed += dt;
|
||||
// Breathing: subtle Y-scale oscillation
|
||||
const breath = 1.0 + Math.sin(elapsed * 1.5) * 0.015;
|
||||
robe.scale.y = breath;
|
||||
// Head tilt
|
||||
head.rotation.z = Math.sin(elapsed * 0.7) * 0.05;
|
||||
head.rotation.x = Math.sin(elapsed * 0.5) * 0.03;
|
||||
}
|
||||
|
||||
return { group, update };
|
||||
}
|
||||
Reference in New Issue
Block a user