Pages

Thursday, October 25, 2018

Moving to Medium

Medium's clean editor and social features have won me over.
I'm posting new articles at https://medium.com/@zmxv.

The first two posts are regarding open-source projects I recently published on Github:

Thursday, January 14, 2016

Let's write a mobile game with React Native (Part 2)

This is the second part of my React Native tutorial that shows you how to write a cross-platform mobile game. In Part 1, we've rendered a resolution-independent letter grid with custom glyphs. We're going to add event handling and animations to enliven the app.

The following guide builds on top of the v0.1 code release; the end result can be found in v0.2.

7. Touch event handling

The conventional way to capture click events in React Native is to use four Touchable* components: TouchableHighlight, TouchableActiveFeedback, TouchableOpacity, and TouchableWithoutFeedback.

TouchableActiveFeedback is Android-only; TouchableHighlight often causes undesirable artifacts; TouchableWithoutFeedback should be used with caution as it provides no visual cues; in most cases such as creating a button, you should consider using TouchableOpacity.

Let's give it a try by importing the component from React in boardview.js.

var {
  ...
  TouchableOpacity, // <- New
  ...
} = React;

We then extract a renderTile method from renderTiles and wrap each tile <View> inside a <TouchableOpacity>:

...
  renderTiles() {
    var result = [];
    for (var row = 0; row < SIZE; row++) {
      for (var col = 0; col < SIZE; col++) {
        var key = row * SIZE + col;
        var letter = String.fromCharCode(65 + key);
        var position = {
          left: col * CELL_SIZE + CELL_PADDING,
          top: row * CELL_SIZE + CELL_PADDING
        };
        result.push(this.renderTile(key, position, letter)); // <- New
      }
    }
    return result;
  },

  // New
  renderTile(id, position, letter) {
    return <TouchableOpacity key={id}
                             onPress={() => console.log(id)}>
             <View style={[styles.tile, position]}>
               <Text style={styles.letter}>{letter}</Text>
             </View>
           </TouchableOpacity>;
  },
...

Notice that each <TouchableOpacity> has a unique key property because they all nest under the same parent node. As mentioned in Part 1 Section 5, this helps React Native to efficiently compare virtual DOM trees. The inner <View> and <Text> don't need keys because they have no sibling nodes at all — it doesn't hurt if you set keys, though.

The onPress property uses ES6's arrow syntax to declare an anonymous, parameterless event handler. For now, it logs the tile id for debugging; our real game logic will be filled in later.

Play with the app on a device and you'll observe two flaws:

  1. The onPress event handler isn't triggered until you lift your finger (see the screencast above). This behavior is not bad for regular apps because it allows users to easily cancel a click by sliding outside the click target. However, responsiveness is paramount in a game, so we'd like to fire the event handler as soon as a touch starts. You could solve this by binding an onPressIn handler rather than an onPress, yet it won't fix the next issue.
  2. If you press a tile and hold your finger, then press another tile, the second click won't register. Again, this behavior is perfectly fine for regular apps, but in our fast-paced game, it'll cause tremendous frustration when a player taps with multiple fingers at high speed.

A quick solution that kills two birds with one stone is to get rid of the TouchableOpacity wrapper and directly attach an onStartShouldSetResponder handler to each tile <View>. The handler should always return false or a false-y value like undefined, so that it never nominates itself as the event responder. Our new renderTile method looks like this:

...
  renderTile(id, position, letter) {
    return <View key={id} style={[styles.tile, position]}
                 onStartShouldSetResponder={() => this.clickTile(id)}>
             <Text style={styles.letter}>{letter}</Text>
           </View>;
  },

  clickTile(id) {
    console.log(id);
  },
...

As the following screencast demonstrates, each click now immediately triggers the clickTile event handler. You may also verify on a device that no clicks are lost when multi-touch is involved.

The only thing missing is some form of visual feedback to highlight the tile being pressed. We're going to fix that with animations.

8. Property animation

React Native provides a convenient Animated module for animating component properties. It's designed in such a way that our rendering logic can remain largely intact when we plug in the animation logic. Let's add a simple opacity animation to see how it works.

First, import Animated and Easing:

var {
  Animated, // <- New
  Easing, // <- New
  ...
} = React;

Add a getInitialState() method to the BoardView component, where we initialize 16 (4x4) Animated.Value instances, each controlling the opacity of a single tile. Pass 1 to the constructor of Animated.Value so that all letter tiles are fully opaque at the start.

var BoardView = React.createClass({
  getInitialState() { // New method
    var opacities = new Array(SIZE * SIZE);
    for (var i = 0; i < opacities.length; i++) {
      opacities[i] = new Animated.Value(1);
    }
    return {opacities}; // ES6 shorthand for {opacities: opacities}
  },
  ...

Once React Native mounts the component, individual Animated.Value instances can be accessed by this.state.opacities[id]. Hook them up with the style object of letter tiles as shown below:

  renderTiles() {
    var result = [];
    for (var row = 0; row < SIZE; row++) {
      for (var col = 0; col < SIZE; col++) {
        var id = row * SIZE + col;
        var letter = String.fromCharCode(65 + id);
        var style = {
          left: col * CELL_SIZE + CELL_PADDING,
          top: row * CELL_SIZE + CELL_PADDING,
          opacity: this.state.opacities[id], // <- New
        };
        result.push(this.renderTile(id, style, letter));
      }
    }
    return result;
  },

  renderTile(id, style, letter) {
    //      v- New
    return <Animated.View key={id} style={[styles.tile, style]}
               onStartShouldSetResponder={() => this.clickTile(id)}>
             <Text style={styles.letter}>{letter}</Text>
           </Animated.View>;
  },

Make sure that you change <View> to Animated.View where Animated.Values can be applied to. Otherwise, you'll run into a scary, red screen of error that isn't particularly informative:

Similarly, you can animate Text with Animated.Text, Image with Animated.Image, or custom components created with createAnimatedComponent. At this stage, nothing is actually animated yet. We'll kick off the opacity animation in the clickTile event handler using the Animated.timing API:

  clickTile(id) {
    var opacity = this.state.opacities[id];
    opacity.setValue(.5); // half transparent, half opaque
    Animated.timing(opacity, {
      toValue: 1, // fully opaque
      duration: 250, // milliseconds
    }).start();
  },

With these simple changes, we have achieved the following effect:

Animated.timing gives us two additional options to fine-tune the animation: easing (custom easing function) and delay (delay in milliseconds). You may also want to play with Animated.spring (bouncing animation) or orchestrate multiple animations with Animated.sequence (sequential animation) and Animated.parallel (simultaneous animation).

Faded opacity is a tad boring. Let's replace it with a 3D tilting effect as if tiles revolve around the X-axis. That's right — we can do simple 3D transformations in React Native!

First rename the Animated.Values and initialize them with 0:

  getInitialState() {
    var tilt = new Array(SIZE * SIZE);
    for (var i = 0; i < tilt.length; i++) {
      tilt[i] = new Animated.Value(0);
    }
    return {tilt};
  },

Then replace opacity in style with a transform — an array of transformation objects:

  • {perspective: ...} declares the virtual distance from the viewing point to the z=0 plane. Use it to control the intensity of 3D effect: the greater the value is, the further away you are from the objects, the less intense the distortion appears (i.e. objects look more flat).
  • {rotateX: ...} sets the rotational degrees around the X-axis, creating a tilting effect. Due to an unfortunate API design (as of RN 0.18), all the rotational properties (rotate, rotateX, rotateY, and rotateZ) only take a string (e.g. '30deg'). We can't directly apply a numerical degree or radian value, so we resort to Animated.Value's interpolate function to map a floating point number to a string.

  renderTiles() {
    var result = [];
    for (var row = 0; row < SIZE; row++) {
      for (var col = 0; col < SIZE; col++) {
        var id = row * SIZE + col;
        var letter = String.fromCharCode(65 + id);
        var tilt = this.state.tilt[id].interpolate({
          inputRange: [0, 1],
          outputRange: ['0deg', '-30deg']
        });
        var style = {
          left: col * CELL_SIZE + CELL_PADDING,
          top: row * CELL_SIZE + CELL_PADDING,
          transform: [{perspective: CELL_SIZE * 8},
                      {rotateX: tilt}]
        };
        result.push(this.renderTile(id, style, letter));
      }
    }
    return result;
  },

The final step is to patch the clickTile method to set up the new animation.

  clickTile(id) {
    var tilt = this.state.tilt[id];
    tilt.setValue(1); // mapped to -30 degrees
    Animated.timing(tilt, {
      toValue: 0, // mapped to 0 degrees (no tilt)
      duration: 250, // milliseconds
      easing: Easing.quad // quadratic easing function: (t) => t * t
    }).start();
  },

Technically, this 3D effect is not accurate — it's a mix of orthogonal projection and perspective projection. If you aspire to render a photorealistic scene with fancy shaders, I'd suggest that you take a look at gl-react-native — wait, no, please please use a real 3D engine. For our little game, I'd say this faux-3D animation is acceptable:

Full source up to this point can be found at https://github.com/zmxv/alpha-reflex/releases/tag/v0.2. In the next article, we'll create a timer component using a different animation technique and implement some game-specific logic.

Saturday, January 9, 2016

Let's write a mobile game with React Native

1. Introduction

This is the first part of a tutorial that will show you how to write a cross-platform mobile game with React Native. We're going to replicate my casual game "Alpha Reflex" which can be freely downloaded from the App Store and the Play Store. The game challenges players to find randomized letters in alphabetical order as fast as possible.

React Native isn't really designed for games. There exists a myriad of better tools for professional game development: Unity, libgdx, cocos2d-x, Moai, Starling, LÖVE, etc. So why are we creating a mobile game with React Native? First off, even simple games are more fun to develop than yet another To-Do app. Games also push for a wide range of transferable skills that will help you develop other React Native apps more effectively. In this series, we'll cover native module development, 2d/3d animations, custom event handling, cross-platform considerations, and many other topics.

Source code for this tutorial can be found at https://github.com/zmxv/alpha-reflex, though I'd suggest that you follow the guide to remake the app from scratch to gain some hands-on practice.

2. App set-up

If you haven't installed the React Native command line tool, please follow the instructions at https://facebook.github.io/react-native/docs/getting-started.html to get started.

Ready? Now run $ react-native init AlphaReflex from the command line. This creates a skeleton app that displays a welcome screen.

To test the iOS app, open alpha/ios/alpha.xcodeproj in Xcode and hit ⌘+R. To run the Android version, you'll first need to either start an emulator or connect a device before executing $ react-native run-android.

Android's emulator is excruciatingly slow. Installing Intel's HAXM alleviates the pain a little bit, but you're better off with a physical test device connected to your computer. If you're using OS X, I recommend that you develop apps for iOS first and verify Android compatibility later. It'll save you a lot of time on thumb-twiddling.

Okay, now is a good time to take a snapshot of your code in a version control system. For example:

git init
git add .
git commit -m 'Initial commit'

3. Entry files

Under the app directory, you'll find two auto-generated JavaScript files: index.ios.js and index.android.js. They are the entry points for iOS and Android respectively. To reuse as much code as possible for both platforms, we should minimize the amount of logic there. I suggest that you only register an app entry class in these two files:

require('react-native').AppRegistry.registerComponent('AlphaReflex',
    () => require('./main.js'));

We then create a real, shared entry class in a main.js file:

'use strict';

var React = require('react-native');
var {
  StyleSheet,
  Text,
  View,
} = React;

var Main = React.createClass({
  render() {
    return <View style={styles.container}>
             <View style={styles.tile}>
               <Text style={styles.letter}>A</Text>
             </View>
           </View>;
  },
});

var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#644B62',
  },
  tile: {
    width: 100,
    height: 100,
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#BEE1D2',
  },
  letter: {
    color: '#333',
    fontSize: 80,
  },
});

