From e880350f621054c54cef9436d045f7b2e9cf3c22 Mon Sep 17 00:00:00 2001 From: Edom <463882408@qq.com> Date: Thu, 20 Aug 2026 00:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8A=A0=E4=B8=8A=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E8=B0=83=E8=8A=82=E4=B8=8E=E8=A1=A8=E6=83=85=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + replica/src/api.js | 4 + replica/src/character.js | 361 ++++++++++-- replica/src/eyes.js | 33 +- src/App.vue | 178 +++++- src/components/AvatarStage.vue | 165 ++++++ src/components/BotTile.vue | 6 +- src/components/Customizer.vue | 33 +- src/components/Hero.vue | 15 +- src/components/ManualCanvas.vue | 821 ++++++++++++++++++++++++++++ src/components/PlaybackBar.vue | 306 +++++++++++ src/components/PlaybackDetails.vue | 139 +++++ src/components/PlaybackIdentity.vue | 68 +++ src/components/PlaybackStep.vue | 90 +++ src/components/Timeline.vue | 14 +- src/components/TimelineTrack.vue | 1 - src/i18n/locales/en.ts | 38 ++ src/i18n/locales/fr.ts | 38 ++ src/i18n/locales/zh.ts | 38 ++ src/replica/boot.ts | 30 + src/replica/host.ts | 48 +- src/replica/motion.ts | 29 + src/styles.css | 371 ++++++++++++- src/ui/capture.ts | 10 +- src/ui/playback.ts | 25 + src/ui/pose/math.ts | 356 ++++++++++++ src/ui/pose/model.ts | 221 ++++++++ src/ui/pose/session.ts | 35 ++ src/ui/pose/space.ts | 99 ++++ src/ui/stockage.ts | 2 +- src/ui/useSnapSheet.ts | 104 ++++ 31 files changed, 3577 insertions(+), 104 deletions(-) create mode 100644 src/components/AvatarStage.vue create mode 100644 src/components/ManualCanvas.vue create mode 100644 src/components/PlaybackBar.vue create mode 100644 src/components/PlaybackDetails.vue create mode 100644 src/components/PlaybackIdentity.vue create mode 100644 src/components/PlaybackStep.vue create mode 100644 src/replica/motion.ts create mode 100644 src/ui/playback.ts create mode 100644 src/ui/pose/math.ts create mode 100644 src/ui/pose/model.ts create mode 100644 src/ui/pose/session.ts create mode 100644 src/ui/pose/space.ts create mode 100644 src/ui/useSnapSheet.ts diff --git a/.gitignore b/.gitignore index 774daba..4d27dee 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ replica/grok-blob-idle.svg /tmp/ snap-out/ +# Upstream reference checkout for editor/geometry comparison +bible-strong-avatar-lab/ + # Never commit unpacked app dumps, unpack scripts, or third-party marks extracted/ extract-asar.js diff --git a/replica/src/api.js b/replica/src/api.js index 8496760..326d653 100644 --- a/replica/src/api.js +++ b/replica/src/api.js @@ -42,6 +42,10 @@ setPaused: (v) => bot.setPaused(v), setMode: (m) => bot.setMode(m), setFollowPointer: (v) => bot.setFollowPointer(v), + setPose: (v) => bot.setPose(v), + setManualOffset: (v) => bot.setManualOffset(v), + setEyeTune: (v) => bot.setEyeTune(v), + setManualHold: (v) => bot.setManualHold(v), setEmphasis: (v) => bot.setEmphasis(v), spin: (n) => bot.spinOnce(n), bounce: () => bot.bounceOnce(), diff --git a/replica/src/character.js b/replica/src/character.js index 27234b2..7e269c9 100644 --- a/replica/src/character.js +++ b/replica/src/character.js @@ -30,6 +30,8 @@ notifying: { x: 7, y: -3 }, playful: { x: 10, y: -4 }, happy: { x: -6, y: -4 }, laughing: { x: 4, y: -4 }, listening: { x: 5, y: -2 }, scared: { x: 12, y: 2 }, }; + // Still-frame eyes. Shy playlist starts at idle 0; 24 is the look-down face. + const HOLD_EYE = { shy: 24 }; class GrokCharacter { constructor(svg, opts = {}) { @@ -46,13 +48,22 @@ this.eyeTopology = opts.eyeTopology ?? this.loginWrap; this.faceTune = opts.faceTune ?? (this.loginWrap ? FACE_TUNE : null); this.pose = { ...(this.loginWrap ? POSE : { turn: 0, tilt: 0, roll: 0, scale: 1 }), ...opts.pose }; + this.poseRest = { turn: this.pose.turn, tilt: this.pose.tilt, roll: this.pose.roll }; this.poseHome = opts.poseHome || (this.loginWrap ? POSE_HOME : { turn: 0, tilt: 0, roll: 0 }); + this.manualHold = false; + this.manualTx = 0; + this.manualTy = 0; + this.manualSpin = 0; + this.eyeTune = null; this.uniformEyes = opts.uniformEyes ?? (this.loginWrap ? UNIFORM_EYES : false); this.eyeScaleProp = opts.eyeScale ?? (this.loginWrap ? shapeEyeScale(this.shapeName) : 1); this.emphasis = !!opts.emphasis; this.followPointer = !!opts.followPointer; this.gazeTarget = opts.gazeTarget || null; this.frozen = opts.paused === "hold-pose"; + this.holdAt = 900; + this.clockHold = 0; + this.frozenAt = 0; this.driven = !!opts.driven; this.paused = !!opts.paused; this.reduceMotion = opts.reduceMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); @@ -84,6 +95,10 @@ this.eyeTo = 0; this.eyeStiffness = 7; this.eyeIdx = 0; + this.eyeHoldMs = 0; + this.eyeRemain = 0; + this.playlistHold = false; + this.eyePinned = false; this._fromPolys = null; this.t0 = performance.now(); @@ -134,6 +149,19 @@ this.setColor(this.colorId, this.scheme); this._applyPoseScale(); this.setState(this.state, { resetEyes: true }); + this.eyePinned = Number.isFinite(opts.eyeIndex) && !!(this.geo.eyes && this.geo.eyes[opts.eyeIndex]); + if (this.eyePinned) { + const id = opts.eyeIndex; + this.eyeFrom = id; + this.eyeTo = id; + this.eyeMorph.x = 1; + this.eyeMorph.t = 1; + this.eyeMorph.v = 0; + this._fromPolys = null; + const list = this.playlists[this.state] || []; + const slot = list.indexOf(id); + this.eyeIdx = slot >= 0 ? slot : 0; + } if (!this.driven) this._bindPointer(); if (this.frozen) { this.holdFrame(); @@ -188,6 +216,10 @@ this.holdFrame(); return; } + if (!v && this.frozen && this.frozenAt) { + this.clockHold += performance.now() - this.frozenAt; + } + this.frozenAt = 0; this.frozen = false; this.paused = !!v; if (!this.paused) this._armTick(); @@ -195,12 +227,16 @@ _armTick() { if (this.frozen || this.driven || this._raf) return; - this.last = performance.now(); - this._raf = requestAnimationFrame((t) => this._tick(t)); + this.last = this._clock(); + this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold)); } _wake() { - if (this.frozen || this.driven) return; + if (this.driven) return; + if (this.frozen) { + this._paint(this.last || this.t0); + return; + } if (this.paused) this._armTick(); } @@ -220,6 +256,111 @@ } } + setPose(next) { + if (!next) return; + if (next.turn != null) this.pose.turn = next.turn; + if (next.tilt != null) this.pose.tilt = next.tilt; + if (next.roll != null) this.pose.roll = next.roll; + this._wake(); + } + + setManualOffset(next) { + if (!next) return; + if (next.tx != null) this.manualTx = next.tx; + if (next.ty != null) this.manualTy = next.ty; + if (next.spin != null) this.manualSpin = next.spin; + this._wake(); + } + + setEyeTune(tune) { + this.eyeTune = tune || null; + this._wake(); + } + + _clearFx() { + this.trick = null; + this.spinTurn = null; + this.gazeOrbit = null; + this.gazeSpin = 0; + this.hopAt = -1; + this.wildWide = false; + this.ovKind = null; + this.ovPrev = null; + this.ovTarget = null; + this.ovOn = false; + this.ovRest = false; + this.ovSpin = 0; + snapSpring(this.overlay, 0); + snapSpring(this.overlayMix, 1); + snapSpring(this.notify, 0); + snapSpring(this.humDots, 0); + this.particles?.clear(); + this.fx?.resetInk(); + this.fx?.resetRecv(); + this.fx?.hideAll(); + if (this.group) this.group.style.opacity = "1"; + } + + _snapBodyRest() { + snapSpring(this.spin, 0); + snapSpring(this.tx, 0); + snapSpring(this.ty, 0); + snapSpring(this.squash, 1); + snapSpring(this.eyeScale, 1); + this.extras = { ...this.extras, turn: null, Kr: 0, yi: 0, ki: 0, Yr: 0, Zr: 0, wi: 0, hop: 0 }; + } + + flushPerformance() { + this._clearFx(); + this._snapBodyRest(); + this._wake(); + } + + setManualHold(on) { + const next = !!on; + if (next === this.manualHold) { + this._wake(); + return; + } + this.manualHold = next; + if (next) { + this.trickAt = Infinity; + this.gazeUntil = Infinity; + snapSpring(this.gazeX, 0); + snapSpring(this.gazeY, 0); + this.pointer.x = 0; + this.pointer.y = 0; + this.pointer.tx = 0; + this.pointer.ty = 0; + this.ctx.wantPn = null; + this.ctx.wantBurst = null; + this.ctx.tyKick = 0; + this.ctx.spinKick = 0; + this.ctx.forceSleepEye = false; + this.ctx.wakeEye = null; + this.setFollowPointer(false); + this._clearFx(); + this._snapBodyRest(); + } else { + this.manualTx = 0; + this.manualTy = 0; + this.manualSpin = 0; + this.eyeTune = null; + this.ctx = this._freshCtx(this._clock()); + this.pose.turn = this.poseRest.turn; + this.pose.tilt = this.poseRest.tilt; + this.pose.roll = this.poseRest.roll; + this.gazeUntil = this._clock(); + this.blinkUntil = this._clock() + rand(1500, 7000); + this.trickAt = this._clock() + rand(4000, 8000); + } + this._wake(); + } + + parts() { + return { svg: this.svg, body: this.body, eyes: this.eyeEls, group: this.group }; + } + setGazeTarget(pt) { this.gazeTarget = pt; } @@ -277,7 +418,47 @@ } _clock() { - return this.driven ? this.last : performance.now(); + if (this.driven) return this.last; + return performance.now() - this.clockHold; + } + + // settle snaps t = x (stop); pause keeps the current targets. + freezeNow({ settle = false } = {}) { + const already = this.frozen; + this.frozen = true; + this.paused = true; + if (!already) this.frozenAt = performance.now(); + this.last = this._clock(); + if (this._raf) { + cancelAnimationFrame(this._raf); + this._raf = 0; + } + for (const s of [ + this.spin, + this.tx, + this.ty, + this.squash, + this.blink, + this.eyeScale, + this.gazeX, + this.gazeY, + this.eyeMorph, + this.overlay, + this.overlayMix, + this.notify, + this.humDots, + this.shapeSpring, + this.overlayTurn, + ]) { + if (settle) s.t = s.x; + s.v = 0; + } + this.ctx.wantPn = null; + this.ctx.wantBurst = null; + this.ctx.tyKick = 0; + this.ctx.spinKick = 0; + this._paint(this.last); + return this.snapshot(); } setState(name, { resetEyes = false } = {}) { @@ -302,7 +483,11 @@ } else if (name !== "sleeping" && name !== "waking") { this._morphEyes(list[0], name === "excited" ? 10 : 8); } - this.eyeUntil = this.stateAt + rand(...EYE_HOLD_MS[name]); + const hold = rand(...EYE_HOLD_MS[name]); + this.eyeHoldMs = hold; + this.eyeRemain = hold; + this.eyeUntil = this.stateAt + hold; + this.playlistHold = false; const blink = BLINK_MS[name]; this.blinkUntil = blink ? this.stateAt + rand(1500, 7000) : Infinity; this.gazeUntil = this.stateAt + rand(500, 1400); @@ -351,20 +536,69 @@ }; } + seekEye(index, { snap = false } = {}) { + const list = this.playlists[this.state]; + if (!list?.length) return; + const i = ((index % list.length) + list.length) % list.length; + this.eyeIdx = i; + if (snap) { + this.eyeFrom = list[i]; + this.eyeTo = list[i]; + this.eyeMorph.x = 1; + this.eyeMorph.t = 1; + this.eyeMorph.v = 0; + this._fromPolys = null; + } else { + this._morphEyes(list[i], this.state === "searching" || this.state === "excited" ? 10 : 6); + } + const hold = rand(...(EYE_HOLD_MS[this.state] || [2000, 3600])); + this.eyeHoldMs = hold; + this.eyeRemain = hold; + this.eyeUntil = this._clock() + hold; + this._wake(); + } + + setPlaylistHold(on) { + const next = !!on; + if (next === this.playlistHold) return; + if (next) this.eyeRemain = Math.max(0, this.eyeUntil - this._clock()); + else { + this.eyeUntil = this._clock() + (this.eyeRemain || 0); + this._wake(); + } + this.playlistHold = next; + } + + playback() { + const remain = this.playlistHold + ? (this.eyeRemain || 0) + : Math.max(0, this.eyeUntil - this._clock()); + return { + state: this.state, + eyeIdx: this.eyeIdx || 0, + holdMs: this.eyeHoldMs || 0, + remainMs: remain, + held: !!this.playlistHold, + frozen: !!this.frozen, + }; + } + spinOnce(turns = 1) { + if (this.manualHold) return; this._wake(); this._pn(turns); } bounceOnce() { + if (this.manualHold) return; this._wake(); this._hop(performance.now()); } burstOnce() { - if (!this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3); + if (!this.manualHold && !this.reduceMotion && !this.paused) this.particles.burst(22, 1.1, 0.3); } orbitGaze(ms = 1500) { - if (this.reduceMotion || this.frozen) return; + if (this.reduceMotion || this.frozen || this.manualHold) return; this.gazeOrbit = { at: performance.now(), dur: Math.max(ms, 1), from: Math.PI * 2 }; this._armTick(); } @@ -372,6 +606,7 @@ holdFrame(at = 900) { this.frozen = true; this.paused = true; + this.holdAt = at; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; @@ -409,6 +644,11 @@ const gz = HOLD_GAZE[this.state] || { x: 0, y: 0 }; snapSpring(this.gazeX, gz.x); snapSpring(this.gazeY, gz.y); + const holdEye = this.eyePinned ? null : HOLD_EYE[this.state]; + if (holdEye != null && this.geo.eyes[holdEye]) { + this.eyeFrom = holdEye; + this.eyeTo = holdEye; + } this.eyeMorph.x = 1; this.eyeMorph.v = 0; this.eyeMorph.t = 1; @@ -514,6 +754,19 @@ this.clipPath.setAttribute("d", geo.shapes[this.shapeName].path); } + _poseRing(restRing, R, spinAmt, morphing) { + // Sphere limb is rotation-invariant. Only solids-of-revolution change + // silhouette with yaw (same path as the spin trick). Tilt-squash was + // flattening the ball into a coin. + const yaw = this.manualHold ? ((this.pose.turn - this.poseRest.turn) * Math.PI) / 180 : 0; + const spinning = spinAmt != null; + const yawAmt = (spinAmt ?? 0) + yaw; + if (morphing || Math.abs(yawAmt) < 1e-5) return { ring: restRing, turned: false }; + const turnAt = FX.turnAtOf(this.shapeName, this.geo.shapes[this.shapeName].path, R, this.geo.solids); + if (!turnAt) return { ring: restRing, turned: false }; + return { ring: turnAt(yawAmt), turned: true }; + } + _morphEyes(index, stiffness = 7) { if (index === this.eyeTo && this.eyeMorph.t === 1) return; const t = clamp(this.eyeMorph.x, 0, 1); @@ -544,7 +797,7 @@ } _cycleShapeTrick() { - if (this.reduceMotion || this.paused || this.driven) return; + if (this.reduceMotion || this.paused || this.driven || this.manualHold) return; this.trickCycle = (this.trickCycle + 1) % 5; this.wildWide = false; if (this.trickCycle === 0) this._pn(1); @@ -654,16 +907,21 @@ prev: this.poseOut, }); this.poseOut = pose; - this.spin.t = pose.spin; - this.tx.t = pose.tx; - this.ty.t = pose.ty; - this.squash.t = pose.squash; - this.eyeScale.t = pose.eyeBoost; - if (this.ctx.tyKick) { + this.spin.t = this.manualHold ? 0 : pose.spin; + this.tx.t = this.manualHold ? 0 : pose.tx; + this.ty.t = this.manualHold ? 0 : pose.ty; + this.squash.t = this.manualHold ? 1 : pose.squash; + this.eyeScale.t = this.manualHold ? 1 : pose.eyeBoost; + if (this.manualHold) { + this.blink.t = 1; + this.gazeX.t = 0; + this.gazeY.t = 0; + } + if (!this.manualHold && this.ctx.tyKick) { this.ty.v += this.ctx.tyKick; this.ctx.tyKick = 0; } - if (this.ctx.spinKick) { + if (!this.manualHold && this.ctx.spinKick) { this.spin.v += this.ctx.spinKick; this.ctx.spinKick = 0; } @@ -684,24 +942,33 @@ EY.queueBlink(this.blinkQueue, now); this.ctx.wantBlink = false; } - if (this.ctx.wantPn) { + if (!this.manualHold && this.ctx.wantPn) { this._pn(...this.ctx.wantPn); this.ctx.wantPn = null; } - if (this.ctx.wantBurst) { + if (!this.manualHold && this.ctx.wantBurst) { this.particles.burst(this.ctx.wantBurst[0], this.ctx.wantBurst[1]); this.ctx.wakingBurst = true; this.ctx.wantBurst = null; } - this._stepOverlay(now); + if (this.manualHold) { + this.ovKind = null; + this.ovPrev = null; + this.ovTarget = null; + this.overlay.t = 0; + this.notify.t = 0; + this.humDots.t = 0; + } else { + this._stepOverlay(now); + } - if (this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) { + if (!this.manualHold && this.celebrateAt > 0 && now >= this.celebrateAt && !this.trick) { this.trick = TR.startTrick("spinWild", this.reduceMotion || this.paused); this.celebrateAt = now + 6200; } - if (!this.driven && now >= this.trickAt) { + if (!this.driven && !this.manualHold && now >= this.trickAt) { if ((V_T.has(this.state) || B_T.has(this.state)) && !this.spinTurn && this.hopAt < 0 && !this.trick) { const z = Math.random(); if (V_T.has(this.state)) { @@ -725,14 +992,17 @@ } this.extras = { ...tf, turn: tf.turn, hop }; - if (this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost; + if (!this.manualHold && this.extras.eyeBoost != null) this.eyeScale.t = this.extras.eyeBoost; - if (this.state !== "waking" && this.state !== "sleeping" && now >= this.eyeUntil) { + if (this.state !== "waking" && this.state !== "sleeping" && !this.playlistHold && now >= this.eyeUntil) { const list = this.playlists[this.state]; this.eyeIdx = (this.eyeIdx + 1 + Math.floor(rand(0, list.length - 1))) % list.length; const stiff = this.state === "searching" || this.state === "excited" ? 10 : 6; this._morphEyes(list[this.eyeIdx], stiff); - this.eyeUntil = now + rand(...EYE_HOLD_MS[this.state]); + const hold = rand(...EYE_HOLD_MS[this.state]); + this.eyeHoldMs = hold; + this.eyeRemain = hold; + this.eyeUntil = now + hold; } const blinkCadence = BLINK_MS[this.state]; @@ -741,16 +1011,18 @@ this.blinkUntil = now + rand(...blinkCadence); } const blinkKey = EY.consumeBlink(this.blinkQueue, now); - this.blink.t = blinkKey ?? (this.blinkQueue.length ? this.blink.t : (this.extras.lidMul ?? pose.lid)); + this.blink.t = this.paused && this.manualHold + ? 1 + : blinkKey ?? (this.blinkQueue.length ? this.blink.t : (this.extras.lidMul ?? pose.lid)); - if (now >= this.gazeUntil) { + if (!this.manualHold && now >= this.gazeUntil) { const gz = nextGaze(this.state); this.gazeX.t = gz.x; this.gazeY.t = gz.y; this.gazeUntil = now + rand(...gz.hold); } - if (WINK_STATES.has(this.state) && now >= this.winkUntil) { + if (!this.manualHold && WINK_STATES.has(this.state) && now >= this.winkUntil) { this.winkAt = now; this.winkEye = Math.random() < 0.5 ? 0 : 1; this.winkUntil = now + rand(4500, 10000); @@ -758,21 +1030,21 @@ this.emphasisBlend += ((this.emphasis ? 1 : 0) - this.emphasisBlend) * Rn(0.12); - if (this.emphasis) { + if (this.emphasis && !this.manualHold) { this.eyeScale.t = Math.max(this.eyeScale.t, 1.32); this.blink.t = Math.max(this.blink.t, 1.18); } const humming = this.state === "humming"; const loading = this.state === "loading"; - if ((humming || loading) && !this.reduceMotion) { + if ((humming || loading) && !this.reduceMotion && !this.manualHold) { const Zt = dtState; const dn = loading ? 3 : 1.6; const on = Zt < 0.5 ? 7 * K2(Zt / 0.5) : Zt < 1.3 ? 7 + (dn - 7) * K2((Zt - 0.5) / 0.8) : dn + 0.3 * Math.sin(Zt * 0.5); this.ovSpin += on * dt; } - if (this.reduceMotion) { + if (this.reduceMotion && !this.manualHold) { this._morphEyes(this.playlists[this.state][0]); this.spin.t = 0; this.tx.t = 0; this.ty.t = 0; this.squash.t = 1; this.blink.t = 1; this.eyeScale.t = 1; @@ -822,8 +1094,10 @@ } else { this.gazeSpin = 0; } - this.notify.t = this.state === "notifying" ? 1 : 0; - this.humDots.t = this.state === "humming" ? 1 : 0; + if (!this.manualHold) { + this.notify.t = this.state === "notifying" ? 1 : 0; + this.humDots.t = this.state === "humming" ? 1 : 0; + } let spinAngle = 0; if (this.spinTurn) spinAngle = this.spinTurn.x; @@ -840,8 +1114,8 @@ this.particles.update(now, dt, { spinAngle, sizeScale: this.partScale, - wideStyle: this.trick?.kind === "spinWild" || this.wildWide || humming, - sustainBelts: humming || loading, + wideStyle: !this.manualHold && (this.trick?.kind === "spinWild" || this.wildWide || humming), + sustainBelts: !this.manualHold && (humming || loading), }); this._updatePointer(now); @@ -887,7 +1161,7 @@ return; } - this._raf = requestAnimationFrame((t) => this._tick(t)); + this._raf = requestAnimationFrame((t) => this._tick(t - this.clockHold)); } _paint(now) { @@ -907,9 +1181,9 @@ const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix); const bodyW = 1 - yl; const ex = this.extras; - const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl; - const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl; - const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl; + const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl + this.manualTx; + const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl + this.manualTy; + const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl + this.manualSpin; const sx = bodyW + ov.wre * yl; const sy = this.squash.x * bodyW + ov.wre * yl; this.group.setAttribute( @@ -922,17 +1196,12 @@ const pencil = this.ovKind === "pencil" || this.ovPrev === "pencil"; const tear = geo.shapes.teardrop?.path; const spinAmt = ex.turn; - const spinning = spinAmt != null; const restRing = morphing ? FX.lerpRing(this.prevRing, FX.shapeRing(shape.path, R), morphK) : FX.shapeRing(shape.path, R); - let liveRing = restRing; - let turned = false; - const turnAt = !morphing && spinning ? FX.turnAtOf(this.shapeName, shape.path, R, geo.solids) : null; - if (turnAt) { - liveRing = turnAt(spinAmt); - turned = true; - } + const posed = this._poseRing(restRing, R, spinAmt, morphing); + const liveRing = posed.ring; + const turned = posed.turned; let faceTop = shape.top; let faceBottom = shape.bottom; if (morphing || turned) { @@ -1007,6 +1276,8 @@ top: faceTop, bottom: faceBottom, emphasisBlend: this.emphasisBlend, + tune: this.eyeTune, + manualHold: this.manualHold, }); const hum = clamp(this.humDots.x, 0, 1); diff --git a/replica/src/eyes.js b/replica/src/eyes.js index 1d1808c..9fb29ad 100644 --- a/replica/src/eyes.js +++ b/replica/src/eyes.js @@ -36,7 +36,7 @@ const { now, polys, morphT, shape, face, faceTune, uniformEyes, eyeScaleProp, blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX, - overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, + overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold, } = opt; const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI); const $i = { @@ -47,6 +47,8 @@ eye: face.eye * (faceTune?.size ?? 1), leftDX: face.leftDX ?? 0, }; + const tune = opt.tune || null; + if (tune) $i.sx *= clamp(tune.spacing ?? 35, 0, 150) / 35; const sX = uniformEyes ? $i.leftDX : 0; const cents = [centroid(polys[0]), centroid(polys[1])]; let a1 = 0, o1 = 0; @@ -57,8 +59,8 @@ const _ee = a1 + o1 > 0.5 ? clamp((l1 - pre) / (a1 + o1), 0.35, 4) : 4; const Uee = (uniformEyes ? 1 : $i.eye) * clamp(eyeScaleProp, 0.25, 4); const oX = Math.min(clamp(opt.eyeBoostX, 0.2, 2) * Uee, _ee / pulse); - const Hee = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse); - const u1 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3); + const Hee0 = Math.min(oX * clamp(faceTune?.eyeWidth ?? 1, 0.2, 3), _ee / pulse); + const u10 = oX * clamp(faceTune?.eyeHeight ?? 1, 0.2, 3); const liveSpan = ringHint ? (y) => spanPoly(ringHint, y, Re) : spanAt(shape.path, Re); @@ -76,10 +78,20 @@ : [Re, shape.top]; for (let i = 0; i < 2; i++) { + if (tune && manualHold) continue; const poly = polys[i]; const [Gn, Ti] = cents[i]; eyeEls[i].setAttribute("d", polyPath(poly)); const lid = winkLid(blinkX, now, winkAt, winkEye, i); + const sideTune = tune?.[i === 0 ? "left" : "right"]; + const tuneW = sideTune ? clamp(sideTune.width ?? 20, 10, 100) / 20 : 1; + const tuneH = sideTune ? clamp(sideTune.height ?? 50, 10, 100) / 50 : 1; + const tuneS = sideTune ? clamp(sideTune.size ?? 1, 0.35, 2.2) : 1; + const tuneA = sideTune ? (sideTune.angle ?? 0) * Math.PI / 180 : 0; + const tuneX = sideTune ? (sideTune.x ?? 0) : 0; + const tuneY = sideTune ? (sideTune.y ?? 0) : 0; + const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse); + const u1 = u10 * tuneH * tuneS; const Ea = Gn + (i === 0 ? sX : 0); let Ca = Re + $i.x; let Wo = (Ea - Re) * $i.sx; @@ -135,8 +147,8 @@ Tre = Dke(clamp(Io0 / 0.5, 0, 1)); } - let Kj = Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5; - let Ko = Math.sin(now * 58e-5 + i) * 0.9; + let Kj = manualHold ? 0 : Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5; + let Ko = manualHold ? 0 : Math.sin(now * 58e-5 + i) * 0.9; if (pointer) { const Zl = Rn(0.16); pointer.x += (pointer.tx - pointer.x) * Zl; @@ -170,10 +182,10 @@ if (Ia2 - Frp > O2) O2 = Ia2 - Frp; if (li2 - Frp < Xl) Xl = li2 - Frp; } - const xre = Ca + Wo + Kj * $i.sx; + const xre = Ca + Wo + Kj * $i.sx + tuneX; const lX = O2 <= Xl ? clamp(xre, O2, Xl) : (O2 + Xl) / 2; let dd = lX + (xre - lX) * (1 - Tre); - let Yj = vl; + let Yj = vl + tuneY; if (notifyX > 0.01) { const xr = 20 * clamp(notifyX, 0, 1.4); const Fr = dd - Yl[0], Ia = Yj - Yl[1]; @@ -188,18 +200,21 @@ } } + const rot = tuneA + ? ` rotate(${(tuneA * 180 / Math.PI).toFixed(2)})` + : ""; if (use3d) { const FrM = clamp((turn != null ? _c : 1) * Hee * pulse, 0.02, 2.4); const IaM = clamp(lid * u1 * pulse, 0.02, 2.4); const liM = km * FrM, blM = Ree * FrM, IoM = Fee * IaM, uoM = zee * IaM; eyeEls[i].setAttribute( "transform", - `translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})` + `translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} matrix(${liM.toFixed(4)} ${blM.toFixed(4)} ${IoM.toFixed(4)} ${uoM.toFixed(4)} 0 0) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})` ); } else { eyeEls[i].setAttribute( "transform", - `translate(${dd.toFixed(2)} ${Yj.toFixed(2)}) scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})` + `translate(${dd.toFixed(2)} ${Yj.toFixed(2)})${rot} scale(${Vee.toFixed(4)} ${_2.toFixed(4)}) translate(${(-Gn).toFixed(2)} ${(-Ti).toFixed(2)})` ); } } diff --git a/src/App.vue b/src/App.vue index b774cfa..52d23f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,12 @@ + + diff --git a/src/components/BotTile.vue b/src/components/BotTile.vue index b773877..04e61af 100644 --- a/src/components/BotTile.vue +++ b/src/components/BotTile.vue @@ -10,6 +10,7 @@ const props = withDefaults( shape?: string color?: string size?: number + eye?: number interactive?: boolean showLabel?: boolean active?: boolean @@ -29,7 +30,8 @@ async function paint() { color: props.color ?? 'black', follow: false, paper: '#f9f9f9', - size: props.size ?? 60 + size: props.size ?? 60, + eye: props.eye }) painted.value = !!mount } @@ -39,7 +41,7 @@ const dirty = ref(false) onMounted(() => { void paint() }) -watch(() => [props.state, props.shape, props.color], () => { +watch(() => [props.state, props.shape, props.color, props.eye], () => { if (!props.active) { dirty.value = true return diff --git a/src/components/Customizer.vue b/src/components/Customizer.vue index 51d6c12..892fcd6 100644 --- a/src/components/Customizer.vue +++ b/src/components/Customizer.vue @@ -3,20 +3,51 @@ import { computed } from 'vue' import BotTile from '@/components/BotTile.vue' import { shapeName, stateName, t } from '@/i18n' import { COLOR_IDS, EXPRESSION_IDS, colorFill, liveShapes } from '@/replica/catalog' +import { type ManualState } from '@/ui/pose/model' const props = defineProps<{ ready?: boolean; active?: boolean }>() const shape = defineModel('shape', { required: true }) const color = defineModel('color', { required: true }) const expression = defineModel('expression', { required: true }) +const manual = defineModel('manual', { required: true }) const shapes = computed(() => { void props.ready return liveShapes() }) + +function patch(next: Partial) { + manual.value = { ...manual.value, ...next } +}