Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
151 lines
4.5 KiB
JavaScript
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 };
|
|
}
|