[SOLVED] Help with virtual joystick

Hi, I wanted to make an on-screen joystick for a project, but I’ve encontered some technical difficulties. I found this project that had a drag system for 2D elements in a canvas.

After fiddling a little with that script, I’ve placed a reset to the center on drag drop but I couldn’t manage a way to keep the knob inside the circle, so it can move out of it’s boundaries. I tried to normalize the difference between the knob’s original local position from application startup and it’s current local position regarding it’s parent. (the background circle)

How can I make a cap so the knob never leaves that said circle? I tried checking for hardcaps but it doesn’t always work because the normalized vector returns different on each edge of the circle the knob is at. Is there a way to check for image intersections, so I can always check if the knob is about to leave the circle?

Link to the project: https://playcanvas.com/editor/project/763245

Hi @Abneto,

I used the formulas from here:

https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection

To clamp the position of my 2D elements during or after drag with the ‘drag:move’ or ‘drag:end’ events in the pc.ElementDragHelper:

https://developer.playcanvas.com/en/api/pc.ElementDragHelper.html

Thanks for the headers, but I still didnt managed to make it work properly. When I try to use draghelper, this error shows up:

Uncaught TypeError: Cannot read property 'screen' of null
    at ElementDragHelper._calculateDragScale (playcanvas-stable.dbg.js:61290)
    at ElementDragHelper._onMouseDownOrTouchStart (playcanvas-stable.dbg.js:61195)
    at ElementComponent.fire (playcanvas-stable.dbg.js:702)
    at ElementInput._fireEvent (playcanvas-stable.dbg.js:70157)
    at ElementInput._onElementMouseEvent (playcanvas-stable.dbg.js:70019)
    at ElementInput._handleDown (playcanvas-stable.dbg.js:69855)

Here is how I’m trying it:

var DragscriptNew = pc.createScript('dragscriptNew');

var originalPosition = new pc.Vec3();

var helper;

// initialize code called once per entity
DragscriptNew.prototype.initialize = function() {  
    this.bindEvents();
};
DragscriptNew.prototype.bindEvents = function(){ 
      var axis = "x";  // x, y or null
      helper = new pc.ElementDragHelper(this.entity.element, axis);
        // helper.on('drag:move', this.onHandleDrag, this); 
};

Do you have any examples you can share?

Hi @Abneto,

Here’s a quick and dirty script I made for sliders that limit the height the handle can move (assuming the meter is 580 units tall).

var SliderControl = pc.createScript('sliderControl');

SliderControl.attributes.add('type', {
    type: 'string',
    title: 'Slider Type?',
    description: 'handle,meter'
    
});

SliderControl.attributes.add('handle', {
    type: 'entity',
    title: 'Handle Object'
});

SliderControl.attributes.add('screenobject', {
    type: 'entity',
    title: 'Screen Object'
});

SliderControl.prototype._handleDragHelper = null;

// initialize code called once per entity
SliderControl.prototype.initialize = function() {
    var app = this.app;
    var self = this;
    
    if(this.type === 'handle') {
        this._handleDragHelper = new pc.ElementDragHelper(this.entity.element, 'y');
    
    this._handleDragHelper.on('drag:move', function(position) {
        
        self._handleDragHelper._deltaHandlePosition.y = pc.math.clamp(self._handleDragHelper._deltaHandlePosition.y, 0,580);
        self.entity.setLocalPosition(self._handleDragHelper._deltaHandlePosition);
        
        app.fire('updatemeter');
        
    });

    }
    
    else if(this.type === 'meter') {
        app.on('updatemeter', function() {
           self.entity.setLocalScale(1,self.handle.getLocalPosition().y/580,1);
        });
    }

};

// update code called every frame
SliderControl.prototype.update = function(dt) {
    
};

// swap method called for script hot-reloading
// inherit your script state here
// SliderControl.prototype.swap = function(old) { };

// to learn more about script anatomy, please read:
// http://developer.playcanvas.com/en/user-manual/scripting/

It looks like the way you’re declaring your variables might be messing with things. You’ll notice in my script that I only assigned the pc.ElementDragHelper to certain objects, and I used ‘this’ so that it’s only associated with the object the script is attached to instead of the global scope.

Here is another example where I limit the placement of an object after dragging is stopped based on the width and height of my elements:

var CcCargoScreen = pc.createScript('ccCargoScreen');

CcCargoScreen.attributes.add('monitor', {
    type: 'entity',
    title: 'Cargo Monitor'
});

CcCargoScreen.attributes.add('unloadColor', {
   type: 'rgb',
   title: 'Unloaded Color',
   default: [1,1,1]
});

