input display and virtual joystick visualization

This commit is contained in:
Brandon Johnson
2026-01-26 11:56:01 -05:00
parent 28fb3e7996
commit 6385a36ed6
4 changed files with 180 additions and 18 deletions

View File

@@ -85,10 +85,17 @@
<span>Input Falloff <output id="input-falloff-value">1.5</output></span>
<input id="input-falloff" type="range" min="1" max="2" step="0.05" value="1.5" />
</label>
<div id="input-falloff-curve-wrap" class="response-curve">
<svg id="input-falloff-curve" viewBox="0 0 100 100" role="img" aria-hidden="true">
<path id="input-falloff-path" d="M 0 100 L 100 0"></path>
</svg>
<div class="input-falloff-visuals">
<div id="input-falloff-curve-wrap" class="response-curve">
<svg id="input-falloff-curve" viewBox="0 0 100 100" role="img" aria-hidden="true">
<path id="input-falloff-path" d="M 0 100 L 100 0"></path>
</svg>
</div>
<div id="input-preview" class="input-preview" aria-hidden="true">
<div class="input-preview-grid"></div>
<div id="input-raw-dot" class="input-dot raw"></div>
<div id="input-processed-dot" class="input-dot processed"></div>
</div>
</div>
<div class="control-hint">
A lower value makes joystick input more linear. Higher makes small adjustments more precise.

View File

