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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user