Lucy v2 (MateEngine-Verhalten): Blickfolgen + Streicheln + Ziehen
- Mauszeiger-Verfolgung: Main pollt globalen Cursor (screen.getCursorScreenPoint), Renderer richtet Augen (lookAt-Offset in Kamera-Achsen) + dezent Kopf/Nacken danach aus - Antippen/Streicheln: Klick auf den Avatar -> kurze freudige Reaktion (Laecheln + Kopf-Wackeln) - Ziehen: im Overlay ist der Avatar eine Drag-Region (am Koerper greifen -> Fenster verschieben), Kamera-Drehung (OrbitControls) im Overlay aus; Blasen/Knoepfe bleiben no-drag - neue IPC-Events: lucy:cursor (+ preload onCursor) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ export default function App() {
|
||||
inputMode, setInputMode, vadListening, lookAtScreen } = useVoiceAgent()
|
||||
const holding = useRef(false)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const cursor = useRef({ x: 0, y: 0 }) // Mauszeiger relativ zu Lucys Kopf (Blickfolgen)
|
||||
const pat = useRef(0) // Zaehler: Antippen/Streicheln des Avatars
|
||||
const [mode, setMode] = useState<"full" | "overlay">("full")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [ghost, setGhost] = useState(false) // Geistmodus: Fenster durchklickbar (nur Overlay)
|
||||
@@ -49,6 +51,9 @@ export default function App() {
|
||||
}, [ghost, mode])
|
||||
useEffect(() => { if (mode !== "overlay") setGhost(false) }, [mode]) // Geistmodus nur im Overlay
|
||||
|
||||
// Mauszeiger-Verfolgung (MateEngine-Idee): Position vom Main-Prozess in den Avatar-Blick fuettern
|
||||
useEffect(() => window.lucy?.onCursor((p) => { cursor.current = p }), [])
|
||||
|
||||
// Leertaste = Push-to-talk (halten)
|
||||
useEffect(() => {
|
||||
const isField = (el: EventTarget | null) =>
|
||||
@@ -126,10 +131,11 @@ export default function App() {
|
||||
|
||||
<div className="body">
|
||||
<div className="stage">
|
||||
<div className="avatarWrap">
|
||||
<div className="avatarWrap" onClick={() => { pat.current += 1 }}
|
||||
title={overlay ? "Ziehen zum Verschieben · Antippen zum Streicheln" : "Antippen zum Streicheln"}>
|
||||
<div className="digitalRoom"><div className="dust" /></div>
|
||||
<AuraGlow status={status} audioLevel={audioLevel} />
|
||||
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} status={status} />
|
||||
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} status={status} cursor={cursor} pat={pat} controls={!overlay} />
|
||||
{speaking && lastAssistant && <div className="subtitle">{lastAssistant}</div>}
|
||||
{!ready && (
|
||||
<div className="warmOverlay">
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { Emotion } from "../lib/voice/sentiment"
|
||||
|
||||
type LevelRef = MutableRefObject<{ current: number; aa?: number; ih?: number; ou?: number }>
|
||||
type EmotionRef = MutableRefObject<Emotion>
|
||||
type CursorRef = MutableRefObject<{ x: number; y: number }>
|
||||
type NumRef = MutableRefObject<number>
|
||||
|
||||
// Welche Clips geladen werden (Dateien unter public/vrma/, MIT-Lizenz, siehe ATTRIBUTION.md).
|
||||
// Die .vrma sind GESTEN (kein echter Ruhe-Loop) -> nur fuer klare Zustaende/Einschuebe nutzen,
|
||||
@@ -75,8 +77,9 @@ const EMO_TO_EXPR: Record<Emotion, string | null> = {
|
||||
neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed",
|
||||
}
|
||||
|
||||
function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
url: string; audioLevel: LevelRef; emotion: EmotionRef; status: MutableRefObject<string>; onError: (m: string) => void
|
||||
function VrmModel({ url, audioLevel, emotion, status, cursor, pat, onError }: {
|
||||
url: string; audioLevel: LevelRef; emotion: EmotionRef; status: MutableRefObject<string>
|
||||
cursor?: CursorRef; pat?: NumRef; onError: (m: string) => void
|
||||
}) {
|
||||
const [vrm, setVrm] = useState<VRM | null>(null)
|
||||
const smooth = useRef<Record<string, number>>({})
|
||||
@@ -86,6 +89,10 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
const gaze = useRef(new Object3D()) // weit entfernter Blickpunkt (gegen Schielen bei naher Kamera)
|
||||
const headPos = useRef(new Vector3())
|
||||
const camPos = useRef(new Vector3())
|
||||
const camRight = useRef(new Vector3())
|
||||
const camUp = useRef(new Vector3())
|
||||
const patReact = useRef(0) // 0..1 Reaktion aufs Antippen/Streicheln
|
||||
const lastPat = useRef(0)
|
||||
// Bewegungs-Clips
|
||||
const mixer = useRef<AnimationMixer | null>(null)
|
||||
const actions = useRef<Record<string, AnimationAction>>({})
|
||||
@@ -182,6 +189,17 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
} else {
|
||||
applyIdleProcedural(vrm, tc, target, lean.current, spk) // ruhiger Leerlauf
|
||||
}
|
||||
// MateEngine-Idee: Kopf dreht sich dezent zum Mauszeiger (Augen folgen unten im lookAt-Block)
|
||||
const cur = cursor?.current
|
||||
if (cur) {
|
||||
const hx = Math.max(-1, Math.min(1, cur.x)), hy = Math.max(-1, Math.min(1, cur.y))
|
||||
addBone(vrm, "neck", hy * 0.06, hx * 0.10, 0)
|
||||
addBone(vrm, "head", hy * 0.08, hx * 0.14, 0)
|
||||
}
|
||||
// Antippen/Streicheln: kurze freudige Reaktion (Kopf-Wackeln; Laecheln in der Mimik unten)
|
||||
if (pat && pat.current !== lastPat.current) { lastPat.current = pat.current; patReact.current = 1 }
|
||||
patReact.current *= Math.max(0, 1 - delta * 1.4)
|
||||
if (patReact.current > 0.01) addBone(vrm, "head", -patReact.current * 0.05, 0, Math.sin(tc * 18) * patReact.current * 0.06)
|
||||
|
||||
// Blick: weit entfernter Punkt in Kamerarichtung -> Augen parallel (kein Schielen), wirkt "auf dich".
|
||||
if (vrm.lookAt) {
|
||||
@@ -190,6 +208,15 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
if (headNode) headNode.getWorldPosition(hp); else hp.set(0, 1.3, 0)
|
||||
const cp = camPos.current.copy(state.camera.position)
|
||||
gaze.current.position.copy(cp).sub(hp).multiplyScalar(4).add(hp)
|
||||
// Augen folgen dem Mauszeiger: Blickpunkt in Kamera-Rechts/Hoch-Richtung verschieben
|
||||
if (cur) {
|
||||
const e = state.camera.matrixWorld.elements
|
||||
camRight.current.set(e[0], e[1], e[2]).normalize()
|
||||
camUp.current.set(e[4], e[5], e[6]).normalize()
|
||||
const cx = Math.max(-1.5, Math.min(1.5, cur.x)), cy = Math.max(-1.5, Math.min(1.5, cur.y))
|
||||
gaze.current.position.addScaledVector(camRight.current, cx * 1.8)
|
||||
gaze.current.position.addScaledVector(camUp.current, -cy * 1.8)
|
||||
}
|
||||
// dezente Blick-Mikrobewegung (Sakkaden) -> das Gesicht wirkt nicht eingefroren
|
||||
const s = sac.current
|
||||
s.t += delta
|
||||
@@ -225,6 +252,8 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
const expr = brk.current.name === "Relax" ? "happy" : "relaxed"
|
||||
em.setValue(expr, Math.max(smooth.current[expr] ?? 0, brk.current.w * 0.35))
|
||||
}
|
||||
// Streichel-Reaktion: Laecheln
|
||||
if (patReact.current > 0.01) em.setValue("happy", Math.max(smooth.current.happy ?? 0, patReact.current * 0.85))
|
||||
const b = blink.current
|
||||
b.t += delta
|
||||
if (b.active <= 0 && b.t > b.next) { b.active = 0.16; b.t = 0; b.next = 3 + Math.random() * 4 }
|
||||
@@ -238,8 +267,9 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
||||
return vrm ? <primitive object={vrm.scene} /> : null
|
||||
}
|
||||
|
||||
export function Avatar3D({ url, audioLevel, emotion, status = "idle" }: {
|
||||
export function Avatar3D({ url, audioLevel, emotion, status = "idle", cursor, pat, controls = true }: {
|
||||
url: string; audioLevel: LevelRef; emotion: EmotionRef; status?: string
|
||||
cursor?: CursorRef; pat?: NumRef; controls?: boolean
|
||||
}) {
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const statusRef = useRef(status); statusRef.current = status
|
||||
@@ -249,9 +279,11 @@ export function Avatar3D({ url, audioLevel, emotion, status = "idle" }: {
|
||||
<ambientLight intensity={0.85} />
|
||||
<directionalLight position={[1, 2, 2]} intensity={1.1} />
|
||||
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
|
||||
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} status={statusRef} onError={setErr} />
|
||||
<OrbitControls target={[0, 1.3, 0]} enablePan={false} minDistance={0.7} maxDistance={3}
|
||||
minPolarAngle={Math.PI / 3} maxPolarAngle={Math.PI / 1.8} />
|
||||
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} status={statusRef} cursor={cursor} pat={pat} onError={setErr} />
|
||||
{controls && (
|
||||
<OrbitControls target={[0, 1.3, 0]} enablePan={false} minDistance={0.7} maxDistance={3}
|
||||
minPolarAngle={Math.PI / 3} maxPolarAngle={Math.PI / 1.8} />
|
||||
)}
|
||||
</Canvas>
|
||||
{err && (
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 12, margin: "0 auto", width: "fit-content",
|
||||
|
||||
@@ -28,6 +28,7 @@ declare global {
|
||||
togglePin: () => Promise<boolean>
|
||||
onMode: (cb: (m: "full" | "overlay") => void) => () => void
|
||||
onHotkey: (cb: () => void) => () => void
|
||||
onCursor: (cb: (p: { x: number; y: number }) => void) => () => void
|
||||
captureScreen: () => Promise<string[]>
|
||||
listWindows: () => Promise<{ id: string; name: string }[]>
|
||||
captureWindow: (id: string) => Promise<string | null>
|
||||
|
||||
@@ -43,6 +43,9 @@ body {
|
||||
}
|
||||
.app.overlay .stage { border: none; background: transparent; }
|
||||
.avatarWrap { flex: 1; min-height: 0; position: relative; overflow: hidden; border-radius: 14px; }
|
||||
/* Overlay: Lucy am Koerper greifen & ueber den Desktop ziehen (Bedienelemente bleiben no-drag) */
|
||||
.app.overlay .avatarWrap { -webkit-app-region: drag; }
|
||||
.app.overlay .overlayBubbles, .app.overlay .overlayBubbles * { -webkit-app-region: no-drag; }
|
||||
|
||||
/* --- digitaler Raum (animierter Sci-Fi-Backdrop, Etappe 2B) --- */
|
||||
.digitalRoom {
|
||||
|
||||
Reference in New Issue
Block a user