mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 10:13:33 +00:00
input display and virtual joystick visualization
This commit is contained in:
15
index.html
15
index.html
@@ -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.
|
||||
|
||||
81
src/input.ts
81
src/input.ts
@@ -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 };
|
||||
}
|
||||
|
||||
44
src/main.ts
44
src/main.ts
@@ -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', () => {
|
||||
|
||||
58
style.css
58
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user