CcCargoScreen.attributes.add('loadColor', {
   type: 'rgb',
   title: 'Loaded Color',
   default: [0.165,0.525,0.702]
});


CcCargoScreen.prototype.isLoaded = false;
// initialize code called once per entity
CcCargoScreen.prototype.initialize = function() {
    var app = this.app;
    var self = this;

    this.origPos = this.entity.getLocalPosition().clone();


     this._handleDragHelper = new pc.ElementDragHelper(this.entity.element);

    this._handleDragHelper.on('drag:start', function() {
       self.entity.setLocalScale(1.25,1.25,1.25);
    });

    this._handleDragHelper.on('drag:end', function() {
       self.entity.setLocalScale(1.0,1.0,1.0);

        if(self.inCargoHold(self.entity.getLocalPosition()) && !self.isLoaded) {
            self.isLoaded = true;
            self._handleDragHelper._deltaHandlePosition.x = pc.math.clamp(self._handleDragHelper._deltaHandlePosition.x,
                                                                          self.monitor.getLocalPosition().x - (self.monitor.element.width/2) + (self.entity.element.width/2),
                                                                          self.monitor.getLocalPosition().x + (self.monitor.element.width/2) - (self.entity.element.width/2));
            self._handleDragHelper._deltaHandlePosition.y = pc.math.clamp(self._handleDragHelper._deltaHandlePosition.y, self.monitor.getLocalPosition().y - (self.monitor.element.height/2) + (self.entity.element.height/2),
                                                                          self.monitor.getLocalPosition().y + (self.monitor.element.height/2) - + (self.entity.element.height/2));
            self.entity.setLocalPosition(self._handleDragHelper._deltaHandlePosition);

            self.monitor.script.ccCargoScreenMonitor.loadedcount++;
            self.entity.element.color = self.loadColor;
            self.entity.children[0].element.color = self.loadColor;

            app.fire('updateCargoCount');
            app.fire('cargoChange', 'load', self.entity.children[0].element.text);

        }

        else if (self.inCargoHold(self.entity.getLocalPosition()) && self.isLoaded) {

            self._handleDragHelper._deltaHandlePosition.x = pc.math.clamp(self._handleDragHelper._deltaHandlePosition.x,
                                                                          self.monitor.getLocalPosition().x - (self.monitor.element.width/2) + (self.entity.element.width/2),
                                                                          self.monitor.getLocalPosition().x + (self.monitor.element.width/2) - (self.entity.element.width/2));
            self._handleDragHelper._deltaHandlePosition.y = pc.math.clamp(self._handleDragHelper._deltaHandlePosition.y,
                                                                          self.monitor.getLocalPosition().y - (self.monitor.element.height/2) + (self.entity.element.height/2),
                                                                          self.monitor.getLocalPosition().y + (self.monitor.element.height/2) - + (self.entity.element.height/2));
            self.entity.setLocalPosition(self._handleDragHelper._deltaHandlePosition);
        }

        else if (!self.inCargoHold(self.entity.getLocalPosition()) && self.isLoaded) {
            self.isLoaded = false;
            self.monitor.script.ccCargoScreenMonitor.loadedcount--;
            app.fire('updateCargoCount');
            self.entity.element.color = self.unloadColor;
            self.entity.children[0].element.color = self.unloadColor;
            self.entity.setLocalPosition(self.origPos);

            app.fire('cargoChange', 'unload',self.entity.name,self.entity.children[0].element.text);
        }

        else if (!self.inCargoHold(self.entity.getLocalPosition()) && !self.isLoaded) {
            self.entity.setLocalPosition(self.origPos);
        }

    });
    
    app.on('cargoReset', function() {
        self.isLoaded = false;
        self.entity.element.color = self.unloadColor;
            self.entity.children[0].element.color = self.unloadColor;
            self.entity.setLocalPosition(self.origPos);
    });
};


CcCargoScreen.prototype.inCargoHold = function(position) {
    if(position.x + (this.entity.element.width/2) > this.monitor.getLocalPosition().x - (this.monitor.element.width/2) &&
        position.x - (this.entity.element.width/2) < this.monitor.getLocalPosition().x + (this.monitor.element.width/2) &&
        position.y + (this.entity.element.height/2) > this.monitor.getLocalPosition().y - (this.monitor.element.height/2) &&
        position.y - (this.entity.element.height/2) < this.monitor.getLocalPosition().y + (this.monitor.element.height/2)) {
        return true;
    }

    else {
        return false;
    }
};

// update code called every frame
CcCargoScreen.prototype.update = function(dt) {

};

// swap method called for script hot-reloading
// inherit your script state here
// CcCargoScreen.prototype.swap = function(old) { };

