For beginner developers, this process can be elusive, so let's explore some techniques for implementing continuous actions.
Enabling Multitouch
Most games which require continuous actions will also require multitouch, allowing the player to manipulate more than one on-screen control using multiple fingers. For instance, a 2D platformer with both "move" buttons and a "jump" button will usually let the player control one set with the left thumb and the other with the right thumb.
As outlined in the Tap/Touch/Multitouch guide, multitouch is disabled by default, but enabling it is simple:
-- Activate multitouch
system.activate( "multitouch" )
Note
Depending on your game design, you should carefully consider where this command should be called. While it could be called as one of the first lines within main.lua
, that may not be optimal — for instance, if your game begins with a menu scene (most games do) then you probably won't need multitouch capability at that point and, in that case, this command should be deferred until multitouch is actually needed.
Now let's explore some common elements where continuous actions may apply:
Virtual Buttons
In mobile games which clearly lack a physical controller like a gamepad, a common UI element is a virtual button. These can range anywhere from "jump" buttons to "fire" buttons or nearly anything the game designer comes up with. In some cases, these offer a one-press type of behavior — tap/touch the button and the action occurs once, like a jump. In other cases, these perform an action for the entire time the player holds their touch over the button, like firing a continuous stream of lasers. This latter case is where continuous actions come into play.
Creating the Button Region
Instead of creating dedicated buttons via a method like widget.newButton(), we'll create a button region that can accommodate one or more buttons. This is necessary to achieve all of the continuous action functionality, as you'll learn in the course of this tutorial. It's also a convenient way to "group" related sets of buttons, for example a "jump" button directly beside a "fire" button.
First, let's create a new display group to contain the region:
local buttonGroup = display.newGroup()
Now, let's create a visual "button" for the region which is, in fact, just a standard image:
local buttonGroup = display.newGroup()
local fireButton = display.newImageRect( buttonGroup, "fireButton.png", 64, 64 )
fireButton.x, fireButton.y = 60, display.contentHeight-60
Important
Note that this image is inserted into the button group via the specification of buttonGroup
as the first parameter to display.newImageRect().
Once again, this is not a functional button, but rather just an image that defines the area within the button region where the player's touch interaction will be handled. Thus, it does not require the addition of a touch or tap event listener.
Now, let's create an object which will actually detect touches on/around the button. This object is merely an invisible vector rectangle which overlays the button image, and its size is automatically calculated by the image(s) previously inserted into buttonGroup
like the fireButton
image above.
local fireButton = display.newImageRect( buttonGroup, "fireButton.png", 64, 64 )
fireButton.x, fireButton.y = 60, display.contentHeight-60
local groupBounds = buttonGroup.contentBounds
local groupRegion = display.newRect( 0, 0, groupBounds.xMax-groupBounds.xMin+200, groupBounds.yMax-groupBounds.yMin+200 )
groupRegion.x = groupBounds.xMin + ( buttonGroup.contentWidth/2 )
groupRegion.y = groupBounds.yMin + ( buttonGroup.height/2 )
groupRegion.isVisible = false
groupRegion.isHitTestable = true
Notes
The size of this groupRegion
vector rectangle is actually 200 pixels larger than the button image, both horizontally and vertically. This is because, as discussed further on in this tutorial, we also need to handle when the player's touch moves from inside the button's region to outside it, or slide-off. While it might seem excessive to extend the rectangle so far outside the button on all sides, this helps ensure that the player can't swipe or move their touch very quickly off the button and still cause Solar2D to assume the touch is active. Don't worry — this large vector object will not block touch propagation to other objects in the scene unless the touch point is inside the bounds of a button image.
On lines 10 and 11, we make the rectangle invisible and hit-testable. The groupRegion.isHitTestable = true
command is especially important in this case because, by default, invisible objects will not detect touches. This command ensures that it will receive touch events.
Region Detection Function
To detect when a touch point on the groupRegion
rectangle intersects the bounds of the fireButton
image, we'll use a function. Essentially, when called, this function will loop through the image objects inserted into buttonGroup
and, for each, check if the touch point is within that object's content bounds. If it detects that the touch point is within any button image's bounds, it returns a reference to that object.
local function detectButton( event )
for i = 1,buttonGroup.numChildren do
local bounds = buttonGroup[i].contentBounds
if (
event.x > bounds.xMin and
event.x < bounds.xMax and
event.y > bounds.yMin and
event.y < bounds.yMax
) then
return buttonGroup[i]
end
end
end
Note
This code will only accurately test if the touch point is inside the rectangular edge bounds of a button. If you have a button which is visually circular as in the example button images shown above, this will not be perfectly accurate. In most games, however, it's sufficient to test that the touch point is within a rectangular region surrounding the button.
Optionally, these conditions can be adjusted to be more (or less) forgiving in regards to where an active touch is acknowledged. For example, we can reduce the size of the valid region slightly to ensure that the touch is truly "inside" a button, insetting all four edges by 4 pixels as follows:
event.x > bounds.xMin + 4 and
event.x < bounds.xMax - 4 and
event.y > bounds.yMin + 4 and
event.y < bounds.yMax - 4
Handling Slide-Off
As you can see, the handleController()
function currently handles the "began"
and "ended"
phases of the touch — when the player touches within the bounds of a button, we can start firing the weapon and, when the player lifts off, we can stop firing. However, there is a very important case which you must account for: the slide-off case.
Internally, Solar2D generates an "ended"
phase when the user's touch lifts off an object, but this only occurs if the touch location is over the object at that point. By default, Solar2D will not generate an "ended"
event if the user touches an object, slides their finger outside of its content bounds, and then releases. Thus, unless we take steps to account for this, the player can slide their touch outside of the bounds of the button region rectangle, release, and the weapon will continue firing!
To prevent this, we can add another check using the "moved"
event phase. As its name implies, this phase is triggered every time the player's finger moves from the initial touch point. Using it, we can ensure that the weapon stops firing when the player slides their touch outside the bounds of a button:
local function handleController( event )
local touchOverButton = detectButton( event )
if ( event.phase == "began" ) then
if ( touchOverButton ~= nil ) then
if not ( buttonGroup.touchID ) then
-- Set/isolate this touch ID
buttonGroup.touchID = event.id
-- Set the active button
buttonGroup.activeButton = touchOverButton
-- Fire the weapon
print( "BEGIN FIRING" )
end
return true
end
elseif ( event.phase == "moved" ) then
-- Handle slide off
if ( touchOverButton == nil and buttonGroup.activeButton ~= nil ) then
event.target:dispatchEvent( { name="touch", phase="ended", target=event.target, x=event.x, y=event.y } )
return true
end
elseif ( event.phase == "ended" and buttonGroup.activeButton ~= nil ) then
-- Release this touch ID
buttonGroup.touchID = nil
-- Set that no button is active
buttonGroup.activeButton = nil
-- Stop firing the weapon
print( "STOP FIRING" )
return true
end
end
Basically, with this additional code, we conditionally check if the touch point is outside the button and that buttonGroup.activeButton
is currently not nil
— this second condition is especially important because we need to know that the button is already pressed when the slide-off occurs.
If both conditions are met, we use the convenient object:dispatchEvent() method to dispatch a "pseudo-event" of "ended"
to the same listener function, making Solar2D think that the touch ended even if the player's finger is physically still touching the screen.
Activating the Controller
The fundamental detection code is now complete, but the controller itself won't do anything. This is because we haven't "activated" it yet!
To make it active, simply add a standard touch event listener to the groupRegion
object, triggering the handleController()
function on each touch event:
groupRegion:addEventListener( "touch", handleController )
Responding to Action
Depending on whether the button is pressed or not, we need to take some associated action. Since we're dealing with continuous actions, the event itself should be continuous in some way.
While one approach is to perform an action (like firing a laser) on every runtime frame using an "enterFrame"
listener, that's probably too often for most purposes — after all, should a ship really fire 30 or 60 lasers per second in a typical shooter game?
A more practical approach is to use a timer and toggle it on/off depending on the button state, allowing us to control the rate of the continuous action. Thus, let's integrate a timer into our existing code:
local fireTimer
local function fireLaser( event )
print( "FIRE A LASER!" )
end
local function handleController( event )
local touchOverButton = detectButton( event )
if ( event.phase == "began" ) then
if ( touchOverButton ~= nil ) then
if not ( buttonGroup.touchID ) then
-- Set/isolate this touch ID
buttonGroup.touchID = event.id
-- Set the active button
buttonGroup.activeButton = touchOverButton
-- Fire the weapon
print( "BEGIN FIRING" )
fireTimer = timer.performWithDelay( 100, fireLaser, 0 )
end
return true
end
elseif ( event.phase == "moved" ) then
-- Handle slide off
if ( touchOverButton == nil and buttonGroup.activeButton ~= nil ) then
event.target:dispatchEvent( { name="touch", phase="ended", target=event.target, x=event.x, y=event.y } )
return true
end
elseif ( event.phase == "ended" and buttonGroup.activeButton ~= nil ) then
-- Release this touch ID
buttonGroup.touchID = nil
-- Set that no button is active
buttonGroup.activeButton = nil
-- Stop firing the weapon
print( "STOP FIRING" )
timer.cancel( fireTimer )
return true
end
end
groupRegion:addEventListener( "touch", handleController )
Let's inspect the highlighted additions in more detail:
On line 28, we forward-declare a variable fireTimer
. This will be used as a persistent reference to the timer which controls the action.
On lines 30-32, we add the foundational function for firing lasers (fireLaser()
). How you actually fire lasers (or perform whatever continuous action) is completely dependent on your game, so for now we'll just print()
a string for testing.
On line 48, when the button is effectively pressed, we start a new timer, assigning it to the fireTimer
reference we created on line 28. This timer will repeat every 100 milliseconds, triggering the fireLaser()
function on each iteration.
On line 69, when the button is effectively released, we cancel the timer using the fireTimer
reference it was created with.
Virtual Directional Pad
Another common UI element is a virtual directional pad. These usually consist of 2-directional or 4-directional virtual buttons arranged side-by-side or in a plus-shaped configuration, similar to the physical directional pad on a game controller.
Creating a control set like this in Solar2D can be done similarly to the virtual button method above, but in this case, the player will usually keep their finger touched down on the screen in the region of the control pad, simply sliding around (not releasing) to activate another directional button. Thus, in addition to the slide-off, we must handle the slide-on action where the player simply moves their touch point from one directional button to another.
Creating the Controller
This time, let's use two images side-by-side to construct a basic 2-directional controller:
local buttonGroup = display.newGroup()
local leftButton = display.newImageRect( buttonGroup, "leftButton.png", 64, 64 )
leftButton.x, leftButton.y = 60, display.contentHeight-60
leftButton.canSlideOn = true
leftButton.ID = "left"
local rightButton = display.newImageRect( buttonGroup, "rightButton.png", 64, 64 )
rightButton.x, rightButton.y = 136, display.contentHeight-60
rightButton.canSlideOn = true
rightButton.ID = "right"
local groupBounds = buttonGroup.contentBounds
local groupRegion = display.newRect( 0, 0, groupBounds.xMax-groupBounds.xMin+200, groupBounds.yMax-groupBounds.yMin+200 )
groupRegion.x = groupBounds.xMin + ( buttonGroup.contentWidth/2 )
groupRegion.y = groupBounds.yMin + ( buttonGroup.height/2 )
groupRegion.isVisible = false
groupRegion.isHitTestable = true
local function detectButton( event )
for i = 1,buttonGroup.numChildren do
local bounds = buttonGroup[i].contentBounds
if (
event.x > bounds.xMin and
event.x < bounds.xMax and
event.y > bounds.yMin and
event.y < bounds.yMax
) then
return buttonGroup[i]
end
end
end
This code is similar to the virtual button example above, with two very important distinctions:
For each button, we set a boolean canSlideOn
property, initially set to true
. Because players manipulating a directional pad will typically slide their touch from button to button, this will let us handle slide-on behavior.
We assign an ID
property of "left"
or "right"
to each button — later, this will help us identify the "direction" it represents.
Button Listener
Now let's construct the listener function to handle touch events, adapting the code from the first example to handle multiple buttons. We'll start with the "began"
phase block:
local function handleController( event )
local touchOverButton = detectButton( event )
if ( event.phase == "began" ) then
if ( touchOverButton ~= nil ) then
if not ( buttonGroup.touchID ) then
-- Set/isolate this touch ID
buttonGroup.touchID = event.id
-- Set the active button
buttonGroup.activeButton = touchOverButton
-- Take proper action based on button ID
if ( buttonGroup.activeButton.ID == "left" ) then
print( "LEFT" )
elseif ( buttonGroup.activeButton.ID == "right" ) then
print( "RIGHT" )
end
end
return true
end
This is similar to the first example, but we've added some conditional checks on the active button's ID
property (lines 48 and 50) to determine which action to take.
Now let's expand upon the "moved"
phase block:
elseif ( event.phase == "moved" ) then
-- Handle slide off
if ( touchOverButton == nil and buttonGroup.activeButton ~= nil ) then
event.target:dispatchEvent( { name="touch", phase="ended", target=event.target, x=event.x, y=event.y } )
return true
-- Handle slide on
elseif ( touchOverButton ~= nil and buttonGroup.activeButton == nil and touchOverButton.canSlideOn ) then
event.target:dispatchEvent( { name="touch", phase="began", target=event.target, x=event.x, y=event.y, id=event.id } )
return true
end
elseif ( event.phase == "ended" and buttonGroup.activeButton ~= nil ) then
-- Release this touch ID
buttonGroup.touchID = nil
-- Set that no button is active
buttonGroup.activeButton = nil
-- Stop the action
print( "STOP" )
return true
end
end
groupRegion:addEventListener( "touch", handleController )
With this additional check (lines 64-67), we check for slide-on by testing if the touch point is inside the bounds of a button and that buttonGroup.activeButton
is currently nil
— this second condition is especially important because we need to know that the button is not already pressed when the slide-on occurs. As a third condition, we confirm that the button accepts slide-on behavior by testing for a canSlideOn
property value of true
.
If all conditions are met, we use the object:dispatchEvent() method to dispatch a pseudo-event of "began"
to the same listener function, making Solar2D think that a new touch began on the button, even though the player's finger is already physically touching the screen.
Frame-Based Movement
Responding to interaction with directional buttons may differ from typical buttons. Usually, if a directional button is pressed, a steady and consistent action should occur until the button is released (or a neighboring button is interacted with).
One way to continuously move a character/object is to simply update its x or y position in a runtime "enterFrame"
function. We can combine this approach with our directional controller by creating a simple test object, writing a basic listener function, and including some "control" code within the handleController()
function:
local testObj = display.newRect( display.contentCenterX, display.contentCenterY, 20, 20 )
testObj.deltaPerFrame = { 0, 0 }
local function frameUpdate()
testObj.x = testObj.x + testObj.deltaPerFrame[1]
testObj.y = testObj.y + testObj.deltaPerFrame[2]
end
Runtime:addEventListener( "enterFrame", frameUpdate )
local function handleController( event )
local touchOverButton = detectButton( event )
if ( event.phase == "began" ) then
if ( touchOverButton ~= nil ) then
if not ( buttonGroup.touchID ) then
-- Set/isolate this touch ID
buttonGroup.touchID = event.id
-- Set the active button
buttonGroup.activeButton = touchOverButton
-- Take proper action based on button ID
if ( buttonGroup.activeButton.ID == "left" ) then
testObj.deltaPerFrame = { -2, 0 }
elseif ( buttonGroup.activeButton.ID == "right" ) then
testObj.deltaPerFrame = { 2, 0 }
end
end
return true
end
elseif ( event.phase == "moved" ) then
elseif ( event.phase == "ended" and buttonGroup.activeButton ~= nil ) then
-- Release this touch ID
buttonGroup.touchID = nil
-- Set that no button is active
buttonGroup.activeButton = nil
-- Stop the action
testObj.deltaPerFrame = { 0, 0 }
return true
end
end
Let's explore the highlighted code in more detail:
On lines 35 and 36, we create a simple test object (vector square) positioned in the center of the content area. We also assign a property to the object, deltaPerFrame
, which is a table of two values, one for x and one for y. Our object will begin in a stopped/stationary state, so we initially set both of these to 0
.
On lines 38-41, we add a basic function (frameUpdate()
) to update the object's x and y position, based on the values in its deltaPerFrame
property. Then, on line 42, we start that function running/executing on each runtime frame by adding an "enterFrame"
event listener.
On lines 58 and 60, we change the object's deltaPerFrame
property values based on which directional button is pressed. If the left button is pressed, the first value (x) is set to -2
, meaning that the object will begin moving 2 pixels to the left on each runtime frame. Similarly, if the right button is pressed, we set the first value to 2
so that the object will move 2 pixels to the right per frame. Note that you can increase/decrease these values if you want the object to move faster or slower.
Finally, on line 86, we reset the deltaPerFrame
values to 0
to stop the object's movement if the player's touch drifts off a directional button.
Physics-Based Movement
Another way to continuously move a character/object is via physics. Of course, this assumes that the object is a physical object being managed by the physics engine, a topic beyond the scope of this tutorial (if you need assistance on physics, start with the Physics Setup guide).
In terms of integrating physics-based movement with a directional controller, the best option is usually to set the object's linear velocity — this is because it applies a consistent, steady rate of movement to the object instead of stacking force values or applying momentary impulses.
Let's adjust the code to use physics and linear velocity:
-- Set up physics engine
local physics = require( "physics" )
physics.start()
local testObj = display.newRect( display.contentCenterX, display.contentCenterY, 20, 20 )
physics.addBody( testObj, "kinematic" )
local function handleController( event )
local touchOverButton = detectButton( event )
if ( event.phase == "began" ) then
if ( touchOverButton ~= nil ) then
if not ( buttonGroup.touchID ) then
-- Set/isolate this touch ID
buttonGroup.touchID = event.id
-- Set the active button
buttonGroup.activeButton = touchOverButton
-- Take proper action based on button ID
if ( buttonGroup.activeButton.ID == "left" ) then
testObj:setLinearVelocity( -100, 0 )
elseif ( buttonGroup.activeButton.ID == "right" ) then
testObj:setLinearVelocity( 100, 0 )
end
end
return true
end
elseif ( event.phase == "moved" ) then
elseif ( event.phase == "ended" and buttonGroup.activeButton ~= nil ) then
-- Release this touch ID
buttonGroup.touchID = nil
-- Set that no button is active
buttonGroup.activeButton = nil
-- Stop the action
testObj:setLinearVelocity( 0, 0 )
return true
end
end
Exploring the highlighted code in more depth, we perform these actions:
On lines 36 and 37, we require()
the physics engine and start it running.
On lines 39 and 40, we create a simple test object (vector square) positioned in the center of the content area. We also tell the physics engine to manage this object by assigning it a physical body of kinematic type.
On lines 56 and 58, we set the object's linear velocity using object:setLinearVelocity(). If the left button is pressed, we assign a value of -100
to the x parameter, causing the object to start moving left. Similarly, if the right button is pressed, we assign a value of 100
to the x parameter, causing the object to begin moving right. Note that you can increase/decrease these values if you want the object to move faster or slower.
Finally, on line 84, we reset both of the object's linear velocity values to 0
to stop its movement if the player's touch drifts off a directional button.