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