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:
parent
c986377b4c
commit
031c7f97ff
2 changed files with 76 additions and 31 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue