Files
Timmy-time-dashboard/static/world/familiar.js
Kimi Agent ab3546ae4b
All checks were successful
Tests / lint (push) Successful in 4s
Tests / test (push) Successful in 1m1s
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>
2026-03-19 02:14:09 -04:00

151 lines
4.5 KiB
JavaScript

/**
* 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 };
}