@@ -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