feat: Workshop Phase 2 — Scene MVP with Three.js
Add the 3D Workshop scene: Timmy's room with wizard figure, Pip the familiar, fireplace lighting, crystal ball, and touch-friendly controls. All vanilla JS with Three.js from CDN — no npm dependencies. Fixes #361 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
55
static/world/controls.js
vendored
Normal file
55
static/world/controls.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* controls.js — Camera + touch controls for the Workshop scene.
|
||||
*/
|
||||
import { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
|
||||
|
||||
/**
|
||||
* Set up the camera and OrbitControls with iPad-friendly defaults.
|
||||
* Returns { camera, controls }.
|
||||
*/
|
||||
export function createControls(renderer) {
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
50,
|
||||
);
|
||||
// Visitor just walked in the door
|
||||
camera.position.set(0, 2.2, 4);
|
||||
camera.lookAt(0, 1.2, -3);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = false;
|
||||
|
||||
// Limit zoom range
|
||||
controls.minDistance = 2;
|
||||
controls.maxDistance = 8;
|
||||
|
||||
// Limit vertical rotation
|
||||
controls.minPolarAngle = Math.PI * 0.2;
|
||||
controls.maxPolarAngle = Math.PI * 0.55;
|
||||
|
||||
// Limit horizontal rotation
|
||||
controls.minAzimuthAngle = -Math.PI * 0.4;
|
||||
controls.maxAzimuthAngle = Math.PI * 0.4;
|
||||
|
||||
// Focus point — Timmy's desk area
|
||||
controls.target.set(0, 1.2, -3);
|
||||
|
||||
// Touch-friendly
|
||||
controls.touches = {
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||
};
|
||||
|
||||
// Resize handler
|
||||
window.addEventListener("resize", () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
return { camera, controls };
|
||||
}
|
||||
108
static/world/familiar.js
Normal file
108
static/world/familiar.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* familiar.js — Pip the familiar: a glowing emerald orb that wanders the room.
|
||||
*/
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
const CORE_COLOR = 0x00b450;
|
||||
const TRAIL_COLOR = 0xdaa520;
|
||||
|
||||
/**
|
||||
* Create Pip and return { group, update(dt) }.
|
||||
*/
|
||||
export function createFamiliar() {
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Core orb
|
||||
const coreGeo = new THREE.SphereGeometry(0.1, 16, 16);
|
||||
const coreMat = new THREE.MeshStandardMaterial({
|
||||
color: CORE_COLOR,
|
||||
emissive: CORE_COLOR,
|
||||
emissiveIntensity: 0.8,
|
||||
});
|
||||
const core = new THREE.Mesh(coreGeo, coreMat);
|
||||
group.add(core);
|
||||
|
||||
// Point light attached to Pip
|
||||
const glow = new THREE.PointLight(CORE_COLOR, 0.5, 4);
|
||||
group.add(glow);
|
||||
|
||||
// Gold particle trail — small spheres that follow
|
||||
const trailMat = new THREE.MeshBasicMaterial({
|
||||
color: TRAIL_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
const trailGeo = new THREE.SphereGeometry(0.025, 8, 8);
|
||||
const TRAIL_COUNT = 6;
|
||||
const trail = [];
|
||||
for (let i = 0; i < TRAIL_COUNT; i++) {
|
||||
const dot = new THREE.Mesh(trailGeo, trailMat.clone());
|
||||
dot.visible = true;
|
||||
group.add(dot);
|
||||
trail.push(dot);
|
||||
}
|
||||
|
||||
// Wander state
|
||||
let _elapsed = 0;
|
||||
let _target = _randomTarget();
|
||||
let _speed = 0.6;
|
||||
let _pauseTimer = 0;
|
||||
|
||||
// Start position
|
||||
group.position.set(2, 2, -2);
|
||||
|
||||
function _randomTarget() {
|
||||
return new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 8,
|
||||
1.2 + Math.random() * 2.5,
|
||||
-1 - Math.random() * 4,
|
||||
);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
_elapsed += dt;
|
||||
|
||||
// Pause occasionally near Timmy
|
||||
if (_pauseTimer > 0) {
|
||||
_pauseTimer -= dt;
|
||||
// Bob gently while paused
|
||||
group.position.y += Math.sin(_elapsed * 3) * 0.002;
|
||||
_updateTrail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toward target
|
||||
const dir = _target.clone().sub(group.position);
|
||||
const dist = dir.length();
|
||||
if (dist < 0.3) {
|
||||
_target = _randomTarget();
|
||||
// 30% chance to pause near Timmy
|
||||
if (Math.random() < 0.3) {
|
||||
_pauseTimer = 1.5 + Math.random() * 2;
|
||||
}
|
||||
} else {
|
||||
dir.normalize();
|
||||
group.position.addScaledVector(dir, _speed * dt);
|
||||
}
|
||||
|
||||
// Gentle bobbing
|
||||
group.position.y += Math.sin(_elapsed * 2.5) * 0.003;
|
||||
|
||||
// Pulse the glow
|
||||
coreMat.emissiveIntensity = 0.6 + Math.sin(_elapsed * 4) * 0.2;
|
||||
glow.intensity = 0.4 + Math.sin(_elapsed * 4) * 0.15;
|
||||
|
||||
_updateTrail();
|
||||
}
|
||||
|
||||
function _updateTrail() {
|
||||
for (let i = trail.length - 1; i > 0; i--) {
|
||||
trail[i].position.lerp(trail[i - 1].position, 0.3);
|
||||
trail[i].material.opacity = 0.4 * (1 - i / trail.length);
|
||||
}
|
||||
// First trail dot follows core with slight lag
|
||||
trail[0].position.set(0, 0, 0);
|
||||
}
|
||||
|
||||
return { group, update };
|
||||
}
|
||||
121
static/world/index.html
Normal file
121
static/world/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!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>
|
||||
<!-- Status overlay -->
|
||||
<div id="status-overlay">
|
||||
<div class="name">Timmy</div>
|
||||
<div class="mood" id="mood-text">focused</div>
|
||||
</div>
|
||||
|
||||
<!-- Speech bubble area -->
|
||||
<div id="speech-area">
|
||||
<div id="speech-bubble"></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 { OrbitControls } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js";
|
||||
import { createRoom, createLighting } from "./scene.js";
|
||||
import { createWizard } from "./wizard.js";
|
||||
import { createFamiliar } from "./familiar.js";
|
||||
import { initState, getState, onStateChange } from "./state.js";
|
||||
|
||||
// ---- Renderer ----
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.9;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
// ---- Scene ----
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a14);
|
||||
scene.fog = new THREE.Fog(0x0a0a14, 8, 16);
|
||||
|
||||
// ---- Camera + Controls ----
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
55,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
50,
|
||||
);
|
||||
camera.position.set(0, 2.2, 4);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = 2;
|
||||
controls.maxDistance = 8;
|
||||
controls.minPolarAngle = Math.PI * 0.2;
|
||||
controls.maxPolarAngle = Math.PI * 0.55;
|
||||
controls.minAzimuthAngle = -Math.PI * 0.4;
|
||||
controls.maxAzimuthAngle = Math.PI * 0.4;
|
||||
controls.target.set(0, 1.2, -3);
|
||||
controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
|
||||
|
||||
// ---- Build scene ----
|
||||
const room = createRoom();
|
||||
scene.add(room);
|
||||
createLighting(scene);
|
||||
|
||||
const wizard = createWizard();
|
||||
scene.add(wizard.group);
|
||||
|
||||
const familiar = createFamiliar();
|
||||
scene.add(familiar.group);
|
||||
|
||||
// ---- State ----
|
||||
const moodEl = document.getElementById("mood-text");
|
||||
const speechBubble = document.getElementById("speech-bubble");
|
||||
|
||||
onStateChange((state) => {
|
||||
if (state.timmyState) {
|
||||
moodEl.textContent = state.timmyState.mood || "idle";
|
||||
}
|
||||
});
|
||||
|
||||
initState();
|
||||
|
||||
// ---- Resize ----
|
||||
window.addEventListener("resize", () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// ---- Render loop ----
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
const dt = clock.getDelta();
|
||||
|
||||
wizard.update(dt);
|
||||
familiar.update(dt);
|
||||
controls.update();
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
animate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
163
static/world/scene.js
Normal file
163
static/world/scene.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* scene.js — Room geometry, lighting, and materials for Timmy's Workshop.
|
||||
*/
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
/**
|
||||
* Build the Workshop room and return the group.
|
||||
*/
|
||||
export function createRoom() {
|
||||
const room = new THREE.Group();
|
||||
|
||||
// Floor — dark stone
|
||||
const floorGeo = new THREE.PlaneGeometry(12, 12);
|
||||
const floorMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x1a1a2e,
|
||||
roughness: 0.9,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
room.add(floor);
|
||||
|
||||
// Back wall
|
||||
const wallGeo = new THREE.PlaneGeometry(12, 6);
|
||||
const wallMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x1a1a2e,
|
||||
roughness: 0.95,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const backWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
backWall.position.set(0, 3, -6);
|
||||
backWall.receiveShadow = true;
|
||||
room.add(backWall);
|
||||
|
||||
// Left wall
|
||||
const leftWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
leftWall.position.set(-6, 3, 0);
|
||||
leftWall.rotation.y = Math.PI / 2;
|
||||
leftWall.receiveShadow = true;
|
||||
room.add(leftWall);
|
||||
|
||||
// Right wall
|
||||
const rightWall = new THREE.Mesh(wallGeo, wallMat);
|
||||
rightWall.position.set(6, 3, 0);
|
||||
rightWall.rotation.y = -Math.PI / 2;
|
||||
rightWall.receiveShadow = true;
|
||||
room.add(rightWall);
|
||||
|
||||
// Desk
|
||||
const desk = _createDesk();
|
||||
desk.position.set(0, 0, -3);
|
||||
room.add(desk);
|
||||
|
||||
// Crystal ball on desk
|
||||
const crystal = _createCrystalBall();
|
||||
crystal.position.set(0.8, 1.15, -3);
|
||||
room.add(crystal);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
function _createDesk() {
|
||||
const desk = new THREE.Group();
|
||||
const woodColor = 0x3e2723;
|
||||
|
||||
// Tabletop
|
||||
const topGeo = new THREE.BoxGeometry(3, 0.1, 1.5);
|
||||
const woodMat = new THREE.MeshStandardMaterial({
|
||||
color: woodColor,
|
||||
roughness: 0.8,
|
||||
});
|
||||
const top = new THREE.Mesh(topGeo, woodMat);
|
||||
top.position.y = 1.0;
|
||||
top.castShadow = true;
|
||||
top.receiveShadow = true;
|
||||
desk.add(top);
|
||||
|
||||
// Four legs
|
||||
const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 1.0, 8);
|
||||
const offsets = [
|
||||
[-1.3, -0.6],
|
||||
[1.3, -0.6],
|
||||
[-1.3, 0.6],
|
||||
[1.3, 0.6],
|
||||
];
|
||||
offsets.forEach(([x, z]) => {
|
||||
const leg = new THREE.Mesh(legGeo, woodMat);
|
||||
leg.position.set(x, 0.5, z);
|
||||
leg.castShadow = true;
|
||||
desk.add(leg);
|
||||
});
|
||||
|
||||
// Scattered papers (thin boxes on the desk surface)
|
||||
const paperMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd4c5a9,
|
||||
roughness: 0.6,
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const paperGeo = new THREE.BoxGeometry(
|
||||
0.3 + Math.random() * 0.3,
|
||||
0.005,
|
||||
0.4 + Math.random() * 0.2,
|
||||
);
|
||||
const paper = new THREE.Mesh(paperGeo, paperMat);
|
||||
paper.position.set(-0.5 + i * 0.5, 1.06, 0.1 * (i - 1));
|
||||
paper.rotation.y = (Math.random() - 0.5) * 0.4;
|
||||
desk.add(paper);
|
||||
}
|
||||
|
||||
return desk;
|
||||
}
|
||||
|
||||
function _createCrystalBall() {
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Base
|
||||
const baseGeo = new THREE.CylinderGeometry(0.15, 0.2, 0.08, 16);
|
||||
const baseMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x555555,
|
||||
metalness: 0.6,
|
||||
roughness: 0.3,
|
||||
});
|
||||
const base = new THREE.Mesh(baseGeo, baseMat);
|
||||
group.add(base);
|
||||
|
||||
// Glass sphere
|
||||
const sphereGeo = new THREE.SphereGeometry(0.18, 24, 24);
|
||||
const sphereMat = new THREE.MeshPhysicalMaterial({
|
||||
color: 0x88ccff,
|
||||
transmission: 0.8,
|
||||
roughness: 0.05,
|
||||
metalness: 0.0,
|
||||
thickness: 0.3,
|
||||
emissive: 0x004488,
|
||||
emissiveIntensity: 0.3,
|
||||
});
|
||||
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
||||
sphere.position.y = 0.2;
|
||||
group.add(sphere);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all scene lighting.
|
||||
*/
|
||||
export function createLighting(scene) {
|
||||
// Emerald ambient
|
||||
const ambient = new THREE.AmbientLight(0x00b450, 0.15);
|
||||
scene.add(ambient);
|
||||
|
||||
// Fireplace warm light (stage left, off-screen)
|
||||
const fireLight = new THREE.PointLight(0xff6633, 1.2, 15);
|
||||
fireLight.position.set(-7, 3, -1);
|
||||
fireLight.castShadow = true;
|
||||
fireLight.shadow.mapSize.set(512, 512);
|
||||
scene.add(fireLight);
|
||||
|
||||
// Soft fill from above
|
||||
const fillLight = new THREE.PointLight(0xffeedd, 0.3, 12);
|
||||
fillLight.position.set(0, 5, 0);
|
||||
scene.add(fillLight);
|
||||
}
|
||||
47
static/world/state.js
Normal file
47
static/world/state.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* state.js — State reader for Timmy's Workshop.
|
||||
*
|
||||
* Phase 2: hardcoded JSON fallback.
|
||||
* Phase 3 will upgrade to WebSocket via /api/world/ws.
|
||||
*/
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
timmyState: {
|
||||
mood: "focused",
|
||||
activity: "thinking",
|
||||
energy: 0.7,
|
||||
confidence: 0.8,
|
||||
},
|
||||
visitorPresent: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let _current = { ...DEFAULT_STATE };
|
||||
let _listeners = [];
|
||||
|
||||
export function getState() {
|
||||
return _current;
|
||||
}
|
||||
|
||||
export function onStateChange(fn) {
|
||||
_listeners.push(fn);
|
||||
}
|
||||
|
||||
function _notify() {
|
||||
_listeners.forEach((fn) => fn(_current));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch initial state from the API, fall back to defaults.
|
||||
*/
|
||||
export async function initState() {
|
||||
try {
|
||||
const res = await fetch("/api/world/state");
|
||||
if (res.ok) {
|
||||
_current = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// Graceful degradation — use defaults
|
||||
}
|
||||
_notify();
|
||||
}
|
||||
70
static/world/style.css
Normal file
70
static/world/style.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Workshop overlay styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #0a0a14;
|
||||
font-family: "Courier New", monospace;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Status overlay — top-left */
|
||||
#status-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#status-overlay .name {
|
||||
font-size: 14px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: #daa520;
|
||||
text-shadow: 0 0 8px rgba(218, 165, 32, 0.4);
|
||||
}
|
||||
|
||||
#status-overlay .mood {
|
||||
font-size: 12px;
|
||||
color: #00b450;
|
||||
margin-top: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Speech area — bottom-center */
|
||||
#speech-area {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#speech-bubble {
|
||||
background: rgba(10, 10, 20, 0.85);
|
||||
border: 1px solid rgba(218, 165, 32, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #e0e0e0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
#speech-bubble.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
86
static/world/wizard.js
Normal file
86
static/world/wizard.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* wizard.js — Timmy's character model built from primitives + idle animations.
|
||||
*/
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
|
||||
|
||||
const ROBE_COLOR = 0x2d1b4e;
|
||||
const GOLD_TRIM = 0xdaa520;
|
||||
|
||||
/**
|
||||
* Build the wizard figure and return { group, update(dt) }.
|
||||
*/
|
||||
export function createWizard() {
|
||||
const group = new THREE.Group();
|
||||
|
||||
const robeMat = new THREE.MeshStandardMaterial({
|
||||
color: ROBE_COLOR,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const goldMat = new THREE.MeshStandardMaterial({
|
||||
color: GOLD_TRIM,
|
||||
metalness: 0.5,
|
||||
roughness: 0.4,
|
||||
});
|
||||
const skinMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xd4a574,
|
||||
roughness: 0.6,
|
||||
});
|
||||
|
||||
// Body — cone
|
||||
const bodyGeo = new THREE.ConeGeometry(0.55, 1.6, 12);
|
||||
const body = new THREE.Mesh(bodyGeo, robeMat);
|
||||
body.position.y = 0.8;
|
||||
body.castShadow = true;
|
||||
group.add(body);
|
||||
|
||||
// Gold belt
|
||||
const beltGeo = new THREE.TorusGeometry(0.35, 0.03, 8, 24);
|
||||
const belt = new THREE.Mesh(beltGeo, goldMat);
|
||||
belt.position.y = 0.7;
|
||||
belt.rotation.x = Math.PI / 2;
|
||||
group.add(belt);
|
||||
|
||||
// Head
|
||||
const headGeo = new THREE.SphereGeometry(0.22, 16, 16);
|
||||
const head = new THREE.Mesh(headGeo, skinMat);
|
||||
head.position.y = 1.82;
|
||||
head.castShadow = true;
|
||||
group.add(head);
|
||||
|
||||
// Hood — larger cone behind head
|
||||
const hoodGeo = new THREE.ConeGeometry(0.35, 0.5, 12, 1, true);
|
||||
const hood = new THREE.Mesh(hoodGeo, robeMat);
|
||||
hood.position.set(0, 1.95, -0.05);
|
||||
hood.rotation.x = 0.15;
|
||||
group.add(hood);
|
||||
|
||||
// Arms — cylinders angled outward
|
||||
[-1, 1].forEach((side) => {
|
||||
const armGeo = new THREE.CylinderGeometry(0.08, 0.06, 0.7, 8);
|
||||
const arm = new THREE.Mesh(armGeo, robeMat);
|
||||
arm.position.set(side * 0.45, 1.1, 0.15);
|
||||
arm.rotation.z = side * 0.4;
|
||||
arm.castShadow = true;
|
||||
group.add(arm);
|
||||
});
|
||||
|
||||
// Position behind desk, facing visitor
|
||||
group.position.set(0, 0, -4.2);
|
||||
|
||||
// Animation state
|
||||
let _elapsed = 0;
|
||||
|
||||
function update(dt) {
|
||||
_elapsed += dt;
|
||||
|
||||
// Breathing — gentle Y scale oscillation
|
||||
const breathe = 1 + Math.sin(_elapsed * 1.5) * 0.01;
|
||||
group.scale.y = breathe;
|
||||
|
||||
// Occasional head tilt
|
||||
const tilt = Math.sin(_elapsed * 0.4) * 0.06;
|
||||
head.rotation.z = tilt;
|
||||
}
|
||||
|
||||
return { group, update };
|
||||
}
|
||||
Reference in New Issue
Block a user