Skip to content

Commit c197c2e

Browse files
committed
bitmap 3D PFP: detect-it-style input classification + first-input refinement
Replaces the single-shot pointer:coarse / ontouchstart probe (which broke for iPad+Pencil, Surface, Android desktop mode) with the detect-it library's documented heuristic, inlined as a few lines: hasTouch = navigator.maxTouchPoints > 0 || 'ontouchstart' in window hasFinePointer = matchMedia('(pointer: fine)').matches anyHover = matchMedia('(any-hover: hover)').matches anyFinePointer = matchMedia('(any-pointer: fine)').matches hybrid = hasTouch && (hasFinePointer || anyHover || anyFinePointer) touchOnly = hasTouch mouseOnly = neither Initial overlay state at PFP entry maps to: mouseOnly -> no touch UI (Space + mouse only) touchOnly -> touch UI shown hybrid -> touch UI shown (last-input refinement flips it) First-input refinement: a pointerdown listener reads PointerEvent.pointerType ('mouse' | 'touch' | 'pen') and updates lastInput. The keydown handler also flips lastInput to 'kbm'. The showTouchUi flag follows lastInput, so a hybrid user typing on the keyboard hides the jump button; touching the canvas brings it back. Canvas click no longer requests pointer lock when lastInput is 'touch' -- iOS Safari rejects pointer lock anyway, and intercepting the tap would steal the touch-look gesture from the user's first interaction. Pattern lifted from Unity/Unreal "control schemes with automatic switching based on the last input used" + detect-it (rafgraph/detect-it) + MDN's PointerEvent.pointerType guidance. References in the prior research agent output.
1 parent edf49f1 commit c197c2e

1 file changed

Lines changed: 53 additions & 6 deletions

File tree

frontend/src/app/components/_ordpool/digital-artifact-viewer/bitmap-viewer/bitmap-3d-renderer.component.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,38 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
449449
const playerDirection = new THREE.Vector3();
450450
let playerOnFloor = false;
451451

452+
// ---- Input-class detection (detect-it pattern, inlined) -----------------
453+
// Single-shot probes are unreliable (iPad+Pencil reports pointer:fine;
454+
// some Android desktop-mode sessions skip ontouchstart; Surface tablets
455+
// are both). Triangulate across 4 primitives at mount, then refine on
456+
// first real input via PointerEvent.pointerType.
457+
// mouseOnly -> WASD overlay, no jump button
458+
// touchOnly -> jump button + joystick
459+
// hybrid -> touch UI initially; first key press hides it; first
460+
// touch shows it again. Both schemes stay functional.
461+
const hasTouch = (navigator.maxTouchPoints || 0) > 0 || ('ontouchstart' in window);
462+
const hasFinePointer = window.matchMedia('(pointer: fine)').matches;
463+
const anyHover = window.matchMedia('(any-hover: hover)').matches;
464+
const anyFinePointer = window.matchMedia('(any-pointer: fine)').matches;
465+
const inputClass: 'mouseOnly' | 'touchOnly' | 'hybrid' =
466+
hasTouch && (hasFinePointer || anyHover || anyFinePointer) ? 'hybrid'
467+
: hasTouch ? 'touchOnly'
468+
: 'mouseOnly';
469+
// Last-used input determines which overlay is visible. Refined per-event.
470+
let lastInput: 'kbm' | 'touch' = inputClass === 'mouseOnly' ? 'kbm' : 'touch';
471+
const setLastInput = (t: 'kbm' | 'touch') => {
472+
if (lastInput === t) return;
473+
lastInput = t;
474+
// Only flip the UI flag if we're in PFP (no point updating the toggle
475+
// for a key press received while orbiting).
476+
if (state === 'pfp') {
477+
this.zone.run(() => {
478+
this.showTouchUi = (t === 'touch');
479+
this.cdr.markForCheck();
480+
});
481+
}
482+
};
483+
452484
const keyStates: Record<string, boolean> = {};
453485
const KEY_ALIASES: Record<string, string> = {
454486
ArrowUp: 'KeyW', ArrowDown: 'KeyS', ArrowLeft: 'KeyA', ArrowRight: 'KeyD',
@@ -458,13 +490,17 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
458490
const code = KEY_ALIASES[e.code] ?? e.code;
459491
keyStates[code] = true;
460492
if (code === 'Space') e.preventDefault();
493+
setLastInput('kbm');
461494
};
462495
const onKeyUp = (e: KeyboardEvent) => {
463496
const code = KEY_ALIASES[e.code] ?? e.code;
464497
keyStates[code] = false;
465498
};
466499
const onCanvasClick = () => {
467500
if (state !== 'pfp') return;
501+
// Don't request pointer lock from a touch tap -- iOS Safari refuses
502+
// anyway and we don't want to override the touch-look gesture.
503+
if (lastInput === 'touch') return;
468504
if (document.pointerLockElement !== renderer.domElement) {
469505
renderer.domElement.requestPointerLock?.();
470506
}
@@ -476,10 +512,17 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
476512
camera.rotation.x -= e.movementY / 500;
477513
camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x));
478514
};
515+
// First-input refinement via PointerEvent.pointerType. Fires for every
516+
// pointer event regardless of source; cheaper than guessing.
517+
const onPointerDown = (e: PointerEvent) => {
518+
if (state !== 'pfp') return;
519+
setLastInput(e.pointerType === 'touch' ? 'touch' : 'kbm');
520+
};
479521
window.addEventListener('keydown', onKeyDown);
480522
window.addEventListener('keyup', onKeyUp);
481523
renderer.domElement.addEventListener('click', onCanvasClick);
482524
document.addEventListener('mousemove', onMouseMove);
525+
renderer.domElement.addEventListener('pointerdown', onPointerDown);
483526

484527
// ---- Touch controls (mobile) ----------------------------------------
485528
// Left half of the canvas = joystick (drag from anywhere; the anchor is
@@ -603,6 +646,7 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
603646
window.removeEventListener('keyup', onKeyUp);
604647
renderer.domElement.removeEventListener('click', onCanvasClick);
605648
document.removeEventListener('mousemove', onMouseMove);
649+
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
606650
renderer.domElement.removeEventListener('touchstart', onTouchStart);
607651
renderer.domElement.removeEventListener('touchmove', onTouchMove);
608652
renderer.domElement.removeEventListener('touchend', onTouchEndOrCancel);
@@ -888,13 +932,16 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
888932
playerOnFloor = false;
889933
physicsClock.getDelta();
890934
state = 'pfp';
891-
// Always show the touch UI in PFP. Pointer-coarse detection
892-
// is unreliable on some browsers; the jump button doesn't
893-
// hurt desktop (you can click it too), and the joystick
894-
// base/knob only render on actual finger contact via the
895-
// touchstart handler.
935+
// Initial overlay visibility follows the device class:
936+
// mouseOnly -> no touch UI (Space + mouse only)
937+
// touchOnly -> touch UI shown
938+
// hybrid -> touch UI shown initially, hidden on first
939+
// key press (lastInput refinement)
940+
// setLastInput won't trigger an update while state isn't
941+
// 'pfp', so we set the flag directly here for the initial
942+
// render. Subsequent input events flip it via setLastInput.
896943
this.zone.run(() => {
897-
this.showTouchUi = true;
944+
this.showTouchUi = (lastInput === 'touch');
898945
this.cdr.markForCheck();
899946
setTimeout(wireJumpButton, 0);
900947
});

0 commit comments

Comments
 (0)