Skip to content

Commit ac818d3

Browse files
committed
bitmap 3D PFP: twin-stick mobile controls (move + look) via nipplejs
Two nipplejs instances in 'static' mode -- left for movement, right for camera look. Replaces the right-half hand-rolled touch-look code. Look integration uses the rate-of-change pattern (canonical for joystick-driven cameras): cache the latest stick vector in the move handler, integrate per animation frame with dt. The previous mouse-style "apply delta per event" model doesn't translate to joysticks (the stick fires continuously while held; events arrive at the lib's internal rate, not at frame rate). Constants per the research-agent's "what shipped twin-stick games use": YAW_SPEED 2.5 rad/s at full deflection PITCH_SPEED 1.8 rad/s at full deflection LOOK_DEADZONE 0.15 of stick vector magnitude INVERT_LOOK_Y false (matches CoD Mobile / PUBG Mobile defaults) Both sticks centered in their zones (left/top 50%) so the visual anchors at the middle of each lower quadrant -- muscle memory friendly. Stuck-knob defence (visibilitychange -> destroyJoysticks, re-init when returning to PFP) covers both instances. Removed: right-half onTouchStart/onTouchMove/onTouchEndOrCancel handlers (~50 lines). The lookStick replaces them cleanly.
1 parent b7e7f90 commit ac818d3

1 file changed

Lines changed: 81 additions & 84 deletions

File tree

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

Lines changed: 81 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, E
44
selector: 'app-bitmap-3d-renderer',
55
template: `
66
<div #host class="bitmap3d-host">
7-
<div #joyZone class="touch-joy-zone"></div>
7+
<div #joyZoneL class="touch-joy-zone touch-joy-zone-left"></div>
8+
<div #joyZoneR class="touch-joy-zone touch-joy-zone-right"></div>
89
<button type="button" #jumpBtn class="touch-jump" aria-label="Jump">▲</button>
910
</div>`,
1011
styles: [`
1112
:host { display: block; width: 100%; aspect-ratio: 1 / 1; max-width: 600px; }
1213
.bitmap3d-host { position: relative; width: 100%; height: 100%; }
1314
.bitmap3d-host > canvas { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; }
1415
15-
/* Touch zone for nipplejs joystick: lower-left quadrant. nipplejs
16-
renders its own DOM/canvas inside this div in 'dynamic' mode -- we
17-
don't manage the visual, that's the whole point. */
16+
/* Twin-stick zones. nipplejs renders its own canvas inside each zone
17+
(mode 'static' -- fixed origin, the way every shipped twin-stick
18+
game does it for muscle memory). */
1819
.touch-joy-zone {
1920
position: absolute;
20-
left: 0;
2121
bottom: 0;
2222
width: 50%;
2323
height: 50%;
@@ -28,6 +28,8 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, E
2828
z-index: 2;
2929
display: none;
3030
}
31+
.touch-joy-zone-left { left: 0; }
32+
.touch-joy-zone-right { right: 0; }
3133
.touch-jump {
3234
position: absolute;
3335
right: 16px;
@@ -51,9 +53,7 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, E
5153
display: none;
5254
}
5355
.touch-jump:active { background: rgba(0, 0, 0, 0.7); }
54-
/* Both visible when the host carries pfp-on + touch-on. Class
55-
toggling happens via direct DOM (no Angular binding -- CD path
56-
wasn't reliable on the user's mobile browser). */
56+
/* All three visible when the host carries pfp-on + touch-on. */
5757
.bitmap3d-host.pfp-on.touch-on .touch-joy-zone { display: block; }
5858
.bitmap3d-host.pfp-on.touch-on .touch-jump { display: flex; }
5959
`],
@@ -66,7 +66,8 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
6666

6767
@ViewChild('host', { static: true }) host!: ElementRef<HTMLDivElement>;
6868
@ViewChild('jumpBtn', { static: true }) jumpBtn!: ElementRef<HTMLButtonElement>;
69-
@ViewChild('joyZone', { static: true }) joyZone!: ElementRef<HTMLDivElement>;
69+
@ViewChild('joyZoneL', { static: true }) joyZoneL!: ElementRef<HTMLDivElement>;
70+
@ViewChild('joyZoneR', { static: true }) joyZoneR!: ElementRef<HTMLDivElement>;
7071

7172
// True on touch-capable devices when in PFP mode -- shows the jump button
7273
// overlay. Joystick + look areas are invisible (just touch regions).
@@ -510,89 +511,87 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
510511
document.addEventListener('mousemove', onMouseMove);
511512
renderer.domElement.addEventListener('pointerdown', onPointerDown);
512513