module.exports = Main;

Main is our first custom UI component. For now, it simply displays a lonely letter tile in its render() method.

Alternatively, you may write class Main extends React.Component to declare the Main component. However, the ES6 class syntax makes it harder to define mix-ins and static properties; the code is also slightly longer. We'll therefore stick to React.createClass.

4. Flexbox

React Native's flexbox layout model borrows ideas and terms from the CSS counterpart. In our main.js file, the flex: 1 declaration stretches the container class to fill the entire screen. If you throw in a sibling <View> with the same style, each <View> will get half of the screen real estate because 1:1 equals 50%:50%. To split the screen into one-third and two-thirds parts, you'll need to style them with flex: 1 and flex: 2.

The flexDirection property controls the direction of child element flow, which is column-wise by default. Change it to 'row' if you want a horizontal arrangement.

justifyContent justifies elements along the primary flex direction; alignItems does the same job along the perpendicular axis. In the previous example, if you want to snap the letter tile to the left edge while keeping it vertically centered, you may change container's alignItems to 'flex-start' or simply comment out that line because 'flex-start' is the default value. The result is shown below:

In general, the flexbox model is quite useful for designing adaptive layouts as it frees us from manually computing the exact bounds of UI elements. Yet we'll sometimes do the calculation ourselves and embrace position: 'absolute' when it comes to dynamic layouts.

5. Resolution-independent rendering

Next, we're going to create another React Native component that renders a 4x4 grid in a resolution-independent manner. Resolution independence is a nice property that scales our UI to fit any size so it won't leave excessive whitespace on large screens. To achieve that, there're generally two strategies:

  1. Dynamically set the size of UI widgets in proportion to the target screen dimension
  2. Pick a "design size" (e.g. 640x960), hardcode the dimension and position of UI widgets inside that box, globally scale the box and its child elements to fit the screen

The second approach is easier to work with, although it may lead to blurred results on large screens. It also lacks flexibility in fixed positioning. For our game, we'll go with the first approach and calculate appropriate UI dimensions at runtime.

We can retrieve the screen size from the built-in module 'Dimensions' using ES6's destructuring assignment syntax:

var {width, height} = require('Dimensions').get('window');

which is a concise way of saying:

var width = require('Dimensions').get('window').width;
var height = require('Dimensions').get('window').height;

The {width, height} pair is the logical resolution (e.g. 375x667 on iPhone 6) rather than the physical resolution (e.g. 750x1334 on iPhone 6), which, if you need, could be deduced by multiplying the former by the pixel density (require('react-native').PixelRatio.get()).

On iOS, height represents the full screen height; on Android, however, this value excludes the height of the status bar but includes the system navigational area at the bottom.

The rest is just basic arithmetic. Let's do it in a new component file boardview.js:

'use strict';

var React = require('react-native');
var {
  StyleSheet,
  Text,
  View,
} = React;
var {width, height} = require('Dimensions').get('window');
var SIZE = 4; // four-by-four grid
var CELL_SIZE = Math.floor(width * .2); // 20% of the screen width
var CELL_PADDING = Math.floor(CELL_SIZE * .05); // 5% of the cell size
var BORDER_RADIUS = CELL_PADDING * 2;
var TILE_SIZE = CELL_SIZE - CELL_PADDING * 2;
var LETTER_SIZE = Math.floor(TILE_SIZE * .75);

var BoardView = React.createClass({
  render() {
    return <View style={styles.container}>
             {this.renderTiles()}
           </View>;
  },

  renderTiles() {
    var result = [];
    for (var row = 0; row < SIZE; row++) {
      for (var col = 0; col < SIZE; col++) {
        var key = row * SIZE + col;
        var letter = String.fromCharCode(65 + key);
        var position = {
          left: col * CELL_SIZE + CELL_PADDING,
          top: row * CELL_SIZE + CELL_PADDING
        };
        result.push(
          <View key={key} style={[styles.tile, position]}>
            <Text style={styles.letter}>{letter}</Text>
          </View>
        );
      }
    }
    return result;
  },
});

var styles = StyleSheet.create({
  container: {
    width: CELL_SIZE * SIZE,
    height: CELL_SIZE * SIZE,
    backgroundColor: 'transparent',
  },
  tile: {
    position: 'absolute',
    width: TILE_SIZE,
    height: TILE_SIZE,
    borderRadius: BORDER_RADIUS,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#BEE1D2',
  },
  letter: {
    color: '#333',
    fontSize: LETTER_SIZE,
    backgroundColor: 'transparent',
  },
});

module.exports = BoardView;

Notice how we set a unique key property on each tile View. This practice helps React Native to detect virtual DOM changes more efficiently. Whenever you create an array of homogeneous components, remember to do this; otherwise, you'll face a prominent yellow-box warning on the screen.

We are now ready to render this component in the much simplified main.js:

'use strict';

var React = require('react-native');
var {
  StyleSheet,
  View,
} = React;

var BoardView = require('./boardview.js');

var Main = React.createClass({
  render() {
    return <View style={styles.container}>
             <BoardView/>
           </View>;
  },
});

var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#644B62',
  },
});

module.exports = Main;

Voilà, we've just created a 4x4 grid of letters that scales nicely on both phones and tablets.

6. Custom fonts

The default system font looks legible yet unadorned; it's also inconsistent across platforms. To improve the typography, we're going to bundle a custom font with the app.

Let's do that for Android first.

  • Step 1: Create a directory android/app/src/main/assets/fonts.
  • Step 2: Drop .TTF or .OTF fonts there.
    The font files will be automatically packaged and ready for use in JavaScript.
  • Step 3: Reference the custom font name in a fontFamily property in boardview.js.
    var styles = StyleSheet.create({
      ...
      letter: {
        fontFamily: 'NukamisoLite', // <= custom font name
        ...
      },
    });
    

Now rebuild the Android app by running react-native run-android.

Let's move on to iOS.

  • Step 1: Drag and drop custom font files into the Project Navigator in XCode.
    When prompted, make sure the fonts are added to the primary build target.
  • Step 2: Select Info.plist in the Project Navigator. Click the "+" button next to "Information Property List" to add a new row. Find "Fonts provided by application" from the drop-down list.
  • Step 3: Expand the new row by clicking the triangular icon. Double-click the empty Value slot and enter the font file name without its full path.
    You may click the "+" button next to "Item 0" to add additional fonts.

Since we're using the same code base for both platforms, there's no need to edit JavaScript if you've already done step 3 for Android. Just rebuild the iOS app and check the results. (We've added a new asset to the project, so we have to rebuild rather than Cmd+R refresh.)

Full source of this part of the tutorial can be downloaded from https://github.com/zmxv/alpha-reflex/releases/tag/v0.1. The next article will discuss high-performance animations, touch event handling, and more.

(Part 2: touch event handling and property animation)