CS 466 - React Native Touch

Due: 2019-11-15

Goals

  • Get more practice with the Canvas API
  • Learn how to handle raw touches

Prerequisites

  1. Accept the assignment on our Github Classroom.
  2. Download the git repository to your local computer. This code is the solution to the last practical plus some additional data files.
  3. Update the package.json file with your name and email address.
  4. Install the package dependencies with npm install

Assignment

In this practical, we are going to finish up the radar screen, adding the ghosts and allowing the user to "catch" the ghosts by tapping on them. The idea here is that the ghosts are invisible, but when the radar passes over them they momentarily appear. If you look closely at the screen shot, you will see that a couple ghosts have been revealed.

Radar screen

Part 1: Make some ghosts

Add a new variable to GhostRadar called ghosts and set it to an empty array.

We want to initialize this with a collection of ghosts when the screen loads, so the setup function seems the appropriate location to handle this.

Use a for loop to make some ghosts (I made 5, you can make your own choice). Each ghost will be represented by a JavaScript object with the following properties:

  • x: the location of the ghost along the x-axis
  • y: the location of the ghost along the y-axis
  • vx: the speed of the ghost in along the x-axis
  • vy: the speed of the ghost in along the y-axis
  • radius: the size of the ghost (we will just draw circles for the ghosts)
  • alpha: the current transparency of the ghost
  • caught: a Boolean indicating if the ghost is caught

Initialize x and y to some random location on the screen. It doesn't matter if they are on the radar screen or not.

Initialize vx and vy to values between -0.5 and 0.5.

You can set the radius to anything you like. I used 10.

Initialize alpha to 1. We will change this to 1 later, but for now you will want to see the ghosts.

Initialize caught to false.

Part 2: Animate the ghosts

Write a new function outside of the component called updateGhosts. This function will be responsible for updating ghost visibility and position as well as drawing. Give the function two arguments: ghosts (this will be the list of ghosts) and ctx (our drawing context).

Iterate over each ghost and do the following:

  1. Add vx to x and vy to y. This will make the ghost move.
  2. Check if the ghost has left the screen (remember that we moved the origin to the middle). If it has, randomly reset the position and speed for the ghost to appear somewhere new on the screen.
  3. We want the ghosts to be unpredictable. So 25% of the time, we will change the direction of the the ghost's movement. Here is an example:
if (Math.random() < 0.25){
    ghost.vx += (Math.random() / 2) - 0.25;
}

Do this to vy as well.

  1. Draw the ghost. Draw the ghost as a circle centered at (ghost.x, ghost.y) with a radius of ghost.radius. Use fill() instead of stroke() to make solid ghosts. For the fillStyle, use the RGB string for we used in the last practical so you can pass ghosts.alpha for the alpha value.

Call this function from renderer, and you should see the ghosts scurry around the screen.

Part 3: Make the ghosts disappear

Immediately after you move the ghost, add a line that multiplies the alpha by some number less than 1 (e.g., 0.95). This will cause the ghost to fade over time. The larger the number, the slower the fade.

Of course, we want the ghost to flare back up again when the radar sweep hits it. To do this, we need to know where the sweep arm is, so add the angle and radius as arguments to the updateGhosts function.

As the geometry needed to figure out if the sweep arm intersects with the ghost is straying rather considerably from our mission, I'll give you the code. Add this function to your code:

const intersectionTest = (ghost, angle) => {
    const e = {x:Math.cos(angle), y:Math.sin(angle)};
    const eLength = Math.sqrt(e.x*e.x + e.y*e.y);
    e.x /= eLength;
    e.y /= eLength;

    // take the dot product
    let lf = e.x*ghost.x + e.y*ghost.y;
    
    let s = ghost.radius*ghost.radius - (ghost.x*ghost.x+ghost.y*ghost.y) + lf*lf;
  
    return s > 0 && lf > 0;
}

Don't worry about how this works. All you need to worry about is that when the angle means the arm crosses the circle of the ghost, this returns true.

Use this condition to test if the sweep arm has hit the ghost, and if it has, set ghost.alpha to 1 again.

When you run this, you should see the ghosts flare up and then fade out as the arm sweeps over them.

One problem is that this works even if the ghost is off the main radar circle. To keep this from happening, we want to test if the ghost is in the circle. The easiest way to do this is to check if the distance from the ghost to the center of the circle is less than the length of the radius. Since the center of our circle is at (0,0)(0, 0), then we just have to ask if

