feat:手动调节与表情播放弹簧衔接
打开手动、播放、暂停都走弹簧过渡,中间形态保持用户自定义眼睛,不再冻帧硬切。
This commit is contained in:
+85
-38
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user