feat(prototype): 更新线缆物理交互原型

This commit is contained in:
2026-07-09 00:51:45 +08:00
parent 2e7c87c886
commit e1f5484e02
2 changed files with 135 additions and 239 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -26,37 +26,25 @@ a{color:#3D63DD;text-decoration:none}a:hover{color:#2B4BB5}
</sc-for>
</div>
</div>
<div style="position:absolute;top:74px;right:28px;width:206px;padding:12px 14px;background:rgba(244,243,240,.92);border:1px solid rgba(38,38,42,.08);box-shadow:0 10px 28px rgba(40,38,32,.10);border-radius:8px;display:flex;flex-direction:column;gap:10px;pointer-events:auto">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;font-weight:650;color:#26262A">
<span>重力</span>
<span style="font-variant-numeric:tabular-nums;color:#77746C">{{ gravityScaleText }}</span>
</div>
<input type="range" min="0.6" max="2.2" step="0.02" value="{{ gravityScale }}" onInput="{{ onGravityInput }}" style="width:100%;accent-color:#4B72E0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;font-weight:650;color:#26262A">
<span>阻尼</span>
<span style="font-variant-numeric:tabular-nums;color:#77746C">{{ motionDampingText }}</span>
</div>
<input type="range" min="0.86" max="0.99" step="0.005" value="{{ motionDamping }}" onInput="{{ onDampingInput }}" style="width:100%;accent-color:#4B72E0">
</div>
<div style="position:absolute;bottom:22px;left:0;right:0;display:flex;justify-content:center;pointer-events:none">
<div style="padding:8px 16px;border-radius:99px;background:{{ statusBg }};color:{{ statusFg }};font-size:12.5px;font-weight:550;white-space:nowrap;flex:none;transition:background .25s,color .25s">{{ status }}</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;maxStretch&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:1.5,&quot;min&quot;:1.5,&quot;max&quot;:4,&quot;step&quot;:0.1,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;物理&quot;,&quot;description&quot;:&quot;最大拉出长度(相对初始长度的倍数)&quot;},&quot;retractSpeed&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:0.09,&quot;min&quot;:0.02,&quot;max&quot;:0.3,&quot;step&quot;:0.01,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;物理&quot;,&quot;description&quot;:&quot;松手后回收速度&quot;},&quot;gravityScale&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:1.28,&quot;min&quot;:0.6,&quot;max&quot;:2.2,&quot;step&quot;:0.02,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;重量&quot;,&quot;description&quot;:&quot;线缆和插头的重力倍率&quot;},&quot;motionDamping&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:0.94,&quot;min&quot;:0.86,&quot;max&quot;:0.99,&quot;step&quot;:0.005,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;重量&quot;,&quot;description&quot;:&quot;数值越低越沉,越不飘&quot;}}">
<script type="text/x-dc" data-dc-script data-props="{&quot;maxStretch&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:4,&quot;min&quot;:2,&quot;max&quot;:8,&quot;step&quot;:0.2,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;物理&quot;,&quot;description&quot;:&quot;最大拉出长度(相对初始长度的倍数)&quot;},&quot;retractSpeed&quot;:{&quot;editor&quot;:&quot;range&quot;,&quot;default&quot;:0.09,&quot;min&quot;:0.02,&quot;max&quot;:0.3,&quot;step&quot;:0.01,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;物理&quot;,&quot;description&quot;:&quot;松手后回收速度&quot;}}">
class Component extends DCLogic {
state = { material: 'rubber', plugged: false, dragging: false, gravityScale: 1.28, motionDamping: 0.94 };
state = { material: 'rubber', plugged: false, dragging: false };
presets = {
rubber: { label: '橡胶粗缆', g: 2600, damp: 0.960, w: 17, iter: 30, smooth: 0.34, Lmin: 92, retract: 0.085,
rubber: { label: '橡胶粗缆', g: 4400, damp: 0.965, w: 17, iter: 30, smooth: 0.34, Lmin: 90, retract: 0.085,
body: '#4B72E0', dark: '#2F4CA8', hi: 'rgba(255,255,255,.38)', kind: 'rubber' },
braided: { label: '编织线', g: 2000, damp: 0.972, w: 12, iter: 24, smooth: 0.20, Lmin: 92, retract: 0.10,
braided: { label: '编织线', g: 3600, damp: 0.975, w: 12, iter: 24, smooth: 0.20, Lmin: 95, retract: 0.10,
body: '#5C6070', dark: '#3B3E49', hi: '#A9AFBE', kind: 'braid' },
pixel: { label: '像素风', g: 1900, damp: 0.974, w: 15, iter: 24, smooth: 0.22, Lmin: 92, retract: 0.11,
pixel: { label: '像素风', g: 3400, damp: 0.975, w: 15, iter: 24, smooth: 0.22, Lmin: 95, retract: 0.11,
body: '#3D5BD9', dark: '#222E66', mid: '#6E8CF5', hi: '#B9C8FF', kind: 'pixel' }
};
N = 42; plugLen = 58; dockOff = 41; exitGuardLen = 12; extraFeedLen = 300;
N = 42; plugLen = 58; dockOff = 41;
componentDidMount() {
this.canvas = this.canvasRef.current;
@@ -66,9 +54,9 @@ class Component extends DCLogic {
this.W = this.canvas.clientWidth; this.H = this.canvas.clientHeight;
this.canvas.width = this.W * dpr; this.canvas.height = this.H * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.anchor = { x: this.W * 0.5, y: Math.min(218, this.H * 0.32) };
this.LminScale = Math.min(1, Math.max(0.4, (this.H - this.anchor.y - 90) / 300));
this.hole = { x: this.W - 86, y: Math.max(260, this.H * 0.44) };
this.anchor = { x: this.W * 0.5, y: Math.max(260, this.H * 0.52) };
this.LminScale = Math.min(1, Math.max(0.4, (this.H - this.anchor.y - 80) / 160));
this.hole = { x: this.W - 86, y: Math.max(280, this.H * 0.5) };
};
this.fit();
window.addEventListener('resize', this.fit);
@@ -81,7 +69,7 @@ class Component extends DCLogic {
}
this.pts = P;
this.L = this.presets.rubber.Lmin;
this.pointer = null; this.reelSpin = 0; this.plugAng = Math.PI / 2; this.plugDir = { x: 0, y: 1 };
this.pointer = null; this.reelSpin = 0; this.plugAng = Math.PI / 2;
const c = this.canvas;
c.addEventListener('pointerdown', this.onDown);
c.addEventListener('pointermove', this.onMove);
@@ -96,9 +84,9 @@ class Component extends DCLogic {
pos(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
plugCenter() {
const P = this.pts, a = P[this.N - 1];
const dir = this.plugDir || { x: Math.cos(this.plugAng ?? Math.PI / 2), y: Math.sin(this.plugAng ?? Math.PI / 2) };
const ux = dir.x, uy = dir.y;
const P = this.pts, a = P[this.N - 1], b = P[this.N - 2];
const d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
const ux = (a.x - b.x) / d, uy = (a.y - b.y) / d;
return { x: a.x + ux * this.plugLen * 0.5, y: a.y + uy * this.plugLen * 0.5, ux, uy };
}
onDown = (e) => {
@@ -129,18 +117,17 @@ class Component extends DCLogic {
loop = () => {
this.raf = requestAnimationFrame(this.loop);
const m = this.presets[this.state.material];
const gravityScale = this.state.gravityScale ?? this.props.gravityScale ?? 1.28;
const motionDamping = this.state.motionDamping ?? this.props.motionDamping ?? 0.94;
const Lmin = m.Lmin * (this.LminScale || 1);
const Lmax = Math.max(Lmin * (this.props.maxStretch ?? 2.8), Lmin + this.extraFeedLen * (this.LminScale || 1));
const dockD = Math.hypot((this.hole.x - this.dockOff) - this.anchor.x, this.hole.y - this.anchor.y);
const Lmax = Math.max(Lmin * (this.props.maxStretch ?? 4), dockD * 1.12);
const retract = this.props.retractSpeed ?? m.retract;
const P = this.pts, N = this.N, dt = 1 / 60;
const dock = { x: this.hole.x - this.dockOff, y: this.hole.y };
// --- drag target (heavy lerp) + unplug check
if (this.state.dragging && this.pointer) {
this.dragT.x += (this.pointer.x - this.dragT.x) * 0.38;
this.dragT.y += (this.pointer.y - this.dragT.y) * 0.38;
this.dragT.x += (this.pointer.x - this.dragT.x) * 0.3;
this.dragT.y += (this.pointer.y - this.dragT.y) * 0.3;
if (this.dragT.y > this.H - 18) this.dragT.y = this.H - 18;
// clamp to CURRENT rope length — the pin can never overstretch the rope (kills taut-drag jitter);
// length feeds out below, and the target follows it
@@ -163,16 +150,14 @@ class Component extends DCLogic {
// --- verlet integrate
const seg = this.L / (N - 1);
const exitGuardSegments = this.getExitGuardSegments(seg);
const pinnedEnd = this.state.dragging || this.state.plugged;
for (let i = exitGuardSegments + 1; i < N; i++) {
for (let i = 1; i < N; i++) {
const p = P[i];
const damp = Math.min(m.damp, motionDamping, (!pinnedEnd && i >= N - 3) ? 0.84 : 1);
const vx = (p.x - p.px) * damp, vy = (p.y - p.py) * damp;
const vx = (p.x - p.px) * m.damp, vy = (p.y - p.py) * m.damp;
p.px = p.x; p.py = p.y;
p.x += vx; p.y += vy + m.g * gravityScale * dt * dt;
p.x += vx; p.y += vy + m.g * (i > N - 4 ? 1.8 : 1) * dt * dt;
}
this.applyExitGuard(seg, exitGuardSegments);
P[0].x = this.anchor.x; P[0].y = this.anchor.y; P[0].px = P[0].x; P[0].py = P[0].y;
if (this.state.dragging) { P[N - 1].x = this.dragT.x; P[N - 1].y = this.dragT.y; }
else if (this.state.plugged) {
P[N - 1].x += (dock.x - P[N - 1].x) * 0.5; P[N - 1].y += (dock.y - P[N - 1].y) * 0.5;
@@ -185,12 +170,16 @@ class Component extends DCLogic {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1e-6;
const diff = (d - seg) / d;
const pinA = i <= exitGuardSegments;
const pinB = (i + 1 <= exitGuardSegments) || ((i === N - 2) && pinnedEnd);
const pinA = i === 0, pinB = (i === N - 2) && pinnedEnd;
const wa = pinA ? 0 : (pinB ? 1 : 0.5), wb = pinB ? 0 : (pinA ? 1 : 0.5);
a.x += dx * diff * wa; a.y += dy * diff * wa;
b.x -= dx * diff * wb; b.y -= dy * diff * wb;
}
// exit-stub guard: a short protected span leaves the stub pointing straight down,
// regardless of where the rest of the rope goes — no sharp kink / self-pierce at the mouth
P[1].x += (P[0].x - P[1].x) * 0.6;
if (P[1].y < P[0].y + 6) P[1].y = P[0].y + 6;
P[2].x += (P[0].x - P[2].x) * 0.2;
if (this.state.plugged) {
// orient plug horizontally into socket
const e2 = P[N - 2];
@@ -198,67 +187,29 @@ class Component extends DCLogic {
}
// bending stiffness
if (m.smooth > 0 && k % 3 === 0) {
for (let i = exitGuardSegments + 1; i < N - 1; i++) {
for (let i = 1; i < N - 1; i++) {
if (i === N - 2 && pinnedEnd) continue;
const p = P[i];
const mx = (P[i - 1].x + P[i + 1].x) / 2, my = (P[i - 1].y + P[i + 1].y) / 2;
p.x += (mx - p.x) * m.smooth * 0.5; p.y += (my - p.y) * m.smooth * 0.5;
}
}
this.stabilizePlugEnd(seg, pinnedEnd);
this.applyExitGuard(seg, exitGuardSegments);
}
// floor (pinned dragged end is exempt — no pin-vs-floor fight)
const floor = this.H - 14;
for (let i = exitGuardSegments + 1; i < N; i++) {
for (let i = 1; i < N; i++) {
if (i === N - 1 && this.state.dragging) continue;
if (P[i].y > floor) {
P[i].y = floor;
P[i].py = P[i].y;
P[i].px += (P[i].x - P[i].px) * 0.45;
}
if (P[i].y > floor) { P[i].y = floor; P[i].px += (P[i].x - P[i].px) * 0.5; }
}
this.draw(m, Lmin, Lmax);
};
getExitGuardSegments(seg) {
return Math.max(1, Math.min(3, Math.round(this.exitGuardLen / Math.max(1, seg))));
}
applyExitGuard(seg, guardSegments) {
const P = this.pts, a = this.anchor;
for (let i = 0; i <= guardSegments && i < this.N; i++) {
const p = P[i];
p.x = a.x;
p.y = a.y + seg * i;
p.px = p.x;
p.py = p.y;
}
}
stabilizePlugEnd(seg, pinnedEnd) {
if (pinnedEnd || this.N < 6) return;
const P = this.pts, N = this.N;
const tail = P[N - 1], neck = P[N - 2], back = P[N - 5];
const dx = neck.x - back.x, dy = neck.y - back.y;
const d = Math.hypot(dx, dy) || 1;
const tx = dx / d, ty = dy / d;
const desiredX = neck.x + tx * seg;
const desiredY = neck.y + ty * seg;
const pull = 0.025;
const ox = (desiredX - tail.x) * pull;
const oy = (desiredY - tail.y) * pull;
tail.x += ox; tail.y += oy;
tail.px += ox; tail.py += oy;
}
drawCablePath(path, m, shadow, cap = 'round') {
const ctx = this.ctx;
ctx.lineCap = cap; ctx.lineJoin = 'round';
drawCablePath(ctx, path, m, shadow, cap) {
ctx.lineCap = cap || 'round'; ctx.lineJoin = 'round';
if (shadow !== false) {
ctx.save(); ctx.translate(0, 7);
ctx.strokeStyle = 'rgba(40,38,32,.10)'; ctx.lineWidth = m.w + 2; ctx.stroke(path); ctx.restore();
ctx.strokeStyle = 'rgba(40,38,32,.16)'; ctx.lineWidth = m.w + 2; ctx.stroke(path); ctx.restore();
}
if (m.kind === 'pixel') {
// flat stepped bands — no gradients, hard edges
@@ -286,160 +237,109 @@ class Component extends DCLogic {
}
draw(m, Lmin, Lmax) {
this.clearCanvas();
this.drawReel(m, Lmin, Lmax);
this.drawExitStub(m);
if (!this.state.plugged) this.drawSocket(m);
this.drawMainCable(m);
this.drawFrontCableOverlay(m);
this.drawCablePlug(m);
if (this.state.plugged) this.drawSocket(m);
}
clearCanvas() {
this.ctx.clearRect(0, 0, this.W, this.H);
}
drawReel(m, Lmin, Lmax) {
const ctx = this.ctx, a = this.anchor;
const reelY = a.y - 128;
const frac = (this.L - Lmin) / (Lmax - Lmin);
const ctx = this.ctx, P = this.pts, N = this.N;
ctx.clearRect(0, 0, this.W, this.H);
// reel
const a = this.anchor;
ctx.fillStyle = '#E8E6E1';
ctx.beginPath(); ctx.arc(a.x, reelY, 108, 0, 7); ctx.fill();
ctx.beginPath(); ctx.arc(a.x, a.y - 128, 108, 0, 7); ctx.fill();
ctx.strokeStyle = '#DBD8D2'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(a.x, reelY, 74, 0, 7); ctx.stroke();
this.drawReelSpinMarker(reelY);
ctx.strokeStyle = m.body; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(a.x, reelY, 92, -Math.PI / 2, -Math.PI / 2 + Math.max(0.001, frac) * Math.PI * 2);
ctx.stroke();
}
drawReelSpinMarker(reelY) {
const ctx = this.ctx, a = this.anchor;
ctx.save(); ctx.translate(a.x, reelY); ctx.rotate(this.reelSpin);
ctx.beginPath(); ctx.arc(a.x, a.y - 128, 74, 0, 7); ctx.stroke();
// spin marker
ctx.save(); ctx.translate(a.x, a.y - 128); ctx.rotate(this.reelSpin);
ctx.fillStyle = '#CFCCC5';
for (let i = 0; i < 3; i++) {
ctx.rotate(Math.PI * 2 / 3);
ctx.beginPath(); ctx.arc(74, 0, 5, 0, 7); ctx.fill();
}
for (let i = 0; i < 3; i++) { ctx.rotate(Math.PI * 2 / 3); ctx.beginPath(); ctx.arc(74, 0, 5, 0, 7); ctx.fill(); }
ctx.restore();
}
// length gauge on reel
const frac = (this.L - Lmin) / (Lmax - Lmin);
ctx.strokeStyle = m.body; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(a.x, a.y - 128, 92, -Math.PI / 2, -Math.PI / 2 + Math.max(0.001, frac) * Math.PI * 2); ctx.stroke();
drawExitStub(m) {
const ctx = this.ctx, a = this.anchor;
// exit stub covers cable (cable is clipped out of the stub area → seamless single path)
ctx.fillStyle = '#E8E6E1';
ctx.beginPath(); ctx.roundRect(a.x - 20, a.y - 46, 40, 44, 8); ctx.fill();
ctx.fillStyle = 'rgba(40,38,32,.28)';
ctx.beginPath(); ctx.ellipse(a.x, a.y - 4, Math.min(16, m.w * 0.5 + 5), 4.5, 0, 0, 7); ctx.fill();
}
drawMainCable(m) {
const ctx = this.ctx;
ctx.save();
ctx.clip(this.buildExitStubMask(), 'evenodd');
this.drawCablePath(this.buildMainCablePath(), m);
ctx.restore();
}
if (!this.state.plugged) this.drawSocket(m);
buildMainCablePath() {
const a = this.anchor, P = this.pts, path = new Path2D();
const cctx = ctx;
// full cable — one seamless path, clipped so it passes under the stub
const path = new Path2D();
path.moveTo(a.x, a.y - 5);
path.lineTo(P[0].x, P[0].y);
this.appendCableCurve(path, 1, this.N - 1);
return path;
}
buildExitStubMask() {
const a = this.anchor, clip = new Path2D();
clip.rect(0, 0, this.W, this.H);
clip.roundRect(a.x - 20, a.y - 46, 40, 44, 8);
return clip;
}
drawFrontCableOverlay(m) {
const frontStartIndex = this.findFrontCableStartIndex();
if (frontStartIndex >= this.N - 1) return;
this.drawCablePath(this.buildFrontCablePath(frontStartIndex), m, false, 'butt');
}
buildFrontCablePath(startIndex) {
const P = this.pts, N = this.N;
const path = new Path2D();
path.moveTo(P[startIndex].x, P[startIndex].y);
this.appendCableCurve(path, startIndex + 1, N - 1);
return path;
}
findFrontCableStartIndex() {
const P = this.pts, N = this.N;
let bottomIndex = 1;
for (let i = 2; i < N - 1; i++) {
if (P[i].y > P[bottomIndex].y) bottomIndex = i;
}
return bottomIndex;
}
appendCableCurve(path, controlStart, endIndex) {
const P = this.pts;
for (let i = controlStart; i < endIndex; i++) {
for (let i = 1; i < N - 1; i++) {
const mx = (P[i].x + P[i + 1].x) / 2, my = (P[i].y + P[i + 1].y) / 2;
path.quadraticCurveTo(P[i].x, P[i].y, mx, my);
}
path.lineTo(P[endIndex].x, P[endIndex].y);
}
path.lineTo(P[N - 1].x, P[N - 1].y);
const clip = new Path2D();
clip.rect(0, 0, this.W, this.H);
clip.roundRect(a.x - 20, a.y - 46, 40, 44, 8);
cctx.save();
cctx.clip(clip, 'evenodd');
this.drawCablePath(cctx, path, m);
cctx.restore();
drawCablePlug(m) {
const P = this.pts, e = P[this.N - 1];
this.updatePlugAngle();
this.drawPlug(e.x, e.y, this.plugAng, m);
}
updatePlugAngle() {
const dir = this.getPlugTargetDirection();
let ta = Math.atan2(dir.y, dir.x);
const da = Math.atan2(Math.sin(ta - this.plugAng), Math.cos(ta - this.plugAng));
if (!this.state.plugged && Math.abs(da) < 0.006) return;
let step = da * (this.state.plugged ? 0.4 : (this.state.dragging ? 0.18 : 0.12));
const MAXW = this.state.plugged ? 0.11 : (this.state.dragging ? 0.075 : 0.045);
if (step > MAXW) step = MAXW; else if (step < -MAXW) step = -MAXW;
this.plugAng += step;
this.plugDir.x = Math.cos(this.plugAng);
this.plugDir.y = Math.sin(this.plugAng);
}
getPlugTargetDirection() {
if (this.state.plugged) return { x: 1, y: 0 };
const P = this.pts, N = this.N, e = P[N - 1];
const prev = this.plugDir || { x: Math.cos(this.plugAng), y: Math.sin(this.plugAng) };
const seg = Math.max(1, this.L / Math.max(1, N - 1));
let sx = 0, sy = 0, total = 0;
for (let i = N - 2; i >= Math.max(0, N - 8); i--) {
const dx = e.x - P[i].x, dy = e.y - P[i].y;
const d = Math.hypot(dx, dy);
if (d < seg * 0.35) continue;
const w = Math.min(1.75, d / seg);
sx += dx / d * w;
sy += dy / d * w;
total += w;
// segment from the sag bottom to the plug re-drawn on top: when the cable overlaps
// itself, the run the player is holding reads closer to camera than the run coming
// off the reel. Both layers coincide at the sag bottom, so no visible seam.
let j = 0;
for (let i = 1; i < N; i++) if (P[i].y > P[j].y) j = i;
// only redraw when the tail run actually passes over the head run — no overlap,
// no overlay, no chance of a junction seam
let overlap = false;
if (j < N - 2) {
const rr = m.w + 6;
for (let s = j + 2; s < N && !overlap; s += 2) {
for (let t = 0; t < j - 2 && !overlap; t += 2) {
if (s - t >= 8 && Math.hypot(P[s].x - P[t].x, P[s].y - P[t].y) < rr) overlap = true;
}
}
}
if (overlap && j < N - 2) {
// start at an on-curve segment midpoint a couple points before the sag bottom, using
// the SAME midpoint construction as the main path — the overlay traces identical
// geometry, and a butt start cap adds nothing beyond it: no seam, no notch.
const j0 = Math.max(0, j - 2);
const tail = new Path2D();
tail.moveTo((P[j0].x + P[j0 + 1].x) / 2, (P[j0].y + P[j0 + 1].y) / 2);
for (let i = j0 + 1; i < N - 1; i++) {
const mx = (P[i].x + P[i + 1].x) / 2, my = (P[i].y + P[i + 1].y) / 2;
tail.quadraticCurveTo(P[i].x, P[i].y, mx, my);
}
tail.lineTo(P[N - 1].x, P[N - 1].y);
this.drawCablePath(cctx, tail, m, false, 'butt');
}
const span = P[Math.max(0, N - 7)];
const spanLen = Math.hypot(e.x - span.x, e.y - span.y);
if (total <= 0.001 || spanLen < seg * 1.2) return prev;
// plug — angle taken from a point a fixed arc-length back along the rope; when the
// rope is compressed (points bunched at the end) the direction is unreliable, so
// hold the previous angle instead of flipping. Angular speed stays capped on top.
const e = P[N - 1];
let kk = N - 1, arc = 0;
while (kk > 0 && arc < 30) { arc += Math.hypot(P[kk].x - P[kk - 1].x, P[kk].y - P[kk - 1].y); kk--; }
const ref = P[kk], refD = Math.hypot(e.x - ref.x, e.y - ref.y);
let ta;
if (this.state.plugged) ta = 0;
else {
// when dragged taut, the cable is a straight line from anchor to plug — aim the
// plug along that line directly; local segment directions are noisy under tension
const ax = e.x - this.anchor.x, ay = e.y - this.anchor.y;
const taut = Math.hypot(ax, ay) / Math.max(1, this.L);
if (this.state.dragging && taut > 0.92) ta = Math.atan2(ay, ax);
else ta = refD > 9 ? Math.atan2(e.y - ref.y, e.x - ref.x) : this.plugAng;
}
const da = Math.atan2(Math.sin(ta - this.plugAng), Math.cos(ta - this.plugAng));
let step = da * (this.state.plugged ? 0.4 : 0.18);
const MAXW = 0.11;
if (step > MAXW) step = MAXW; else if (step < -MAXW) step = -MAXW;
this.plugAng += step;
this.drawPlug(cctx, e.x, e.y, this.plugAng, m);
let dx = sx / total, dy = sy / total;
const d = Math.hypot(dx, dy) || 1;
dx /= d; dy /= d;
const dot = dx * prev.x + dy * prev.y;
if (dot < -0.2 && spanLen < seg * 2.8) return prev;
return { x: dx, y: dy };
// plate drawn over the plug when connected — shaft disappears into the socket
if (this.state.plugged) this.drawSocket(m);
}
drawSocket(m) {
@@ -460,8 +360,8 @@ class Component extends DCLogic {
ctx.beginPath(); ctx.arc(h.x + 26, h.y - 52, 4.5, 0, 7); ctx.fill();
}
drawPlug(x, y, ang, m) {
const ctx = this.ctx;
drawPlug(ctx, x, y, ang, m) {
const px = m.kind === 'pixel';
ctx.save(); ctx.translate(x, y); ctx.rotate(ang);
ctx.lineCap = 'butt';
// strain relief (cable-colored taper)
@@ -470,21 +370,31 @@ class Component extends DCLogic {
ctx.moveTo(-14, -m.w * 0.5 - 1); ctx.lineTo(6, -m.w * 0.72 - 2);
ctx.lineTo(6, m.w * 0.72 + 2); ctx.lineTo(-14, m.w * 0.5 + 1); ctx.closePath(); ctx.fill();
// barrel
const g = ctx.createLinearGradient(0, -13, 0, 13);
g.addColorStop(0, '#B9BCC2'); g.addColorStop(0.28, '#F2F3F5'); g.addColorStop(0.55, '#8E9298'); g.addColorStop(1, '#5A5D63');
ctx.fillStyle = g;
if (px) { ctx.fillStyle = '#AEB3BC'; }
else {
const g = ctx.createLinearGradient(0, -13, 0, 13);
g.addColorStop(0, '#B9BCC2'); g.addColorStop(0.28, '#F2F3F5'); g.addColorStop(0.55, '#8E9298'); g.addColorStop(1, '#5A5D63');
ctx.fillStyle = g;
}
ctx.beginPath(); ctx.roundRect(6, -13, 26, 26, 3); ctx.fill();
// accent rings
ctx.fillStyle = '#C2358F'; ctx.fillRect(32, -12, 5, 24);
ctx.fillStyle = '#28B7A8'; ctx.fillRect(37, -12, 4, 24);
// collar
const g2 = ctx.createLinearGradient(0, -10, 0, 10);
g2.addColorStop(0, '#D8DADE'); g2.addColorStop(0.5, '#F5F6F7'); g2.addColorStop(1, '#75787E');
ctx.fillStyle = g2;
if (px) { ctx.fillStyle = '#DDE0E4'; }
else {
const g2 = ctx.createLinearGradient(0, -10, 0, 10);
g2.addColorStop(0, '#D8DADE'); g2.addColorStop(0.5, '#F5F6F7'); g2.addColorStop(1, '#75787E');
ctx.fillStyle = g2;
}
ctx.beginPath(); ctx.roundRect(41, -10, 6, 20, 1.5); ctx.fill();
// gold shaft
const g3 = ctx.createLinearGradient(0, -7, 0, 7);
g3.addColorStop(0, '#8F6B12'); g3.addColorStop(0.3, '#F4D876'); g3.addColorStop(0.6, '#D9AE33'); g3.addColorStop(1, '#7A5A0E');
let g3;
if (px) { g3 = '#E0B23C'; }
else {
g3 = ctx.createLinearGradient(0, -7, 0, 7);
g3.addColorStop(0, '#8F6B12'); g3.addColorStop(0.3, '#F4D876'); g3.addColorStop(0.6, '#D9AE33'); g3.addColorStop(1, '#7A5A0E');
}
ctx.fillStyle = g3;
ctx.fillRect(47, -6.5, 20, 13);
// notch rings
@@ -498,14 +408,6 @@ class Component extends DCLogic {
canvasRef = React.createRef();
onGravityInput = (e) => {
this.setState({ gravityScale: Number(e.target.value) });
};
onDampingInput = (e) => {
this.setState({ motionDamping: Number(e.target.value) });
};
renderVals() {
const active = this.state.material;
const materials = Object.keys(this.presets).map((id) => ({
@@ -518,12 +420,6 @@ class Component extends DCLogic {
return {
canvasRef: this.canvasRef,
materials,
gravityScale: this.state.gravityScale,
gravityScaleText: this.state.gravityScale.toFixed(2),
motionDamping: this.state.motionDamping,
motionDampingText: this.state.motionDamping.toFixed(3),
onGravityInput: this.onGravityInput,
onDampingInput: this.onDampingInput,
status: plugged ? '已连接 — 拖拽插头可拔出' : (this.state.dragging ? '拉扯会放出更多线,松手自动回收' : '拖拽插头试试 — 插进右侧插孔'),
statusBg: plugged ? '#26402F' : '#E9E7E2',
statusFg: plugged ? '#8FE0AE' : '#77746C'