fix(manifold): keep input knob inside the circular area for mouse + gamepad

The on-screen circular input disc let both the mouse and the gamepad drive
the knob outside the visible circle.

- Gamepad: each stick axis was clamped to [0,1] independently, so a full
  diagonal push reached the square corner. Clamp the stick *vector* to the
  unit disc (radially symmetric) before mapping to [0,1]; covers both sticks.
- Mouse: the circular variant clamped in normalised [0,1]^2 but the canvas
  drew the knob across the full non-square panel against the inscribed circle,
  so the disc rendered as an ellipse that spilled past the rim. Map both the
  pointer and every drawn position (knob/pins/markers/trail/flash) through the
  inscribed-circle geometry, and disc-clamp the auto-drift.
This commit is contained in:
monkey-w1n5t0n 2026-06-29 00:14:49 +02:00
parent c986377b4c
commit 031c7f97ff
2 changed files with 76 additions and 31 deletions

View file

@ -72,18 +72,25 @@ export function Manifold({
const el = wrapRef.current; const el = wrapRef.current;
if (!el) return null; if (!el) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
let x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
let y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
if (stateRef.current.variant === 'circular') { if (stateRef.current.variant === 'circular') {
// Clamp to the unit disc centred at (0.5, 0.5). // Map the pointer relative to the *inscribed circle* the canvas draws (see
const dx = x - 0.5; // `drawRadius` below): a unit-disc vector around the centre, so the knob
const dy = y - 0.5; // tracks the cursor inside the disc and snaps to the rim outside it. This
const d = Math.hypot(dx, dy); // keeps the reachable area a true circle on a non-square surface (where a
if (d > 0.5) { // [0,1]² clamp would render as an ellipse spilling past the drawn rim).
x = 0.5 + (dx / d) * 0.5; const radius = Math.min(r.width, r.height) / 2 - 2;
y = 0.5 + (dy / d) * 0.5; if (radius <= 0) return [0.5, 0.5];
let vx = (e.clientX - r.left - r.width / 2) / radius;
let vy = (r.height / 2 - (e.clientY - r.top)) / radius; // screen y is down; flip so up = +
const mag = Math.hypot(vx, vy);
if (mag > 1) {
vx /= mag;
vy /= mag;
} }
return [0.5 + 0.5 * vx, 0.5 + 0.5 * vy];
} }
const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
return [x, y]; return [x, y];
}; };
@ -170,6 +177,13 @@ export function Manifold({
const cx = W / 2; const cx = W / 2;
const cy = H / 2; const cy = H / 2;
const radius = Math.min(W, H) / 2 - 2; const radius = Math.min(W, H) / 2 - 2;
// Map a normalised [0,1] coord to a screen pixel. The rectangular variant
// spreads [0,1]² across the full surface; the circular variant maps the
// central unit disc onto the inscribed circle so the [0,1]² clamp lines up
// exactly with the drawn rim (and stays a true circle when W ≠ H). x and y
// map independently (the circular transform is separable).
const sx = circular ? (nx: number) => cx + (nx - 0.5) * 2 * radius : (nx: number) => nx * W;
const sy = circular ? (ny: number) => cy - (ny - 0.5) * 2 * radius : (ny: number) => (1 - ny) * H;
if (fl && !draggingRef.current && !fz) { if (fl && !draggingRef.current && !fz) {
let [x, y] = p; let [x, y] = p;
@ -180,6 +194,17 @@ export function Manifold({
if (y < 0.05 || y > 0.95) d.vy *= -1; if (y < 0.05 || y > 0.95) d.vy *= -1;
x = Math.max(0.05, Math.min(0.95, x)); x = Math.max(0.05, Math.min(0.95, x));
y = Math.max(0.05, Math.min(0.95, y)); y = Math.max(0.05, Math.min(0.95, y));
if (circular) {
// Keep the auto-drift inside the disc too, so it never wanders past
// the drawn rim into the corners.
const dx = x - 0.5;
const dy = y - 0.5;
const dd = Math.hypot(dx, dy);
if (dd > 0.5) {
x = 0.5 + (dx / dd) * 0.5;
y = 0.5 + (dy / dd) * 0.5;
}
}
onMove(x, y); onMove(x, y);
} }
@ -228,12 +253,12 @@ export function Manifold({
} }
} }
const px = p[0] * W; const px = sx(p[0]);
const py = (1 - p[1]) * H; const py = sy(p[1]);
for (const pin of pn) { for (const pin of pn) {
const ppx = pin.x * W; const ppx = sx(pin.x);
const ppy = (1 - pin.y) * H; const ppy = sy(pin.y);
ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)'; ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)';
ctx.beginPath(); ctx.beginPath();
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2); ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
@ -248,8 +273,8 @@ export function Manifold({
// Feedback markers: positive = filled accent dot, negative = open red // Feedback markers: positive = filled accent dot, negative = open red
// ring. Plotted at the input location each verdict was given (session). // ring. Plotted at the input location each verdict was given (session).
for (const m of mk) { for (const m of mk) {
const mx = m.x * W; const mx = sx(m.x);
const my = (1 - m.y) * H; const my = sy(m.y);
if (m.polarity === 'positive') { if (m.polarity === 'positive') {
ctx.fillStyle = 'rgba(255,106,0,0.9)'; ctx.fillStyle = 'rgba(255,106,0,0.9)';
ctx.beginPath(); ctx.beginPath();
@ -280,8 +305,8 @@ export function Manifold({
const alpha = (1 - age / LIFE) * 0.5; const alpha = (1 - age / LIFE) * 0.5;
ctx.strokeStyle = `rgba(0,204,255,${alpha})`; ctx.strokeStyle = `rgba(0,204,255,${alpha})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(a.x * W, (1 - a.y) * H); ctx.moveTo(sx(a.x), sy(a.y));
ctx.lineTo(b.x * W, (1 - b.y) * H); ctx.lineTo(sx(b.x), sy(b.y));
ctx.stroke(); ctx.stroke();
} }
@ -320,8 +345,8 @@ export function Manifold({
placedRef.current = null; placedRef.current = null;
} else { } else {
const a = 1 - age / 900; const a = 1 - age / 900;
const mx = placed.x * W; const mx = sx(placed.x);
const my = (1 - placed.y) * H; const my = sy(placed.y);
ctx.strokeStyle = `rgba(0,204,255,${a})`; ctx.strokeStyle = `rgba(0,204,255,${a})`;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();

View file

@ -117,12 +117,17 @@ export class GamepadSource extends BaseSource {
for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent
return n; return n;
} }
// Left stick = axes 0,1; right stick = axes 2,3 (standard mapping). // Left stick = axes 0,1; right stick = axes 2,3 (standard mapping). Each
out[offset] = remap(pad.axes[0] ?? 0); // stick is clamped to the unit disc (not per-axis), so a full diagonal push
out[offset + 1] = remap(-(pad.axes[1] ?? 0)); // flip: up = 1 // lands ON the circular boundary rather than the square corner — matching
// the on-screen circular input area and the engine's own circular clamp.
const [lx, ly] = clampStick(pad.axes[0] ?? 0, -(pad.axes[1] ?? 0)); // flip: up = 1
out[offset] = lx;
out[offset + 1] = ly;
if (n === 4) { if (n === 4) {
out[offset + 2] = remap(pad.axes[2] ?? 0); const [rx, ry] = clampStick(pad.axes[2] ?? 0, -(pad.axes[3] ?? 0));
out[offset + 3] = remap(-(pad.axes[3] ?? 0)); out[offset + 2] = rx;
out[offset + 3] = ry;
} }
return n; return n;
} }
@ -186,11 +191,26 @@ export class GamepadSource extends BaseSource {
} }
} }
/** Map a [-1,1] stick axis (with radial deadzone) to [0,1]. */ /** Apply the per-axis deadzone, returning a signed value in [-1,1]. */
function remap(v: number): number { function deadzoneAxis(v: number): number {
let x = v; if (v > -DEADZONE && v < DEADZONE) return 0;
if (x > -DEADZONE && x < DEADZONE) x = 0; const x = v > 0 ? (v - DEADZONE) / (1 - DEADZONE) : (v + DEADZONE) / (1 - DEADZONE);
else x = x > 0 ? (x - DEADZONE) / (1 - DEADZONE) : (x + DEADZONE) / (1 - DEADZONE); return x < -1 ? -1 : x > 1 ? 1 : x;
const out = (x + 1) / 2; }
return out < 0 ? 0 : out > 1 ? 1 : out;
/**
* Map a raw stick (rawX, rawY with y already flipped so up = +) to two [0,1]
* axes, clamping the stick *vector* to the unit disc first. This keeps full
* deflection on the circular boundary in every direction (radially symmetric),
* instead of letting a diagonal reach the square corner.
*/
function clampStick(rawX: number, rawY: number): [number, number] {
let x = deadzoneAxis(rawX);
let y = deadzoneAxis(rawY);
const mag = Math.hypot(x, y);
if (mag > 1) {
x /= mag;
y /= mag;
}
return [0.5 + 0.5 * x, 0.5 + 0.5 * y];
} }