// to learn more about script anatomy, please read:
// http://developer.playcanvas.com/en/user-manual/scripting/

Here you can see the use of the formulas from Mozilla above to place the object with range of the designated area after dragging has stopped. I hope these examples are helpful, and of course, if I went wrong somewhere, I would love to hear from the rest of the community.

1 Like

Actually @Abneto,

You might be on to something here! I remember in my last question about this that @LeXXik provided an important tip!

You can’t natively apply the draghelper to a child of a 2D screen. The draggable should have an element entity as it’s parent!

It looks like there is already an issue logged for this!

1 Like

Hey @eproasim,
After some few hours trying to figure out how it works (I’m pretty much new to playcanvas so somethings can be a headache) I actually managed to do it, however the knob moves in a rectangular way rather than respecting the circular boundary (extreme diagonal movements go off the circle)

Here’s how I’m currently doing it:

var DragscriptNew = pc.createScript('dragscriptNew');

DragscriptNew.attributes.add('handle', {
    type: 'entity',
    title: 'handle',
});

DragscriptNew.attributes.add('bg', {
    type: 'entity',
    title: 'handle background'
});
// initialize code called once per entity
DragscriptNew.prototype.postInitialize = function() {
  
     var app = this.app;
     var self = this;    
     var hnd = this.handle; 
     var localBg = this.bg;   
     var localBgPos = this.bg.getLocalPosition().clone();
     var originalPosition = hnd.getLocalPosition().clone();     
    
     this.handleDragHelper = new pc.ElementDragHelper(this.handle.element);

     self.handleDragHelper.on('drag:move', function(pos){
       
         console.log(hnd.getLocalPosition());
         
         self.handleDragHelper._deltaHandlePosition.y = pc.math.clamp(self.handleDragHelper._deltaHandlePosition.y,
                                                                      originalPosition.y - localBg.element.height / 2,
                                                                      originalPosition.y + localBg.element.height / 2);
         self.handleDragHelper._deltaHandlePosition.x = pc.math.clamp(self.handleDragHelper._deltaHandlePosition.x,
                                                                      originalPosition.x - localBg.element.width / 2,
                                                                      originalPosition.x + localBg.element.width / 2);
         self.entity.setLocalPosition(self.handleDragHelper._deltaHandlePosition);
         
     });
    
     self.handleDragHelper.on('drag:end', function(pos){
        
         console.log("stopped");
         hnd.setLocalPosition(originalPosition);
         
     });   
                           
};

Any ideas how to make it a round movement rather than rectangular?

@Abneto,

No problem. I will see if I can throw something together for you in the next little bit. My starting point will be using the circle collision logic here:

https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection#circle_collision

And defining my radii by the half extents of the background and the knob. If you manage to get figured out before I do, great!

1 Like

Good evening @Abneto,

Sorry for the delay. Some other projects got in the way. It’s not the cleanest script in the world, but I did manage to have the handle limited to the radius of the background:

var DragscriptNew = pc.createScript('dragscriptNew');

DragscriptNew.attributes.add('handle', {
    type: 'entity',
    title: 'handle',
});

DragscriptNew.attributes.add('bg', {
    type: 'entity',
    title: 'handle background'
});
// initialize code called once per entity
DragscriptNew.prototype.postInitialize = function() {
  
     var app = this.app;
     var self = this;    
     var hnd = this.handle; 
     var localBg = this.bg;   
     var localBgPos = this.bg.getLocalPosition().clone();
     var originalPosition = hnd.getLocalPosition().clone();
     var radius = localBg.element.height / 2;
     
    
     this.handleDragHelper = new pc.ElementDragHelper(this.handle.element);
    
    
    
        
    
     self.handleDragHelper.on('drag:move', function(pos){
       
        var x = pos.x;
        var y = pos.y;
        var dx = self.handleDragHelper._deltaHandlePosition.x;
        var dy = self.handleDragHelper._deltaHandlePosition.y;
        var distance = Math.sqrt( (dx * dx) + (dy * dy) );
         
         
         if(distance > radius) {
           var normX = dx / distance;
           var normY = dy / distance;
             
             x = normX * radius;
             y = normY * radius;
             self.handleDragHelper._deltaHandlePosition.x = x;
             self.handleDragHelper._deltaHandlePosition.y = y;
             
             self.entity.setLocalPosition(self.handleDragHelper._deltaHandlePosition);
         }
         
         
     });
    
     self.handleDragHelper.on('drag:end', function(pos){
        
         console.log("stopped");
         hnd.setLocalPosition(originalPosition);
         
     });   
                           
};

