feat(prototype): 更新线缆物理交互原型
This commit is contained in:
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="{"maxStretch":{"editor":"range","default":1.5,"min":1.5,"max":4,"step":0.1,"tsType":"number","section":"物理","description":"最大拉出长度(相对初始长度的倍数)"},"retractSpeed":{"editor":"range","default":0.09,"min":0.02,"max":0.3,"step":0.01,"tsType":"number","section":"物理","description":"松手后回收速度"},"gravityScale":{"editor":"range","default":1.28,"min":0.6,"max":2.2,"step":0.02,"tsType":"number","section":"重量","description":"线缆和插头的重力倍率"},"motionDamping":{"editor":"range","default":0.94,"min":0.86,"max":0.99,"step":0.005,"tsType":"number","section":"重量","description":"数值越低越沉,越不飘"}}">
|
||||
<script type="text/x-dc" data-dc-script data-props="{"maxStretch":{"editor":"range","default":4,"min":2,"max":8,"step":0.2,"tsType":"number","section":"物理","description":"最大拉出长度(相对初始长度的倍数)"},"retractSpeed":{"editor":"range","default":0.09,"min":0.02,"max":0.3,"step":0.01,"tsType":"number","section":"物理","description":"松手后回收速度"}}">
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user