feat:手动调节与表情播放弹簧衔接

打开手动、播放、暂停都走弹簧过渡,中间形态保持用户自定义眼睛,不再冻帧硬切。
This commit is contained in:
Edom
2026-08-21 05:20:47 +08:00
parent 1ab0054233
commit 62f39d27e7
6 changed files with 323 additions and 110 deletions
+85 -38
View File
@@ -21,6 +21,7 @@
s.x = s.t = v; s.x = s.t = v;
s.v = 0; s.v = 0;
}; };
const springBusy = (s) => Math.abs(s.x - s.t) > 0.002 || Math.abs(s.v) > 0.04;
const HOLD_GAZE = { const HOLD_GAZE = {
curious: { x: 12, y: -6 }, sad: { x: 2, y: 8 }, shy: { x: 10, y: 7 }, 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.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.poseHome = opts.poseHome || (this.loginWrap ? POSE_HOME : { turn: 0, tilt: 0, roll: 0 });
this.manualHold = false; this.manualHold = false;
this.manualMix = spring(0);
this.manualTx = 0; this.manualTx = 0;
this.manualTy = 0; this.manualTy = 0;
this.manualSpin = 0; this.manualSpin = 0;
@@ -222,7 +224,7 @@
this.frozenAt = 0; this.frozenAt = 0;
this.frozen = false; this.frozen = false;
this.paused = !!v; this.paused = !!v;
if (!this.paused) this._armTick(); this._armTick();
} }
_armTick() { _armTick() {
@@ -237,7 +239,7 @@
this._paint(this.last || this.t0); this._paint(this.last || this.t0);
return; return;
} }
if (this.paused) this._armTick(); this._armTick();
} }
step(dt) { step(dt) {
@@ -316,20 +318,37 @@
this._wake(); 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) { setManualHold(on) {
const next = !!on; const next = !!on;
if (next === this.manualHold) { const changed = next !== this.manualHold;
this._wake();
return;
}
this.manualHold = next; this.manualHold = next;
if (next) { if (next) {
this.manualMix.t = 1;
this.trickAt = Infinity; this.trickAt = Infinity;
this.gazeUntil = Infinity; this.gazeUntil = Infinity;
snapSpring(this.gazeX, 0); this.gazeX.t = 0;
snapSpring(this.gazeY, 0); this.gazeY.t = 0;
this.pointer.x = 0;
this.pointer.y = 0;
this.pointer.tx = 0; this.pointer.tx = 0;
this.pointer.ty = 0; this.pointer.ty = 0;
this.ctx.wantPn = null; this.ctx.wantPn = null;
@@ -339,20 +358,27 @@
this.ctx.forceSleepEye = false; this.ctx.forceSleepEye = false;
this.ctx.wakeEye = null; this.ctx.wakeEye = null;
this.setFollowPointer(false); this.setFollowPointer(false);
this._clearFx(); this.trick = null;
this._snapBodyRest(); 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 { } else {
this.manualTx = 0; this.manualMix.t = 0;
this.manualTy = 0; if (changed) {
this.manualSpin = 0; this.ctx = this._freshCtx(this._clock());
this.eyeTune = null; this.gazeUntil = this._clock();
this.ctx = this._freshCtx(this._clock()); this.blinkUntil = this._clock() + rand(1500, 7000);
this.pose.turn = this.poseRest.turn; this.trickAt = this._clock() + rand(4000, 8000);
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(); this._wake();
} }
@@ -449,6 +475,7 @@
this.humDots, this.humDots,
this.shapeSpring, this.shapeSpring,
this.overlayTurn, this.overlayTurn,
this.manualMix,
]) { ]) {
if (settle) s.t = s.x; if (settle) s.t = s.x;
s.v = 0; s.v = 0;
@@ -580,6 +607,7 @@
remainMs: remain, remainMs: remain,
held: !!this.playlistHold, held: !!this.playlistHold,
frozen: !!this.frozen, frozen: !!this.frozen,
manualMix: this._mix(),
}; };
} }
@@ -758,7 +786,7 @@
// Sphere limb is rotation-invariant. Only solids-of-revolution change // Sphere limb is rotation-invariant. Only solids-of-revolution change
// silhouette with yaw (same path as the spin trick). Tilt-squash was // silhouette with yaw (same path as the spin trick). Tilt-squash was
// flattening the ball into a coin. // 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 spinning = spinAmt != null;
const yawAmt = (spinAmt ?? 0) + yaw; const yawAmt = (spinAmt ?? 0) + yaw;
if (morphing || Math.abs(yawAmt) < 1e-5) return { ring: restRing, turned: false }; if (morphing || Math.abs(yawAmt) < 1e-5) return { ring: restRing, turned: false };
@@ -1069,6 +1097,7 @@
stepSpring(this.overlayMix, ...SPRINGS.overlayMix, step); stepSpring(this.overlayMix, ...SPRINGS.overlayMix, step);
stepSpring(this.shapeSpring, ...SPRINGS.shape, step); stepSpring(this.shapeSpring, ...SPRINGS.shape, step);
stepSpring(this.overlayTurn, ...SPRINGS.overlayTurn, step); stepSpring(this.overlayTurn, ...SPRINGS.overlayTurn, step);
stepSpring(this.manualMix, ...SPRINGS.manualMix, step);
} }
if (this.reduceMotion) { if (this.reduceMotion) {
this.overlayMix.x = 1; this.overlayMix.x = 1;
@@ -1132,6 +1161,11 @@
&& !this.spinTurn && !this.spinTurn
&& this.hopAt < 0 && this.hopAt < 0
&& !this.particles.hasLife() && !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.overlay.x - this.overlay.t) < 0.001
&& Math.abs(this.overlayTurn.t - this.overlayTurn.x) < 0.01 && Math.abs(this.overlayTurn.t - this.overlayTurn.x) < 0.01
&& this.overlayMix.x > 0.996 && this.overlayMix.x > 0.996
@@ -1148,15 +1182,17 @@
this.shapeSpring.v = 0; this.shapeSpring.v = 0;
this.blinkQueue = []; this.blinkQueue = [];
this.winkAt = -1e9; this.winkAt = -1e9;
const home = this.playlists[this.state]?.[0] ?? 0; if (!this.manualHold) {
this.eyeFrom = home; const home = this.playlists[this.state]?.[0] ?? 0;
this.eyeTo = home; this.eyeFrom = home;
this._fromPolys = null; this.eyeTo = home;
this.eyeMorph.x = 1; this._fromPolys = null;
this.eyeMorph.v = 0; this.eyeMorph.x = 1;
this.eyeMorph.t = 1; this.eyeMorph.v = 0;
this.eyeMorph.t = 1;
}
this.poseOut = { spin: 0, tx: 0, ty: 0, squash: 1, lid: 1, eyeBoost: 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; this._raf = 0;
return; return;
} }
@@ -1181,9 +1217,10 @@
const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix); const ov = this.fx.extras(now, this.stateAt, this.ovKind, this.ovPrev, yl, mix);
const bodyW = 1 - yl; const bodyW = 1 - yl;
const ex = this.extras; const ex = this.extras;
const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl + this.manualTx; const holdMix = this._mix();
const ty = (this.ty.x + ex.hop) * bodyW + ex.ki * bodyW - ov.rX.lift * ov.Lee + ov.aX * yl + this.manualTy; const tx = this.tx.x * bodyW + ex.yi * bodyW + ov.yre * yl + this.manualTx * holdMix;
const rot = (this.spin.x * bodyW + ex.Kr * bodyW) * tilt + (ex.Yr || 0) * bodyW + ov.wl * yl + this.manualSpin; 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 sx = bodyW + ov.wre * yl;
const sy = this.squash.x * bodyW + ov.wre * yl; const sy = this.squash.x * bodyW + ov.wre * yl;
this.group.setAttribute( this.group.setAttribute(
@@ -1236,7 +1273,7 @@
const morphT = clamp(this.eyeMorph.x, 0, 1); const morphT = clamp(this.eyeMorph.x, 0, 1);
const polys = this._currentPolys(morphT); 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; const overlayLive = yl > 0.001 || Math.abs(this.overlayTurn.t - this.overlayTurn.x) > 0.01;
let cyl = overlayLive ? this.overlayTurn.x : null; let cyl = overlayLive ? this.overlayTurn.x : null;
if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn; if (ex.turn != null) cyl = (cyl ?? 0) + ex.turn;
@@ -1276,9 +1313,19 @@
top: faceTop, top: faceTop,
bottom: faceBottom, bottom: faceBottom,
emphasisBlend: this.emphasisBlend, emphasisBlend: this.emphasisBlend,
tune: this.eyeTune, tune: null,
manualHold: this.manualHold, 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); const hum = clamp(this.humDots.x, 0, 1);
if (hum > 0.01) { if (hum > 0.01) {
+184 -11
View File
@@ -38,6 +38,7 @@
blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX, blinkX, gazeX, gazeY, winkAt, winkEye, turn, cr, pointer, notifyX,
overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold, overlayX, eyeEls, badgeEl, badgeColor, Re, G9e, VJt, extras, ringHint, manualHold,
} = opt; } = opt;
const mix = clamp(opt.manualMix ?? (manualHold ? 1 : 0), 0, 1);
const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI); const pulse = 1 + 0.07 * Math.sin(morphT * Math.PI);
const $i = { const $i = {
x: face.x, x: face.x,
@@ -48,7 +49,7 @@
leftDX: face.leftDX ?? 0, leftDX: face.leftDX ?? 0,
}; };
const tune = opt.tune || null; 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 sX = uniformEyes ? $i.leftDX : 0;
const cents = [centroid(polys[0]), centroid(polys[1])]; const cents = [centroid(polys[0]), centroid(polys[1])];
let a1 = 0, o1 = 0; let a1 = 0, o1 = 0;
@@ -78,18 +79,17 @@
: [Re, shape.top]; : [Re, shape.top];
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
if (tune && manualHold) continue;
const poly = polys[i]; const poly = polys[i];
const [Gn, Ti] = cents[i]; const [Gn, Ti] = cents[i];
eyeEls[i].setAttribute("d", polyPath(poly)); eyeEls[i].setAttribute("d", polyPath(poly));
const lid = winkLid(blinkX, now, winkAt, winkEye, i); const lid = winkLid(blinkX, now, winkAt, winkEye, i);
const sideTune = tune?.[i === 0 ? "left" : "right"]; const sideTune = tune?.[i === 0 ? "left" : "right"];
const tuneW = sideTune ? clamp(sideTune.width ?? 20, 10, 100) / 20 : 1; const tuneW = sideTune ? 1 + (clamp(sideTune.width ?? 20, 10, 100) / 20 - 1) * mix : 1;
const tuneH = sideTune ? clamp(sideTune.height ?? 50, 10, 100) / 50 : 1; const tuneH = sideTune ? 1 + (clamp(sideTune.height ?? 50, 10, 100) / 50 - 1) * mix : 1;
const tuneS = sideTune ? clamp(sideTune.size ?? 1, 0.35, 2.2) : 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 : 0; const tuneA = sideTune ? (sideTune.angle ?? 0) * Math.PI / 180 * mix : 0;
const tuneX = sideTune ? (sideTune.x ?? 0) : 0; const tuneX = sideTune ? (sideTune.x ?? 0) * mix : 0;
const tuneY = sideTune ? (sideTune.y ?? 0) : 0; const tuneY = sideTune ? (sideTune.y ?? 0) * mix : 0;
const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse); const Hee = Math.min(Hee0 * tuneW * tuneS, _ee / pulse);
const u1 = u10 * tuneH * tuneS; const u1 = u10 * tuneH * tuneS;
const Ea = Gn + (i === 0 ? sX : 0); const Ea = Gn + (i === 0 ? sX : 0);
@@ -147,8 +147,8 @@
Tre = Dke(clamp(Io0 / 0.5, 0, 1)); 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 Kj = (1 - mix) * (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 Ko = (1 - mix) * Math.sin(now * 58e-5 + i) * 0.9;
if (pointer) { if (pointer) {
const Zl = Rn(0.16); const Zl = Rn(0.16);
pointer.x += (pointer.tx - pointer.x) * Zl; 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); })(window);
+1
View File
@@ -72,6 +72,7 @@
shape: [10, 1], shape: [10, 1],
overlayTurn: [14, 1], overlayTurn: [14, 1],
spinTurn: [6.2, 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 }; const FACE_TUNE = { size: 0.86, gap: 1.18, height: 1, eyeWidth: 0.96, eyeHeight: 0.92 };
+40 -21
View File
@@ -80,6 +80,7 @@ const hero = ref<{
remainMs: number remainMs: number
held: boolean held: boolean
frozen: boolean frozen: boolean
manualMix: number
} | null } | null
} | null>(null) } | null>(null)
const manual = ref<ManualState>(parseManual(lis('manuel'))) const manual = ref<ManualState>(parseManual(lis('manuel')))
@@ -129,10 +130,11 @@ watch(expression, (id) => {
ecris('expression', id) ecris('expression', id)
if (view.value !== 'animations') state.value = id if (view.value !== 'animations') state.value = id
if (view.value !== 'personnaliser') return if (view.value !== 'personnaliser') return
posePlay.value = true
poseStopped.value = false
poseIdx.value = 0 poseIdx.value = 0
poseElapsed.value = 0 poseElapsed.value = 0
if (manual.value.on) return
posePlay.value = true
poseStopped.value = false
}) })
watch( watch(
manual, manual,
@@ -188,11 +190,23 @@ function onSeek(t: number) {
state.value = cycle.value.blocks[index]?.state ?? 'idle' state.value = cycle.value.blocks[index]?.state ?? 'idle'
} }
const posePlay = ref(true) const posePlay = ref(!manual.value.on)
const poseStopped = ref(false) const poseStopped = ref(false)
const poseIdx = ref(0) const poseIdx = ref(0)
const poseElapsed = ref(0) const poseElapsed = ref(0)
const poseHold = ref(2) 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) { function onSeekPose(index: number) {
const frozen = !!hero.value?.playback()?.frozen const frozen = !!hero.value?.playback()?.frozen
@@ -205,24 +219,32 @@ function releasePoseFreeze() {
hero.value?.setPlaylistHold(false) hero.value?.setPlaylistHold(false)
} }
function applyPosePlayback() { function playPose() {
if (view.value !== 'personnaliser' || preview.value) return poseStopped.value = false
if (posePlay.value) { hero.value?.setPaused(false)
poseStopped.value = false hero.value?.setPlaylistHold(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 return
} }
hero.value?.seekEye(poseIdx.value, { snap: true }) hero.value?.seekEye(poseIdx.value, { snap: true })
hero.value?.setPlaylistHold(true) hero.value?.freezeNow({ settle })
hero.value?.freezeNow({ settle: poseStopped.value }) }
function applyPosePlayback() {
if (view.value !== 'personnaliser' || preview.value) return
if (posePlay.value) playPose()
else pausePose(poseStopped.value)
} }
function onStopPose() { function onStopPose() {
poseStopped.value = true poseStopped.value = true
posePlay.value = false posePlay.value = false
hero.value?.setPlaylistHold(true) if (view.value === 'personnaliser') pausePose(true)
hero.value?.freezeNow({ settle: true })
} }
const gardeAnim = ref(false) const gardeAnim = ref(false)
@@ -257,14 +279,8 @@ watch(preview, (on) => {
watch(posePlay, (on) => { watch(posePlay, (on) => {
if (view.value !== 'personnaliser') return if (view.value !== 'personnaliser') return
if (on) { if (on) playPose()
poseStopped.value = false else pausePose(poseStopped.value)
hero.value?.setPaused(false)
hero.value?.setPlaylistHold(false)
return
}
hero.value?.setPlaylistHold(true)
hero.value?.freezeNow({ settle: poseStopped.value })
}) })
let raf = 0 let raf = 0
@@ -279,6 +295,7 @@ function tick(ms: number) {
poseIdx.value = snap.eyeIdx poseIdx.value = snap.eyeIdx
poseHold.value = snap.holdMs / 1000 poseHold.value = snap.holdMs / 1000
poseElapsed.value = Math.max(0, (snap.holdMs - snap.remainMs) / 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 if (!playing.value || (view.value !== 'animations' && !preview.value)) return
@@ -527,6 +544,8 @@ onUnmounted(() => {
:follow="follow" :follow="follow"
:manual="manual" :manual="manual"
:tools="view === 'personnaliser' && !preview" :tools="view === 'personnaliser' && !preview"
:playing="posePlay"
:mix="poseMix"
@ready="onHeroReady" @ready="onHeroReady"
@update:manual="manual = $event" @update:manual="manual = $event"
/> />
+12 -40
View File
@@ -2,14 +2,7 @@
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Hero from '@/components/Hero.vue' import Hero from '@/components/Hero.vue'
import ManualCanvas, { type HeroHandle } from '@/components/ManualCanvas.vue' import ManualCanvas, { type HeroHandle } from '@/components/ManualCanvas.vue'
import { import { type EyeTune, type ManualPart, type ManualState } from '@/ui/pose/model'
DEFAULT_EYE,
DEFAULT_EYES,
type EyeSide,
type EyeTune,
type ManualPart,
type ManualState
} from '@/ui/pose/model'
import type { HeadPose } from '@/ui/pose/math' import type { HeadPose } from '@/ui/pose/math'
const props = withDefaults( const props = withDefaults(
@@ -22,6 +15,8 @@ const props = withDefaults(
paper?: string paper?: string
manual?: ManualState | null manual?: ManualState | null
tools?: boolean tools?: boolean
playing?: boolean
mix?: number
}>(), }>(),
{ {
size: 440, size: 440,
@@ -31,7 +26,9 @@ const props = withDefaults(
follow: false, follow: false,
paper: '#f9f9f9', paper: '#f9f9f9',
manual: null, manual: null,
tools: false tools: false,
playing: false,
mix: 0
} }
) )
@@ -42,7 +39,9 @@ const emit = defineEmits<{
const hero = ref<InstanceType<typeof Hero> | null>(null) const hero = ref<InstanceType<typeof Hero> | null>(null)
const liveHero = computed<HeroHandle | null>(() => hero.value) const liveHero = computed<HeroHandle | null>(() => 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<ManualState>) { function patch(next: Partial<ManualState>) {
if (!props.manual) return if (!props.manual) return
@@ -57,38 +56,10 @@ function applyEngine() {
bot.setManualHold(false) bot.setManualHold(false)
return return
} }
bot.setManualHold(true)
bot.setPose(manual.pose) bot.setPose(manual.pose)
bot.setManualOffset(manual.offset) bot.setManualOffset(manual.offset)
bot.setEyeTune(engineEyes(manual.eyes)) bot.setEyeTune(manual.eyes)
} bot.setManualHold(!props.playing)
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 }
} }
function clearSelection() { function clearSelection() {
@@ -107,6 +78,7 @@ watch(
props.manual?.offset, props.manual?.offset,
props.manual?.eyes, props.manual?.eyes,
props.tools, props.tools,
props.playing,
hero.value hero.value
], ],
() => applyEngine(), () => applyEngine(),
+1
View File
@@ -41,6 +41,7 @@ export type ReplicaPlayback = {
remainMs: number remainMs: number
held: boolean held: boolean
frozen: boolean frozen: boolean
manualMix: number
} }
declare global { declare global {