@@ -37,6 +37,7 @@ export class Input {
this.gyroSensitivity = 25;
this.joystickScale = 1;
this.inputFalloff = 1.5;
this.touchPreview = false;
this.touchRoot = document.getElementById('touch-controls');
this.joystickEl = this.touchRoot?.querySelector?.('.joystick') ?? null;
@@ -64,6 +65,15 @@ export class Input {
if (!this.isTouchLayerActive()) {
return;
}
if (this.isOverlayVisible() && this.touchPreview) {
if (!(event.target instanceof HTMLElement)) {
return;
}
const onJoystick = event.target.closest('.joystick');
if (!onJoystick) {
return;
}
}
if (this.touch.pointerId !== null) {
return;
}
@@ -71,13 +81,21 @@ export class Input {
event.preventDefault();
this.touch.pointerId = event.pointerId;
this.touch.active = true;
this.touch.centerX = event.clientX;
this.touch.centerY = event.clientY;
if (this.isOverlayVisible() && this.touchPreview && this.joystickEl) {
const rect = this.joystickEl.getBoundingClientRect();
this.touch.centerX = rect.left + rect.width / 2;
this.touch.centerY = rect.top + rect.height / 2;
} else {
this.touch.centerX = event.clientX;
this.touch.centerY = event.clientY;
}
this.touch.value.x = 0;
this.touch.value.y = 0;
this.touchRoot?.setPointerCapture?.(event.pointerId);
this.showJoystickAt(event.clientX, event.clientY);
if (!this.isOverlayVisible() || !this.touchPreview) {
this.showJoystickAt(event.clientX, event.clientY);
}
this.updateJoystickHandle(0, 0);
},
@@ -105,8 +123,8 @@ export class Input {
ny = py / MAX_RADIUS;
}
this.touch.value.x = clamp(nx * 2, -1, 1);
this.touch.value.y = clamp(ny * 2, -1, 1);
this.touch.value.x = clamp(nx * 1.5, -1, 1);
this.touch.value.y = clamp(ny * 1.5, -1, 1);
this.updateJoystickHandle(px, py);
},
@@ -258,7 +276,7 @@ export class Input {
if (!this.touchRoot) {
return false;
}
if (this.isOverlayVisible()) {
if (this.isOverlayVisible() && !this.touchPreview) {
return false;
}
return true;
@@ -269,19 +287,30 @@ export class Input {
return;
}
const shouldEnable = mode === 'touch' && this.hasTouch && !this.isOverlayVisible();
if (!shouldEnable) {
const overlayVisible = this.isOverlayVisible();
const shouldEnable = mode === 'touch' && this.hasTouch && !overlayVisible;
const shouldPreview = mode === 'touch' && this.hasTouch && overlayVisible && this.touchPreview;
if (!shouldEnable && !shouldPreview) {
if (this.touch.active) {
this.endTouch();
}
this.touchRoot.classList.remove('active');
this.touchRoot.classList.remove('preview');
this.touchRoot.classList.add('hidden');
this.joystickEl?.classList.add('hidden');
return;
}
this.touchRoot.classList.remove('hidden');
this.touchRoot.classList.add('active');
if (shouldEnable) {
this.touchRoot.classList.add('active');
this.touchRoot.classList.remove('preview');
this.joystickEl?.classList.add('hidden');
} else {
this.touchRoot.classList.remove('active');
this.touchRoot.classList.add('preview');
this.joystickEl?.classList.remove('hidden');
}
}
showJoystickAt(x, y) {
@@ -306,6 +335,9 @@ export class Input {
}
hideJoystick() {
if (this.touchPreview && this.isOverlayVisible()) {
return;
}
this.joystickEl?.classList.add('hidden');
}
@@ -367,6 +399,29 @@ export class Input {
this.inputFalloff = clamp(value, 1, 2);
}
setTouchPreview(enabled) {
this.touchPreview = !!enabled;
this.syncTouchLayer(this.getControlMode());
if (!this.touchPreview && this.touch.active) {
this.endTouch();
}
}
getRawInputPreview() {
if (this.hasTouch && this.touch.active) {
return { x: this.touch.value.x, y: this.touch.value.y };
}
const padStick = this.getGamepadStick();
if (padStick && (Math.abs(padStick.x) > 0 || Math.abs(padStick.y) > 0)) {
return padStick;
}
return null;
}
applyInputFalloffToStick(stick) {
return applyInputFalloff(stick, this.inputFalloff);
}
wasPressed(code) {
if (this.pressed.has(code)) {
this.pressed.delete(code);
@@ -613,12 +668,12 @@ function readPadStick(pad) {
}
function applyInputFalloff(stick, power) {
const magnitude = Math.hypot(stick.x, stick.y);
if (magnitude <= 0) {
const maxAxis = Math.max(Math.abs(stick.x), Math.abs(stick.y));
if (maxAxis <= 0) {
return stick;
}
const clamped = Math.min(1, magnitude);
const clamped = Math.min(1, maxAxis);
const eased = Math.pow(clamped, power);
const scale = eased / magnitude;
const scale = eased / maxAxis;
return { x: stick.x * scale, y: stick.y * scale };
}

View File

@@ -76,6 +76,9 @@ const inputFalloffInput = document.getElementById('input-falloff') as HTMLInputE
const inputFalloffValue = document.getElementById('input-falloff-value') as HTMLOutputElement | null;
const inputFalloffCurveWrap = document.getElementById('input-falloff-curve-wrap') as HTMLElement | null;
const inputFalloffPath = document.getElementById('input-falloff-path') as SVGPathElement | null;
const inputPreview = document.getElementById('input-preview') as HTMLElement | null;
const inputRawDot = document.getElementById('input-raw-dot') as HTMLElement | null;
const inputProcessedDot = document.getElementById('input-processed-dot') as HTMLElement | null;
const startButton = document.getElementById('start') as HTMLButtonElement;
const resumeButton = document.getElementById('resume') as HTMLButtonElement;
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
@@ -113,6 +116,7 @@ function setOverlayVisible(visible: boolean) {
canvas.style.pointerEvents = visible ? 'none' : 'auto';
document.body.classList.toggle('gameplay-active', !visible);
updateMobileMenuButtonVisibility();
syncTouchPreviewVisibility();
}
const STAGE_FADE_MS = 333;
@@ -705,6 +709,31 @@ function updateFalloffCurve(power: number) {
inputFalloffPath.setAttribute('d', path);
}
function updateInputPreview() {
if (!inputPreview || !inputRawDot || !inputProcessedDot) {
return;
}
const raw = game.input?.getRawInputPreview?.();
if (!raw) {
inputRawDot.style.opacity = '0';
inputProcessedDot.style.opacity = '0';
return;
}
const processed = game.input?.applyInputFalloffToStick?.(raw) ?? raw;
inputRawDot.style.opacity = '1';
inputProcessedDot.style.opacity = '1';
const placeDot = (dot: HTMLElement, value: { x: number; y: number }) => {
const clampedX = clamp(value.x, -1, 1);
const clampedY = clamp(value.y, -1, 1);
const x = ((clampedX + 1) / 2) * 100;
const y = ((clampedY + 1) / 2) * 100;
dot.style.left = `${x}%`;
dot.style.top = `${y}%`;
};
placeDot(inputRawDot, raw);
placeDot(inputProcessedDot, processed);
}
function updateControlModeSettingsVisibility() {
if (!controlModeSelect || !controlModeSettings) {
return;
@@ -720,6 +749,7 @@ function updateControlModeSettingsVisibility() {
touchSettings?.classList.add('hidden');
inputFalloffBlock?.classList.toggle('hidden', !hasController);
inputFalloffCurveWrap?.classList.toggle('hidden', !hasController);
inputPreview?.classList.toggle('hidden', !hasController);
return;
}
const mode = controlModeSelect.value;
@@ -727,7 +757,9 @@ function updateControlModeSettingsVisibility() {
touchSettings?.classList.toggle('hidden', mode !== 'touch');
const showFalloff = mode === 'touch' || hasController;
inputFalloffBlock?.classList.toggle('hidden', !showFalloff);
inputFalloffCurveWrap?.classList.toggle('hidden', mode === 'gyro');
const hideCurve = mode === 'gyro';
inputFalloffCurveWrap?.classList.toggle('hidden', hideCurve);
inputPreview?.classList.toggle('hidden', hideCurve);
}
function maybeUpdateControlModeSettings(now: number) {
@@ -738,11 +770,19 @@ function maybeUpdateControlModeSettings(now: number) {
updateControlModeSettingsVisibility();
}
function syncTouchPreviewVisibility() {
const overlayVisible = !overlay.classList.contains('hidden');
const mode = controlModeSelect?.value;
const shouldPreview = overlayVisible && mode === 'touch';
game.input?.setTouchPreview?.(shouldPreview);
}
function renderFrame(now: number) {
requestAnimationFrame(renderFrame);
updateGyroHelper();
maybeUpdateControlModeSettings(now);
updateInputPreview();
if (!running || !viewerInput || !camera) {
lastTime = now;
@@ -915,6 +955,7 @@ bindRangeControl(
updateControlModeSettingsVisibility();
updateFalloffCurve(game.input?.inputFalloff ?? 1.5);
syncTouchPreviewVisibility();
smb2ModeSelect?.addEventListener('change', () => {
updateSmb2ModeFields();
@@ -934,6 +975,7 @@ gameSourceSelect?.addEventListener('change', () => {
controlModeSelect?.addEventListener('change', () => {
updateControlModeSettingsVisibility();
syncTouchPreviewVisibility();
});
window.addEventListener('gamepadconnected', () => {

View File

@@ -63,6 +63,16 @@ body.gameplay-active {
pointer-events: auto;
}
.touch-controls.preview {
inset: auto;
left: 16px;
bottom: 16px;
width: min(46vw, 240px);
height: min(46vw, 240px);
pointer-events: auto;
z-index: 11;
}
.touch-controls.hidden {
display: none;
}
@@ -79,6 +89,12 @@ body.gameplay-active {
display: none;
}
.touch-controls.preview .joystick {
left: 50%;
top: 50%;
pointer-events: auto;
}
.touch-controls .joystick-base {
position: absolute;
inset: 0;
@@ -272,6 +288,48 @@ body.gameplay-active {
stroke-linejoin: round;
}
.input-falloff-visuals {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.input-preview {
position: relative;
width: 120px;
height: 120px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(8, 8, 12, 0.6);
overflow: hidden;
}
.input-preview-grid {
position: absolute;
inset: 12px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.input-dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 999px;
transform: translate(-50%, -50%);
}
.input-dot.raw {
background: #fff;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
}
.input-dot.processed {
background: rgba(255, 77, 61, 0.95);
box-shadow: 0 0 6px rgba(255, 77, 61, 0.75);
}
.slider-field span {
display: flex;
justify-content: space-between;