ghost.x2+ghost.y2<radius\sqrt{ghost.x^2 + ghost.y^2} \lt radius

Since square roots are computationally expensive, and we don't actually care about the distance itself, a trick often employed in graphics is to square both sides of this equation to get ghost.x2+ghost.y2<radius2ghost.x^2 + ghost.y^2 \lt radius^2

Express this as a conditional and only do the intersection and the drawing if the ghost is on the radar screen. Including the drawing in this will mean that activated ghosts can't be seen to wander out of the radar circle.

Once this is working reliably, you can go back and set the default alpha value for ghosts to 0.

Part 4: Handling touch

When the user taps the screen, we want to check if they hit any ghosts. We also want to provide the user feedback, so we need to show the user where they tapped, which we will do by drawing a small circle. To indicate that the user caught a ghost, we will change the color of the circle.

We previously used TouchableOpacity for touch events. This provides a familiar interface and nice feedback for the user, dimming the tapped component to show the tap happened.

We are going to get a little more bare-bones, and use the Gesture Responder System directly.

Specifically, we are going to add two more properties to our GLView: onStartShouldSetResponder and onResponderStart.

The first of these is how we register a desire to receive touch events. You should set this to a function that always returns true (in other words, we want touch events).

The callback set on the second prop, onResponderStart, will be called when a touch event happens. Write a callback function in here that has a single argument. That argument is an event object. You will find the full list of its fields in the page linked above, but for our purposes (assuming you called the argument evt), we care about evt.nativeEvent.locationX, and evt.nativeEvent.locationY.

Create a new variable in GhostRadar called taps -- this will hold a collection of "tap" objects so we can paint them.

In the onResponderStart callback, create a new object to represent the tap. It should have four fields:

  • x: the x location of the tap
  • y: the y location of the tap
  • life: this is a measure of how long the tap will remain visible. Initialize to 1.
  • caught: a Boolean indicating if the tap caught anything. Initialize to false

Append the new object to taps.

Draw the taps

Write a new function called updateTaps. Draw a small circle for each record in taps.

After calling this from renderer and running it, you will notice that the taps do not show up at the correct locations on the screen. We have two problems.

  1. We need to correct for moving the origin to the center.
  2. The scaling is off. The pixels of our device tend to be denser than the "resolution", which is what our tap locations are expressed in.

To figure out the pixel ratio, we can import PixelRatio from the React native library.

import { PixelRatio } from 'react-native';

We can then get the ration with

const ratio = PixelRatio.get();

So we need to multiply the coordinate by the ratio and perform the transformation on it (e.g., for x, we want evt.nativeEvent.locationX * ratio - ctx.width/2). Correct both coordinates before adding them to the tap object.

We would like the taps to fade away, so do the same thing we did to the ghosts. Use the life property as an alpha value and slowly diminish it by multiplying it by a value less than 1.

To clean up old taps, add this line after the call to updateTaps() in renderer: taps = taps.filter((tap)=>tap.life > 0.01);. This will "retire" old taps that we can't see any more.

Catching ghosts

In renderer, you will add a test to see if a ghost has been caught.

Iterate over each ghost. For each ghost, iterate over each tap. Test if the distance between the ghost and the tap is less than the radius of the tap (which you had to decide for yourself when you drew it). Use the squared version of Pythagoras again.

If the ghost is inside of the tap circle, set the caught property of each to true.

In updateGhost, add ghost.caught to the collection of conditions that check if the ghost went off screen. In other words, if the ghost is caught, it should just invisibly jump to some new location on screen (you should set the ghost's alpha to 0 when you do this).

In updateTaps, use a different fill color for the taps to show it hit something.

The last thing to do is to connect tapping ghosts to creating ghosts like we were with the button previously.

Go to RadarScreen.js. Add a new prop to the GhostRadar component called catchGhost. The value of the prop should be what we previously had the Button doing: ()=>addGhost(createNewGhost()).

Go back to GhostRadar and call this new prop when a ghost capture is detected. Now, taping on a moving ghost should add one to your list.

Finishing up

Commit your changes to git and push them back up to GitHub. I will find them there.

Grading

Points Requirement
✓/✗ Ghosts move around
✓/✗ Ghosts fade and are revealed by radar sweep
✓/✗ Taps are shown
✓/✗ Ghosts can be "captured"
✓/✗ The list of ghosts updates with new captures