diff --git a/replica/src/character.js b/replica/src/character.js index 7e269c9..6f4f24f 100644 --- a/replica/src/character.js +++ b/replica/src/character.js @@ -21,6 +21,7 @@ s.x = s.t = v; s.v = 0; }; + const springBusy = (s) => Math.abs(s.x - s.t) > 0.002 || Math.abs(s.v) > 0.04; const HOLD_GAZE = { curious: { x: 12, y: -6 }, sad: { x: 2, y: 8 }, shy: { x: 10, y: 7 }, @@ -51,6 +52,7 @@ 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.manualMix = spring(0); this.manualTx = 0; this.manualTy = 0; this.manualSpin = 0; @@ -222,7 +224,7 @@ this.frozenAt = 0; this.frozen = false; this.paused = !!v; - if (!this.paused) this._armTick(); + this._armTick(); } _armTick() { @@ -237,7 +239,7 @@ this._paint(this.last || this.t0); return; } - if (this.paused) this._armTick(); + this._armTick(); } step(dt) { @@ -316,20 +318,37 @@ this._wake(); } + _mix() { + return clamp(this.manualMix.x, 0, 1); + } + + _angLerp(from, to, t) { + let d = to - from; + while (d > 180) d -= 360; + while (d < -180) d += 360; + return from + d * t; + } + + _poseNow() { + const k = this._mix(); + return { + turn: this._angLerp(this.poseRest.turn, this.pose.turn, k), + tilt: this._angLerp(this.poseRest.tilt, this.pose.tilt, k), + roll: this._angLerp(this.poseRest.roll, this.pose.roll, k), + scale: this.pose.scale, + }; + } + setManualHold(on) { const next = !!on; - if (next === this.manualHold) { - this._wake(); - return; - } + const changed = next !== this.manualHold; this.manualHold = next; if (next) { + this.manualMix.t = 1; this.trickAt = Infinity; this.gazeUntil = Infinity; - snapSpring(this.gazeX, 0); - snapSpring(this.gazeY, 0); - this.pointer.x = 0; - this.pointer.y = 0; + this.gazeX.t = 0; + this.gazeY.t = 0; this.pointer.tx = 0; this.pointer.ty = 0; this.ctx.wantPn = null; @@ -339,20 +358,27 @@ this.ctx.forceSleepEye = false; this.ctx.wakeEye = null; this.setFollowPointer(false); - this._clearFx(); - this._snapBodyRest(); + this.trick = null; + this.spinTurn = null; + this.hopAt = -1; + this.wildWide = false; + this.ovKind = null; + this.ovPrev = null; + this.ovTarget = null; + this.ovOn = false; + this.overlay.t = 0; + this.notify.t = 0; + this.humDots.t = 0; + this.particles?.clear(); + this.fx?.hideAll(); } 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.manualMix.t = 0; + if (changed) { + this.ctx = this._freshCtx(this._clock()); + this.gazeUntil = this._clock(); + this.blinkUntil = this._clock() + rand(1500, 7000); + this.trickAt = this._clock() + rand(4000, 8000); + } } this._wake(); } @@ -449,6 +475,7 @@ this.humDots, this.shapeSpring, this.overlayTurn, + this.manualMix, ]) { if (settle) s.t = s.x; s.v = 0; @@ -580,6 +607,7 @@ remainMs: remain, held: !!this.playlistHold, frozen: !!this.frozen, + manualMix: this._mix(), }; } @@ -758,7 +786,7 @@ // 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 yaw = ((this.pose.turn - this.poseRest.turn) * Math.PI) / 180 * this._mix(); const spinning = spinAmt != null; const yawAmt = (spinAmt ?? 0) + yaw; if (morphing || Math.abs(yawAmt) < 1e-5) return { ring: restRing, turned: false }; @@ -1069,6 +1097,7 @@ stepSpring(this.overlayMix, ...SPRINGS.overlayMix, step); stepSpring(this.shapeSpring, ...SPRINGS.shape, step); stepSpring(this.overlayTurn, ...SPRINGS.overlayTurn, step); + stepSpring(this.manualMix, ...SPRINGS.manualMix, step); } if (this.reduceMotion) { this.overlayMix.x = 1; @@ -1132,6 +1161,11 @@ && !this.spinTurn && this.hopAt < 0 && !this.particles.hasLife() + && !springBusy(this.manualMix) + && !springBusy(this.spin) + && !springBusy(this.tx) + && !springBusy(this.ty) + && !springBusy(this.squash) && Math.abs(this.overlay.x - this.overlay.t) < 0.001 && Math.abs(this.overlayTurn.t - this.overlayTurn.x) < 0.01 && this.overlayMix.x > 0.996 @@ -1148,15 +1182,17 @@ this.shapeSpring.v = 0; this.blinkQueue = []; this.winkAt = -1e9; - const home = this.playlists[this.state]?.[0] ?? 0; - this.eyeFrom = home; - this.eyeTo = home; - this._fromPolys = null; - this.eyeMorph.x = 1; - this.eyeMorph.v = 0; - this.eyeMorph.t = 1; + if (!this.manualHold) { + const home = this.playlists[this.state]?.[0] ?? 0; + this.eyeFrom = home; + this.eyeTo = home; + this._fromPolys = null; + this.eyeMorph.x = 1; + this.eyeMorph.v = 0; + this.eyeMorph.t = 1; + } this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 1 }; - this._paint(0); + this._paint(this.last); this._raf = 0; return; } @@ -1181,9 +1217,10 @@ 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 + 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 holdMix = this._mix(); + const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl + this.manualTx * holdMix; + const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl + this.manualTy * holdMix; + const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl + this.manualSpin * holdMix; const sx = bodyW + ov.wre * yl; const sy = this.squash.x * bodyW + ov.wre * yl; this.group.setAttribute( @@ -1236,7 +1273,7 @@ const morphT = clamp(this.eyeMorph.x, 0, 1); const polys = this._currentPolys(morphT); - const cr = this.eyeTopology ? relRot(this.pose, this.poseHome) : null; + const cr = this.eyeTopology ? relRot(this._poseNow(), this.poseHome) : null; const overlayLive = yl > 0.001 || Math.abs(this.overlayTurn.t - this.overlayTurn.x) > 0.01; let cyl = overlayLive ? this.overlayTurn.x : null; if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn; @@ -1276,9 +1313,19 @@ top: faceTop, bottom: faceBottom, emphasisBlend: this.emphasisBlend, - tune: this.eyeTune, - manualHold: this.manualHold, + tune: null, + manualHold: false, + manualMix: 0, }); + if (holdMix > 0.002) { + EY.blendCustomEyes({ + eyeEls: this.eyeEls, + pose: this._poseNow(), + eyes: this.eyeTune, + svg: this.svg, + mix: holdMix, + }); + } const hum = clamp(this.humDots.x, 0, 1); if (hum > 0.01) { diff --git a/replica/src/eyes.js b/replica/src/eyes.js index 9fb29ad..d5eee95 100644 --- a/replica/src/eyes.js +++ b/replica/src/eyes.js @@ -38,6 +38,7 @@ blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX, overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold, } = opt; + const mix = clamp(opt.manualMix ?? (manualHold ? 1 : 0), 0, 1); const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI); const $i = { x: face.x, @@ -48,7 +49,7 @@ leftDX: face.leftDX ?? 0, }; const tune = opt.tune || null; - if (tune) $i.sx *= clamp(tune.spacing ?? 35, 0, 150) / 35; + if (tune) $i.sx *= 1 + (clamp(tune.spacing ?? 35, 0, 150) / 35 - 1) * mix; const sX = uniformEyes ? $i.leftDX : 0; const cents = [centroid(polys[0]), centroid(polys[1])]; let a1 = 0, o1 = 0; @@ -78,18 +79,17 @@ : [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 tuneW = sideTune ? 1 + (clamp(sideTune.width ?? 20, 10, 100) / 20 - 1) * mix : 1; + const tuneH = sideTune ? 1 + (clamp(sideTune.height ?? 50, 10, 100) / 50 - 1) * mix : 1; + const tuneS = sideTune ? 1 + (clamp(sideTune.size ?? 1, 0.35, 2.2) - 1) * mix : 1; + const tuneA = sideTune ? (sideTune.angle ?? 0) * Math.PI / 180 * mix : 0; + const tuneX = sideTune ? (sideTune.x ?? 0) * mix : 0; + const tuneY = sideTune ? (sideTune.y ?? 0) * mix : 0; const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse); const u1 = u10 * tuneH * tuneS; const Ea = Gn + (i === 0 ? sX : 0); @@ -147,8 +147,8 @@ Tre = Dke(clamp(Io0 / 0.5, 0, 1)); } - 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; + let Kj = (1 - mix) * (Math.sin(now * 42e-5 + i) * 1.4 + Math.sin(now * 0.001 + i * 2) * 0.5); + let Ko = (1 - mix) * Math.sin(now * 58e-5 + i) * 0.9; if (pointer) { const Zl = Rn(0.16); pointer.x += (pointer.tx - pointer.x) * Zl; @@ -232,5 +232,178 @@ } } - g.GROK_EYES = { queueBlink, consumeBlink, paintEyes, lerpPoly }; + const EYE_HOME = { turn: 17, tilt: -14, roll: 29 }; + const DEFAULT_EYE = { width: 20, height: 50, size: 1, angle: 0, x: 0, y: -7 }; + const DEFAULT_EYES = { left: DEFAULT_EYE, right: DEFAULT_EYE, spacing: 35 }; + const OVERLAY = 300; + const OVERLAY_HALF = OVERLAY / 2; + const FACE_R = 120; + const FOCAL = 620; + const ARC_N = 14; + + const quatMul = (a, b) => [ + a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3], + a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2], + a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1], + a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0], + ]; + const quatAxis = (axis, angle) => { + const h = angle / 2; + const s = Math.sin(h); + return [Math.cos(h), axis[0] * s, axis[1] * s, axis[2] * s]; + }; + const quatEuler = (tilt, turn, roll) => { + const d = Math.PI / 180; + return quatMul(quatMul(quatAxis([0, 0, 1], roll * d), quatAxis([1, 0, 0], tilt * d)), quatAxis([0, 1, 0], turn * d)); + }; + const quatRotate = (q, p) => { + const tx = 2 * (q[2] * p[2] - q[3] * p[1]); + const ty = 2 * (q[3] * p[0] - q[1] * p[2]); + const tz = 2 * (q[1] * p[1] - q[2] * p[0]); + return [ + p[0] + q[0] * tx + (q[2] * tz - q[3] * ty), + p[1] + q[0] * ty + (q[3] * tx - q[1] * tz), + p[2] + q[0] * tz + (q[1] * ty - q[2] * tx), + ]; + }; + const screenOf = (pose, home) => { + const h = quatEuler(home.tilt, home.turn, home.roll); + const vis = quatMul(quatEuler(pose.tilt, pose.turn, pose.roll), [h[0], -h[1], -h[2], -h[3]]); + return [vis[0], -vis[1], vis[2], -vis[3]]; + }; + + function roundedRect(width, height) { + const hw = width / 2; + const hh = height / 2; + const r = Math.min(hw, hh); + const pts = []; + const line = (a, b) => { + const n = Math.max(2, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / 1.5)); + for (let i = 0; i < n; i++) { + const t = i / n; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + }; + const arc = (cx, cy, start) => { + for (let i = 0; i < ARC_N; i++) { + const a = start + (i / ARC_N) * Math.PI / 2; + pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); + } + }; + line([-hw + r, -hh], [hw - r, -hh]); + arc(hw - r, -hh + r, -Math.PI / 2); + line([hw, -hh + r], [hw, hh - r]); + arc(hw - r, hh - r, 0); + line([hw - r, hh], [-hw + r, hh]); + arc(-hw + r, hh - r, Math.PI / 2); + line([-hw, hh - r], [-hw, -hh + r]); + arc(-hw + r, -hh + r, Math.PI); + return pts; + } + + function projectEye(pose, home, faceX, faceY, lx, ly, angle) { + const a = (angle || 0) * Math.PI / 180; + const c = Math.cos(a); + const s = Math.sin(a); + const x = faceX + lx * c - ly * s; + const y = faceY + lx * s + ly * c; + const lon = x / FACE_R; + const lat = y / FACE_R; + const cl = Math.cos(lat); + const rotated = quatRotate(screenOf(pose, home), [cl * Math.sin(lon), Math.sin(lat), cl * Math.cos(lon)]); + const depth = rotated[2] * FACE_R; + const sc = FOCAL / Math.max(FOCAL - depth, 0.0001); + return [rotated[0] * FACE_R * sc, rotated[1] * FACE_R * sc, rotated[2]]; + } + + function overlayToSvg(svg, x, y) { + const box = svg.viewBox?.baseVal; + const w = box?.width || OVERLAY; + const h = box?.height || OVERLAY; + const ox = box?.x || 0; + const oy = box?.y || 0; + return [ox + ((x + OVERLAY_HALF) / OVERLAY) * w, oy + ((y + OVERLAY_HALF) / OVERLAY) * h]; + } + + function resampleRing(pts, n) { + if (!pts?.length) return Array.from({ length: n }, () => [0, 0]); + const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]); + let total = 0; + const segs = pts.map((p, i) => { + const d = dist(p, pts[(i + 1) % pts.length]) || 1e-6; + total += d; + return d; + }); + const out = []; + let i = 0; + let acc = 0; + for (let k = 0; k < n; k++) { + const target = (k / n) * total; + while (acc + segs[i % segs.length] < target && i < pts.length * 2) { + acc += segs[i % segs.length]; + i++; + } + const a = pts[i % pts.length]; + const b = pts[(i + 1) % pts.length]; + const sl = segs[i % segs.length]; + const t = clamp((target - acc) / sl, 0, 1); + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + return out; + } + + function sampleEl(el, n) { + if (!el || typeof el.getTotalLength !== "function") return null; + let len = 0; + try { + len = el.getTotalLength(); + } catch { + return null; + } + const m = el.transform.baseVal.consolidate()?.matrix; + const pts = []; + for (let i = 0; i < n; i++) { + const p = el.getPointAtLength(len > 0 ? (i / n) * len : 0); + pts.push(m ? [m.a * p.x + m.c * p.y + m.e, m.b * p.x + m.d * p.y + m.f] : [p.x, p.y]); + } + return pts; + } + + function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME }) { + if (!svg || !pose) return null; + const src = eyes || DEFAULT_EYES; + const sides = ["left", "right"]; + return sides.map((side) => { + const tune = { ...DEFAULT_EYE, ...(src[side] || {}) }; + const faceX = (side === "left" ? -1 : 1) * clamp(src.spacing ?? 35, 0, 150) / 2 + tune.x; + const faceY = tune.y; + const local = roundedRect(Math.max(tune.width, 1), Math.max(tune.height, 1)); + let z = 0; + const projected = local.map(([lx, ly]) => { + const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle); + z += p[2]; + return overlayToSvg(svg, p[0], p[1]); + }); + return { pts: resampleRing(projected, n), visible: z > 0 }; + }); + } + + function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48 }) { + const k = clamp(mix, 0, 1); + if (k <= 0.002 || !eyeEls) return; + const custom = customEyePolys({ pose, eyes, svg, n }); + if (!custom) return; + const gate = 0.22; + const shapeK = k >= gate ? 1 : Dke(k / gate); + for (let i = 0; i < 2; i++) { + const el = eyeEls[i]; + const expr = sampleEl(el, n); + if (!el || !expr || !custom[i]) continue; + el.setAttribute("d", polyPath(lerpPoly(custom[i].pts, expr, 1 - shapeK))); + el.removeAttribute("transform"); + if (shapeK > 0.55) el.style.display = custom[i].visible ? "" : "none"; + } + } + + g.GROK_EYES = { queueBlink, consumeBlink, paintEyes, lerpPoly, sampleEl, customEyePolys, blendCustomEyes }; })(window); diff --git a/replica/src/tables.js b/replica/src/tables.js index 617b2fd..2c38d29 100644 --- a/replica/src/tables.js +++ b/replica/src/tables.js @@ -72,6 +72,7 @@ shape: [10, 1], overlayTurn: [14, 1], spinTurn: [6.2, 1], + manualMix: [7, 1], }; const FACE_TUNE = { size: 0.86, gap: 1.18, height: 1, eyeWidth: 0.96, eyeHeight: 0.92 }; diff --git a/src/App.vue b/src/App.vue index 52d23f9..9626b4e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -80,6 +80,7 @@ const hero = ref<{ remainMs: number held: boolean frozen: boolean + manualMix: number } | null } | null>(null) const manual = ref(parseManual(lis('manuel'))) @@ -129,10 +130,11 @@ watch(expression, (id) => { ecris('expression', id) if (view.value !== 'animations') state.value = id if (view.value !== 'personnaliser') return - posePlay.value = true - poseStopped.value = false poseIdx.value = 0 poseElapsed.value = 0 + if (manual.value.on) return + posePlay.value = true + poseStopped.value = false }) watch( manual, @@ -188,11 +190,23 @@ function onSeek(t: number) { state.value = cycle.value.blocks[index]?.state ?? 'idle' } -const posePlay = ref(true) +const posePlay = ref(!manual.value.on) const poseStopped = ref(false) const poseIdx = ref(0) const poseElapsed = ref(0) const poseHold = ref(2) +const poseMix = ref(0) + +watch( + () => manual.value.on, + (on) => { + if (view.value !== 'personnaliser') return + if (on) { + posePlay.value = false + pausePose() + } + } +) function onSeekPose(index: number) { const frozen = !!hero.value?.playback()?.frozen @@ -205,24 +219,32 @@ function releasePoseFreeze() { hero.value?.setPlaylistHold(false) } -function applyPosePlayback() { - if (view.value !== 'personnaliser' || preview.value) return - if (posePlay.value) { - poseStopped.value = false - hero.value?.setPaused(false) - hero.value?.setPlaylistHold(false) +function playPose() { + poseStopped.value = false + hero.value?.setPaused(false) + hero.value?.setPlaylistHold(false) +} + +function pausePose(settle = false) { + hero.value?.setPlaylistHold(true) + if (manual.value.on) { + hero.value?.setPaused(true) return } hero.value?.seekEye(poseIdx.value, { snap: true }) - hero.value?.setPlaylistHold(true) - hero.value?.freezeNow({ settle: poseStopped.value }) + hero.value?.freezeNow({ settle }) +} + +function applyPosePlayback() { + if (view.value !== 'personnaliser' || preview.value) return + if (posePlay.value) playPose() + else pausePose(poseStopped.value) } function onStopPose() { poseStopped.value = true posePlay.value = false - hero.value?.setPlaylistHold(true) - hero.value?.freezeNow({ settle: true }) + if (view.value === 'personnaliser') pausePose(true) } const gardeAnim = ref(false) @@ -257,14 +279,8 @@ watch(preview, (on) => { watch(posePlay, (on) => { if (view.value !== 'personnaliser') return - if (on) { - poseStopped.value = false - hero.value?.setPaused(false) - hero.value?.setPlaylistHold(false) - return - } - hero.value?.setPlaylistHold(true) - hero.value?.freezeNow({ settle: poseStopped.value }) + if (on) playPose() + else pausePose(poseStopped.value) }) let raf = 0 @@ -279,6 +295,7 @@ function tick(ms: number) { poseIdx.value = snap.eyeIdx poseHold.value = snap.holdMs / 1000 poseElapsed.value = Math.max(0, (snap.holdMs - snap.remainMs) / 1000) + if (typeof snap.manualMix === 'number') poseMix.value = snap.manualMix } } if (!playing.value || (view.value !== 'animations' && !preview.value)) return @@ -527,6 +544,8 @@ onUnmounted(() => { :follow="follow" :manual="manual" :tools="view === 'personnaliser' && !preview" + :playing="posePlay" + :mix="poseMix" @ready="onHeroReady" @update:manual="manual = $event" /> diff --git a/src/components/AvatarStage.vue b/src/components/AvatarStage.vue index 2a32e9e..1dc8967 100644 --- a/src/components/AvatarStage.vue +++ b/src/components/AvatarStage.vue @@ -2,14 +2,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import Hero from '@/components/Hero.vue' import ManualCanvas, { type HeroHandle } from '@/components/ManualCanvas.vue' -import { - DEFAULT_EYE, - DEFAULT_EYES, - type EyeSide, - type EyeTune, - type ManualPart, - type ManualState -} from '@/ui/pose/model' +import { type EyeTune, type ManualPart, type ManualState } from '@/ui/pose/model' import type { HeadPose } from '@/ui/pose/math' const props = withDefaults( @@ -22,6 +15,8 @@ const props = withDefaults( paper?: string manual?: ManualState | null tools?: boolean + playing?: boolean + mix?: number }>(), { size: 440, @@ -31,7 +26,9 @@ const props = withDefaults( follow: false, paper: '#f9f9f9', manual: null, - tools: false + tools: false, + playing: false, + mix: 0 } ) @@ -42,7 +39,9 @@ const emit = defineEmits<{ const hero = ref | null>(null) const liveHero = computed(() => hero.value) -const active = computed(() => !!props.manual?.on && props.tools) +const active = computed( + () => !!props.manual?.on && props.tools && !props.playing && props.mix > 0.985 +) function patch(next: Partial) { if (!props.manual) return @@ -57,38 +56,10 @@ function applyEngine() { bot.setManualHold(false) return } - bot.setManualHold(true) bot.setPose(manual.pose) bot.setManualOffset(manual.offset) - bot.setEyeTune(engineEyes(manual.eyes)) -} - -function near(a: number, b: number) { - return Math.abs(a - b) < 1e-6 -} - -function sameSide(a: EyeSide, b: EyeSide) { - return ( - near(a.width, b.width) && - near(a.height, b.height) && - near(a.size, b.size) && - near(a.angle, b.angle) && - near(a.x, b.x) && - near(a.y, b.y) - ) -} - -function sameEyes(a: EyeTune, b: EyeTune) { - return sameSide(a.left, b.left) && sameSide(a.right, b.right) && near(a.spacing, b.spacing) -} - -function engineSide(side: EyeSide): EyeSide { - return { ...side, x: side.x - DEFAULT_EYE.x, y: side.y - DEFAULT_EYE.y } -} - -function engineEyes(eyes: EyeTune): EyeTune | null { - if (sameEyes(eyes, DEFAULT_EYES)) return null - return { left: engineSide(eyes.left), right: engineSide(eyes.right), spacing: eyes.spacing } + bot.setEyeTune(manual.eyes) + bot.setManualHold(!props.playing) } function clearSelection() { @@ -107,6 +78,7 @@ watch( props.manual?.offset, props.manual?.eyes, props.tools, + props.playing, hero.value ], () => applyEngine(), diff --git a/src/replica/boot.ts b/src/replica/boot.ts index d62cfb9..b0cca5e 100644 --- a/src/replica/boot.ts +++ b/src/replica/boot.ts @@ -41,6 +41,7 @@ export type ReplicaPlayback = { remainMs: number held: boolean frozen: boolean + manualMix: number } declare global {