513-
// ---- Touch controls (mobile) ----------------------------------------
514-
// Joystick = nipplejs in 'dynamic' mode bound to the .touch-joy-zone
515-
// div (lower-left quadrant of the canvas). nipplejs renders its own
516-
// visual (canvas-based) and handles all the dead-zone / multi-touch
517-
// ID routing / pointercancel quirks that we don't want to rebuild.
518-
// Right half of the canvas = look (mouse-look equivalent, touch-driven).
519-
// Jump button = plain <button> with touchstart/mousedown handlers.
514+
// ---- Twin-stick mobile controls ------------------------------------
515+
// Left stick (movement) + right stick (look) via two nipplejs
516+
// instances in 'static' mode. Cached vectors are integrated per-frame
517+
// with dt (rate-of-change look) rather than applied directly in the
518+
// event handler -- the latter pattern is event-rate-dependent and
519+
// produces jittery rotation.
520520
const joy = { fwd: 0, right: 0 };
521+
const look = { x: 0, y: 0 };
521522
let jumpPulse = false;
522-
let rightId: number | null = null;
523-
let rightLastX = 0, rightLastY = 0;
524-
let nipple: { destroy: () => void } | null = null;
525-
526-
const initJoystick = async () => {
527-
if (nipple) return;
523+
let nippleL: { destroy: () => void } | null = null;
524+
let nippleR: { destroy: () => void } | null = null;
525+
526+
// Tuned per the research-agent's "what shipped twin-stick games use":
527+
// 2.5 rad/s yaw, 1.8 rad/s pitch at full deflection, 0.15 deadzone.
528+
const YAW_SPEED = 2.5;
529+
const PITCH_SPEED = 1.8;
530+
const LOOK_DEADZONE = 0.15;
531+
const INVERT_LOOK_Y = false;
532+
533+
const initJoysticks = async () => {
534+
if (nippleL && nippleR) return;
528535
const { default: nipplejs } = await import('nipplejs');
529-
// Cast to any -- the lib's TS types are vague about the event payload.
530-
const manager: any = (nipplejs as any).create({
531-
zone: this.joyZone.nativeElement,
532-
mode: 'dynamic',
536+
// Left stick: movement. Centered in the left zone div (50% across
537+
// the zone from each edge).
538+
const moveStick: any = (nipplejs as any).create({
539+
zone: this.joyZoneL.nativeElement,
540+
mode: 'static',
541+
position: { left: '50%', top: '50%' },
533542
color: '#FF9900',
534543
size: 120,
535-
threshold: 0.05,
536544
});
537-
manager.on('move', (_e: unknown, data: any) => {
538-
// data.vector is in [-1, 1] each axis; screen +Y is down so we
539-
// negate Y for forward.
540-
joy.right = data.vector.x;
541-
joy.fwd = -data.vector.y;
545+
moveStick.on('move', (_e: unknown, d: any) => {
546+
// nipplejs vector y is positive UP (screen-inverted from CSS y).
547+
joy.right = d.vector.x;
548+
joy.fwd = d.vector.y;
549+
});
550+
moveStick.on('end', () => { joy.fwd = 0; joy.right = 0; });
551+
nippleL = moveStick;
552+
553+
// Right stick: look.
554+
const lookStick: any = (nipplejs as any).create({
555+
zone: this.joyZoneR.nativeElement,
556+
mode: 'static',
557+
position: { left: '50%', top: '50%' },
558+
color: '#FF9900',
559+
size: 120,
542560
});
543-
manager.on('end', () => {
544-
joy.fwd = 0;
545-
joy.right = 0;
561+
lookStick.on('move', (_e: unknown, d: any) => {
562+
look.x = d.vector.x;
563+
look.y = d.vector.y;
546564
});
547-
nipple = manager;
565+
lookStick.on('end', () => { look.x = 0; look.y = 0; });
566+
nippleR = lookStick;
548567
};
549-
const destroyJoystick = () => {
550-
if (!nipple) return;
551-
try { nipple.destroy(); } catch { /* idempotent */ }
552-
nipple = null;
553-
joy.fwd = 0;
554-
joy.right = 0;
568+
const destroyJoysticks = () => {
569+
if (nippleL) { try { nippleL.destroy(); } catch { /* idempotent */ } nippleL = null; }
570+
if (nippleR) { try { nippleR.destroy(); } catch { /* idempotent */ } nippleR = null; }
571+
joy.fwd = 0; joy.right = 0;
572+
look.x = 0; look.y = 0;
555573
};
556574
// Stuck-knob defence (nipplejs #61): rebuild on app-switch.
557575
const onVisibility = () => {
558-
if (document.visibilityState === 'hidden') destroyJoystick();
559-
else if (state === 'pfp') void initJoystick();
576+
if (document.visibilityState === 'hidden') destroyJoysticks();
577+
else if (state === 'pfp') void initJoysticks();
560578
};
561579
document.addEventListener('visibilitychange', onVisibility);
562580

563-
// Right-half touch look. Picks up touches that DIDN'T start in the
564-
// nipplejs zone (the zone's pointer-events absorb left-quadrant
565-
// touches first).
566-
const onTouchStart = (e: TouchEvent) => {
567-
if (state !== 'pfp') return;
568-
for (const t of Array.from(e.changedTouches)) {
569-
if (rightId !== null) continue;
570-
rightId = t.identifier;
571-
rightLastX = t.clientX;
572-
rightLastY = t.clientY;
573-
}
574-
};
575-
const onTouchMove = (e: TouchEvent) => {
576-
if (state !== 'pfp') return;
577-
if (rightId !== null) e.preventDefault();
578-
for (const t of Array.from(e.changedTouches)) {
579-
if (t.identifier !== rightId) continue;
580-
camera.rotation.y -= (t.clientX - rightLastX) / 4 * (Math.PI / 180);
581-
camera.rotation.x -= (t.clientY - rightLastY) / 4 * (Math.PI / 180);
582-
camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x));
583-
rightLastX = t.clientX;
584-
rightLastY = t.clientY;
585-
}
586-
};
587-
const onTouchEndOrCancel = (e: TouchEvent) => {
588-
for (const t of Array.from(e.changedTouches)) {
589-
if (t.identifier === rightId) rightId = null;
590-
}
581+
// Per-frame look integration (rate-of-change). Runs each rAF tick
582+
// while in PFP. Yaw and pitch advance proportional to stick
583+
// deflection and elapsed time, NOT to event arrival rate.
584+
const lookClock = new THREE.Clock();
585+
const applyLookStick = () => {
586+
const dt = lookClock.getDelta();
587+
const lx = Math.abs(look.x) > LOOK_DEADZONE ? look.x : 0;
588+
const ly = Math.abs(look.y) > LOOK_DEADZONE ? look.y : 0;
589+
if (lx === 0 && ly === 0) return;
590+
camera.rotation.y -= lx * YAW_SPEED * dt;
591+
// nipplejs y is positive UP. Stick up -> look up (positive pitch).
592+
camera.rotation.x += (INVERT_LOOK_Y ? -ly : ly) * PITCH_SPEED * dt;
593+
camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x));
591594
};
592-
renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: true });
593-
renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
594-
renderer.domElement.addEventListener('touchend', onTouchEndOrCancel);
595-
renderer.domElement.addEventListener('touchcancel', onTouchEndOrCancel);
596595

597596
// Jump button -- plain HTML button, visibility gated by host classes.
598597
const triggerJump = (e?: Event) => { e?.preventDefault?.(); jumpPulse = true; };
@@ -606,12 +605,8 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
606605
renderer.domElement.removeEventListener('click', onCanvasClick);
607606
document.removeEventListener('mousemove', onMouseMove);
608607
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
609-
renderer.domElement.removeEventListener('touchstart', onTouchStart);
610-
renderer.domElement.removeEventListener('touchmove', onTouchMove);
611-
renderer.domElement.removeEventListener('touchend', onTouchEndOrCancel);
612-
renderer.domElement.removeEventListener('touchcancel', onTouchEndOrCancel);
613608
document.removeEventListener('visibilitychange', onVisibility);
614-
destroyJoystick();
609+
destroyJoysticks();
615610
jumpEl.removeEventListener('touchstart', triggerJump);
616611
jumpEl.removeEventListener('mousedown', triggerJump);
617612
if (document.pointerLockElement === renderer.domElement) document.exitPointerLock?.();
@@ -897,24 +892,26 @@ export class Bitmap3dRendererComponent implements AfterViewInit, OnDestroy {
897892
// the same chunk as three.js).
898893
setPfpClass(true);
899894
setTouchClass(true);
900-
void initJoystick();
895+
void initJoysticks();
896+
lookClock.getDelta(); // discard the pre-PFP idle delta
901897
} else if (flyAfterIso === 'orbit') {
902898
controls.enabled = true;
903899
state = 'orbit';
904900
setPfpClass(false);
905901
setTouchClass(false);
906-
destroyJoystick();
902+
destroyJoysticks();
907903
} else {
908904
state = 'exit-done';
909905
setPfpClass(false);
910906
setTouchClass(false);
911-
destroyJoystick();
907+
destroyJoysticks();
912908
this.zone.run(() => this.exitDone.emit());
913909
}
914910
}
915911
break;
916912
}
917913
case 'pfp': {
914+
applyLookStick();
918915
const dt = Math.min(0.05, physicsClock.getDelta()) / STEPS_PER_FRAME;
919916
for (let i = 0; i < STEPS_PER_FRAME; i++) {
920917
applyControls(dt);

0 commit comments

Comments
 (0)