// update code called every frame
DragscriptNew.prototype.update = function(dt) {
    
};



// swap method called for script hot-reloading
// inherit your script state here
// DragscriptNew.prototype.swap = function(old) { };

// to learn more about script anatomy, please read:
// http://developer.playcanvas.com/en/user-manual/scripting/
// 

I hope this is helpful. Since the calculation was now purely distance based, I had to take a different approach that doesn’t immediately reference the background apart to set the radius.

I hope others comment if there are any serious improvements that need to be made.

edit:

@Abneto I just realized I should mention that I adjusted the set up of the scene in question and adjusted the pivots for this script. Check out this fork of your project:

https://playcanvas.com/project/763684/overview/round-draggable

1 Like

This make it alot more clearer. Thank you!

Also, I was about to ask about the boundaries being slightly off :joy:

1 Like

I also noticed that the fork of the project does not have a child canvas on the hierarchy for the draggable, yet it works. Was this fixed today? :thinking:

1 Like

The draggable just can’t have a root screen as it’s parent. Any type of element (image, text, screen) will make it work as its parent, just not the top level screen.

I removed the child screen, because it made it much easier for me to see what was happening and balanced out the positioning in the editor.

I hope that helps!

2 Likes

Oh, I tought it had to be 2 screens… Now it makes sense!

1 Like

Hello guys, have you also figured out the rotation of player towards the way ? I ve managed to add movement to my player character although i cant give him rotation.

Hello @smokys,

It’s been a bit since I last fiddled with this project, but I was indeed making the part of looking around with a second virtual joystick. Here is an example fork of the look joystick working.

What I did was getting the normalized value of the delta X and delta Y of the joystick knob with these:

Dragscript.prototype.normalize = function(val, min, max)
{
    return (val - min) / (max - min);
};


Dragscript.prototype.normalizeDirection = function()
{
  dirX = this.normalize(dirX, -300, 300);
  dirY = this.normalize(dirY, -300, 300);    
  
    //console.log(dirX, dirY);
};

Note that the 300 value was from trial and error from the commented log, and each extreme of the knob in my case goes to 300. For better visualization, I used values of 0 and 1, with 1 being far right, 0.5 being center, and 0 being far left.

Knowing those values, I can tell where the knob is (and also how much force should be applied if you want to apply a sense of “pressure”). Then I pass those values as variables (mobileLookX, mobileLookY) to the first person movement script:

        if (mobileLookX < 0.3)
        {
            this.eulers.x += 1;
        }
        else if (mobileLookX > 0.7)
        {
            this.eulers.x -= 1;
        }
        if (mobileLookY < 0.3)
        {
            this.eulers.y -= 1;
        }
        else if (mobileLookY > 0.7)
        {
            this.eulers.y += 1;
        }

I also use a boolean to compare if the knob should move or make the player look around.

3 Likes

But it only rotates camera, no the player ?

As I said, it was a bit of time since I last worked on this project, and I was in the middle of applying that “pressure” system I mentioned, so I believe the problem is regarding the multiplies not propely being applied on the movement vector. (There is two vectors, one for movement and one for looking around)

With that boolean (isLook) you can define which knob controls which vector, and with those values you can define what should be moved and in which direction. Try taking a look on the older posts in this thread, as it’s the part where the movement is still working.

1 Like

I actually used your code for joystick movement, had to rework it a bit so it works in my project but you havent implemented rotation in there, thats why im asking. With Lexxik’s help, i now know what degree is there so I only need to apply it, but dynamic rigidbody doesnt support functions such as setEulerAngles or lookAt. i can only use teleport, angularVelocity or applyTorque, but neither of them sets stacic rotation degree, they only put force in rotatios, except teleport.

Hello @smokys,

I’ve returned to the project, and made a fork of both joysticks working.

It also includes the pressure system. However, I’ve noticed that:

  • When you try to walk forward or horizontal (straight line) in perpendicular angles (90, 180, 270, etc.), you will not move, or move extremely slow.

  • When you rotate into diagonal degrees (45, 135, 225, etc.), the movement will stop being correspondent to the knob (i.e: walking towards the right when moving the knob straight up when at 45º)

I’m trying to look what is causing this problem (most likely related to camera directions), but if you or someone else reading this finds a solution, please let me know.

1 Like

Hello, we finally managed to make the rotation and movement work

You can check the project: https://playcanvas.com/editor/scene/1130613

1 Like

Also as a heads-up, There doesn’t seems to be a built-in way to determine quantity of touches when it comes to ElementDragHelper or which touch a draggable object should follow. (and the documentation on that class seems rather raw)

When using two joysticks, they start getting confused in which touch to follow, causing flicker issues…