Saturday, October 15, 2011

Handling 2D Graphics for Multiple Devices

This is a post by Nuffer Brothers Software Team member Cambell Christensen, a talented mobile developer available for hire.

Most app developers run into the multi-platform wall at some point in the development process. Say you’ve designed a game for the iPhone, and since it is such a hit in the Apple Store, you want to release it for the iPad, Android and that new [insert other mobile device here].

But each mobile device seems to have a different screen resolution, aspect ratio and compression support. So if your app is graphics intensive, handling all the resizing and compression can be a real pain, not to mention time consuming.

So how can you get that app out there while it’s hot on the market and not spend weeks going back and forth with the artists just to get a new version out? Write a script to do the work for you! For a recent in-house project, we wrote a script for this very problem and in this post we’ll walk you through our process. It has been a huge time saver.

CHOOSE YOUR WEAPON

We opted for a shell script, but you could use another scripting language of your choice. A shell scripting tutorial is probably outside the scope of this post, but here’s a good shell scripting beginner’s guide for anyone who might be interested: Shell Scripting Tutorial

And it is worth mentioning that there are likely many ways to handle graphic variations for multi-platform support, so this is not the only way by any means. This is just what worked for us. So we hope you will take any ideas you find useful, improve upon them and let us know about it!

Let’s start by taking a look at some important initial considerations for multi-platform graphics.

DESIGN CONSIDERATIONS

First off, it helps to think about multi-platform from the beginning so that the graphics will “work” for multiple devices. By “work”, I mean making sure images will display properly for multiple aspect ratios and screen sizes (and not cut off your hero’s head on the load screen, or whatever).

The artists at Evil Onion Productions created the artwork for our recent android game Redneck Racer as vector graphics (for scalability) and also added image padding to make different aspect ratios less problematic. However, if the graphics are not vector-based, that’s fine. As long as they are high-resolution with padding for different aspects.

On another recent 2D game port we did from PC to iOS, we had to go back to the original graphics and significantly re-design graphics to work for iPhone and iPad. Luckily, the company still had some of the Illustrator and Photoshop files. It is understandable to need to redesign interfaces for different size devices, but on this project, the full size graphics were too small for the iPad, so we had to extend backgrounds and piece other parts together. Case in point--if the game had been designed from the beginning with multiple platforms in mind, then adjusting the graphics would have been much faster.

On to the fun part: The script.

AUTOMATING THE RESIZE PROCESS

Basically, our script sifts through all the high-resolution graphics (and associated meta-data --- we’ll get there in a moment), re-sizes them accordingly, and feeds them into a sprite sheet generator. The sprite sheet generator packages the images into “atlases” with square aspects at power of two sizes (a must for sprite sheets for memory efficiency). The result is a complete graphics package for any given device in a few minutes.

The script has two main ways it can be run:

  • By specifying a device (keep it simple, let the script do the rest)
  • By specifying a target screen size, compression type and which directories to use

There are meta-data files (named “resize.data”) that the script relies on stored along with the source images. We’ll get into detail on the actual content and format of those files in a bit.

But for now, here’s a high-level rundown of each step the resize script takes:

  1. Finds all the resize.data files in any sub-folders of the source directory.
  2. Parses each line and scales the associated image(s) using imagemagick’s “convert” tool
  3. Places the scaled image(s) in an intermediary directory.
  4. Adds the image names to an .atl file (in the intermediary directory; See the flow chart above) which the sprite sheet generator (mkatlas.pl) relies on.
  5. Once all images are resized, it runs the sprite sheet generator with each .atl file.
  6. The generated sprite sheets are placed in the proper directory (we used “assets”) along with the newly generated .atlas files (which contain the coordinates of each graphic in a given sprite sheet).


EXTERNAL UTILITIES

As indicated in the graphic above, the script relies on a few key external tools:

  1. imagemagick (a powerful command-line image processing tool - see convert for details)
  2. mkatlas.pl (a sprite sheet “atlas” generating script from cocos2d-iphone)
  3. etc1tool (command-line tool for ETC1 compression - supported on most Android devices)
  4. texturetool (command-line tool for PVR compression - supported by iOS)

The above tools require arguments (desired image sizes, ratios, formats, etc). The trouble is, how will the script know how big you want your squirrel animation to be? You get all your graphics delivered from the artist and your squirrel road kill is as big as your pick up trucks, so you cannot rely on one ratio to resize everything. So that is why the metadata files (those resize.data files I mentioned) are so important.

THE META-DATA

The format of the “resize.data” files is arbitrary--- you might discover a better, more efficient approach or at least a cooler name for your metadata. The important thing is that you HAVE a format that your script can rely on.

In our approach, each resize.data file consists of a line for each image (or image “glob” for animations) in the folder. Each line consists of the following colon-delimited fields:

  1. an image name (or image glob prefix)
  2. the target graphic-to-screen-height ratio OR an absolute target size
  3. the atlas the final image belongs in (Loading.atlas, Menu.atlas, Game.atlas, however you want to break them up)
  4. OPTIONAL: Compression flags
  5. OPTIONAL: Grouping name for selecting between variations of an image for different aspect ratios. (The naming format for the images that have alternates is Letter_GroupingName.png. A grouping is specified in the resize.data file with: “alternates=”)

Take a look at this example “resize.data” file to clarify the above descriptions:

glob=RedCarDrivesNorth*.png:atlas=game:resize=.234563
glob=SteeringWheel.png:atl=loading:resize=.2375
glob=Stereo.png:atl=loading:resize=.3541666
glob=Audio*.png:atl=loading:resize=.3791666
glob=Controls*.png:atl=loading:resize=.5770833
glob=A_MainBackground.png:atl=loading:resize=1.00000:alternates=MainBackground
glob=B_MainBackground.png:atl=loading:resize=1.00000:alternates=MainBackground
glob=background_grassy*.png:atl=background_grassy:resize=508x1020:compression=yes

When a new graphic is delivered by the artist, adding the image to the game is as simple as specifying an atlas it belongs in, and a resize ratio. Just drop it in the folder and running the script does the rest.

THE SCRIPT(S)

Ok, to tell the truth, the script is actually a set of scripts that call each other. We tried to decouple the resize step and the atlas generation step to allow for more control and re-usability.

Here are the scripts:

prepare_graphics.sh

This script parses parameters, adds necessary tools to the path and calls the other scripts.


#!/bin/bash

# this script depends on resize_fonts.sh, resize_assets.sh and generate_atlases.sh

# (NOTE: generate_atlases.sh depends on mkatlas.pl which depends on compression tools: etc1tool and texturetool)

# for simplicity, this script can be run with a single parameter for a target device (android or iphone).
# Or it can be run with custom size, compression and directory options. See usage for details.

set -u #referencing undefined variable causes error
set -x #print out everything it does
set -e #anything with non-zero exit status aborts script

usage()
{
echo "USAGE: Option 1: <target-device (android or iphone)>"
echo " Option 2: <target-width> <target height> <compression (etc1, pvr or none)> [<graphics-src-dir> <resized-dest-dir> <atlas-dest-dir>]"
exit
}

checkCompression()
{
if [[ ${1} != 'pvr' && ${1} != 'etc1' && ${1} != 'none' ]]; then
echo "error: unsupported compression specification: ${1}"
usage
fi
}

checkSrcDirectory()
{
if [ ! -d "${1}" ]; then
echo "specified source directory does not exist: ${1}"
exit
fi
}

#defaults
compression='none'
graphicsSrcDir='./graphics/ginormous'
resizedGraphicsDir='./graphics/wvga'
atlasesDir='./assets'

targetDevice='android'
targetW=480
targetH=800

if [ $# == 6 ]; then
targetW=${1}
targetH=${2}
compression="${3}"
graphicsSrcDir="${4}"
resizedGraphicsDir="${5}"
atlasesDir="${6}"
elif [ $# == 5 ]; then
targetW=${1}
targetH=${2}
compression="${3}"
graphicsSrcDir="${4}"
resizedGraphicsDir="${5}"
elif [ $# == 3 ]; then
targetW=${1}
targetH=${2}
compression=${3}
elif [ $# == 2 ]; then
targetW=${1}
targetH=${2}
elif [ $# == 1 ]; then

targetDevice=${1}

if [ "${targetDevice}" == 'android' ]; then
compression='etc1'
targetW=480
targetH=800
elif [ "${targetDevice}" == 'iphone' ]; then
compression='pvr'
targetW=640
targetH=960
else
echo "error: unsupported target device: ${targetDevice}"
usage
fi
else
echo "error: unable to interpret parameters"
usage
fi

checkCompression "${compression}"
checkSrcDirectory "${graphicsSrcDir}"

#add android sdk tools dir (for etc1tool) to the PATH variable if the directory exists
if [ -e local.properties ]; then
sdkpath=$(grep 'sdk\.dir' local.properties | cut -s -d= -f2)
#FIXME! check if sdkpath is assigned or not
PATH="${PATH}:${sdkpath}/tools"
fi

echo "target screen resolution: ${targetW}x${targetH}"
echo "compression: ${compression}"
echo "full-size graphics src dir: ${graphicsSrcDir}"
echo "resized graphics dest dir: ${resizedGraphicsDir}"
echo "atlas dest dir: ${atlasesDir}"

# clear out atlas dir
find ${atlasesDir} -type f | egrep -v '(sounds/|fonts/)' | xargs rm -f

./resize_assets.sh ${targetW} ${targetH} "${graphicsSrcDir}" "${resizedGraphicsDir}" || { echo "FAILED!"; exit 1; }

mv "${resizedGraphicsDir}/"*".road" "${resizedGraphicsDir}/realrect.bound" "${atlasesDir}"

./generate_atlases.sh ${compression} "${resizedGraphicsDir}" "${atlasesDir}" || { echo "FAILED!"; exit 1; }
./resize_fonts.sh ${targetH} "${graphicsSrcDir}" "${atlasesDir}/fonts" || { echo "FAILED!"; exit 1; }

echo "Finished!"

resize_graphics.sh

This script searches for the resize.data files, resizes images accordingly and places them in the intermediary resize folder.

#!/bin/bash

# generates scaled graphics and .atl, .road and realrect.bound files from full size graphics and associated metadata

# USAGE: <target-Screen-Width> <target-Screen-Height> <src-dir> <dest-dir>

# depends on resize.data metadata files  and .road (if any) metadata file(s) in src dir

# METADATA LINE FORMAT in resize.data file
# <glob=filename_glob>:<atl=atlas_group_name>:<resize=resize_param>[:alternates=<alternate_set_name>][:compression=yes][,compression param 01][,compression param 02],...[:realrect=filename,left bound,top bound,right bound,bottom bound, scale factor X, scale factor Y]

# ALTERNATES
# some files have alternate source graphics for different aspect ratios.  Alternates should be named with a letter underscore prefix such as 'A_'
# Each alternate set has a an <alternate_set_name> to designate the set of files from which a choice will be made.

# EXAMPLE
# since the iPhone4 resolution is 960h x 640w, the script could be called using the following command:
# $./resizeAssets.sh 640 960 ./graphics/ginormous ./graphics/wvga 

# FUNCTIONS 
# usage()              --- called by main()
# processAlternates()  --- called by resizeGraphics()
# resizeGraphics()     --- called by main()
# main()

set -u #referencing undefined variable causes error
set -x #print out everything it does
set -e #anything with non-zero exit status aborts script

usage()
{
  echo "USAGE: <target-Screen-Width> <target-Screen-Height> <src-dir> <dest-dir>"
  exit 1
}

#select from alternates stored in *.alternate.tmp files
#each alternate set will have a different .alternate.tmp file associated with it
processAlternates()
{
  targetWidth=${1}
  targetHeight=${2}
  outDir="${3}"

  find . -type f -name '*.alternates.tmp' | while read alternateFile; do

    currDir=$(dirname ${alternateFile})
  
    cat ${alternateFile} | while read alternate; do
  
      altglob=$( echo "${alternate}" | cut -s -d: -f1  | cut -s -d= -f2)
      targetAspect=$(echo "scale=12; ${targetWidth} / ${targetHeight}" | bc )

      #calculate difference between target aspect ratio and current alternate possibility's aspect
      imageWidth=$(convert "${currDir}/${altglob}" -format %w info:)
      imageHeight=$(convert "${currDir}/${altglob}" -format %h info:)
      imageAspect=$(echo "scale=12;  ${imageWidth} / ${imageHeight}" | bc )
      difference=$(echo "scale=12; ${imageAspect} - ${targetAspect}" | bc )
      absoluteDifference=${difference#-}
      
      #append difference field and add the new alternate possibility to the file
      echo ${absoluteDifference}:${alternate} >> ${currDir}/alternates.ratios.tmp
      #sort lines in file so that closest ratio to target aspect ratio is at the top
      sort -n -t ':' -k 1 ${currDir}/alternates.ratios.tmp
      #pull out the closest option
      closest=$(head -n 1 ${currDir}/alternates.ratios.tmp)
      #save only the closest option
      echo ${closest} > "${currDir}/alternates.ratios.tmp"
    done
  
    #extract data of the closest match to the target aspect ratio
    cat "${currDir}/alternates.ratios.tmp" | tr ':' '\n'  > "${currDir}/closest.alternate.tmp"
    closestGlob=$(grep 'glob=' ${currDir}/closest.alternate.tmp  | cut -s -d= -f2)
    closestAtl=$(grep 'atl=' ${currDir}/closest.alternate.tmp  | cut -s -d= -f2)
    closestResize=$(grep 'resize=' ${currDir}/closest.alternate.tmp  | cut -s -d= -f2)
    closestWidth=$(echo "${targetWidth} * ${closestResize}" | bc)
    
    rm ${currDir}/alternates.ratios.tmp
    rm ${currDir}/closest.alternate.tmp
    
    #now that the appropriate alternate is selected from the choices, process that image or image sequence (if an animation)
    find "${currDir}" -type f -name "${closestGlob}" | while read image; do
      outputName="$(basename ${image})"
      #strip off alternate's letter-underscore prefix so that resized-dir receives a predictable filename from any of the alternate options
      outputName="${outputName#??}"
      convert "${image}" -define png:color-type=6 -resize ${closestWidth} "${outDir}/${outputName}"
      echo $(basename "${outputName}") >> "${outDir}/${closestAtl}.atl"
    done
  
    rm "${alternateFile}"
  done
}

resizeGraphics()
{
  targetWidth=$1
  targetHeight=$2
  fullSizeDir="${3}"
  outDir="${4}"
   
  #initialize realrect.bound file with a header
  #realrect.bound is a file ised to overrides collision information for animations that do not fill their own frame for the duration of the animation
  # i.e. - so that you don't destroy an obstacle merely by riding past it.
  echo "<name> <left> <top> <right> <bottom> <x scale factor> <y scale factor>" > "${outDir}/realrect.bound"


  find "${fullSizeDir}" -type f -name 'resize.data' | while read filename; do
    cat ${filename} | while read line; do
    
      resizeDir=$(dirname ${filename})
    
      if [ x${line:+set} = xset ]; then
      
        newLine=$(echo ${line} | tr ':' '\n')
      
        echo "${newLine}" > ${resizeDir}/resize.tmp

        glob=$(grep 'glob=' "${resizeDir}/resize.tmp"  | cut -s -d= -f2)
        atlas=$(grep 'atl=' "${resizeDir}/resize.tmp" | cut -s -d= -f2)
        resize=$(grep 'resize=' "${resizeDir}/resize.tmp" | cut -s -d= -f2)
        compression=$(grep 'compression=' "${resizeDir}/resize.tmp" | cut -s -d= -f2)
        alternates=$(grep 'alternates=' "${resizeDir}/resize.tmp" | cut -s -d= -f2)
        realrect=$(grep 'realrect=' "${resizeDir}/resize.tmp" | cut -s -d= -f2)
      
        rm ${resizeDir}/resize.tmp
      
        #compress.tmp stores names of files requiring compression before sprite sheet generation
        if ! grep "${atlas}" "${outDir}/compress.tmp" > /dev/null 2>&1; then
          echo "${atlas}:compression=${compression:-no}" >> "${outDir}/compress.tmp"
        fi
        
        #if a specific target size in form <width>x<height> is specified, these variables will be set
        absoluteX=$(echo ${resize} | cut -s -dx -f1)
        absoluteY=$(echo ${resize} | cut -s -dx -f2)

        #alternates stored to be processed later - alternate sets should not span multiple resize.data files
        if [ x${alternates:+set} = xset ]; then
          echo "${line}" >> "${resizeDir}/${alternates}.alternates.tmp"
        else
          #if absolute <width>x<height> target size specified (can be aspect ratio destructive)
          if [ x${absoluteY:+set} = xset ]; then
            find "${resizeDir}" -type f -name "${glob}" | while read absoluteFile; do
              convert "${absoluteFile}" -define png:color-type=6 -resize "${absoluteX}x${absoluteY}!" "${outDir}/$(basename ${absoluteFile})"
            done
          else
          #else target ratio to screen-size specified
            width=$(echo "scale=12; ${targetWidth} * ${resize}" | bc | xargs printf "%1.0f")
            find "${resizeDir}" -type f -name "${glob}" | while read ratioFile; do
              convert "${ratioFile}" -define png:color-type=6 -resize ${width} "${outDir}/$(basename ${ratioFile})"
              echo "$(basename ${ratioFile})" >> "${outDir}/${atlas}.atl"
            done
          fi
          
          #ENGINE SPECIFIC FILES NEEDING TO BE SCALED WITH IMAGE(S)
          #road files are for determining road borders in game
          #if .road files exist, they need to be scaled to match the newly scaled image files to which they correspond
          #roadFilesExist=$(find ${resizeDir} -type f -name ${glob%.png}.road)
          #if [ x${roadFilesExist:+set} = xset ]; then
          #  ./scaleRoadBounds.sh "${glob}" ${resize} ${targetWidth} ${targetHeight} "${resizeDir}" "${outDir}"  
          #fi
          
          #ENGINE SPECIFIC FILES NEEDING TO BE SCALED WITH IMAGE(S)
          #the realrect.bounds file is used to override collision data
          #if the current image has realrect data, scale it      
          #if [ x${realrect:+set} = xset ]; then        
          #  ./scaleRealRect.sh "${realrect}" ${resize} ${targetWidth} ${targetHeight} "${resizeDir}" "${outDir}" 
          #fi
        fi
      
      fi
    done
  done
  
  #now that the main resizing process is over, alternate.tmp files will have been generated for any alternate sets
  processAlternates ${targetWidth} ${targetHeight} "${outDir}"
}

main()
{
  if [ $# != 4 ]
  then
    usage
  fi

  targetScreenWidth=$1
  targetScreenHeight=$2
  srcDir="${3}"
  destDir="${4}"
  
  if [ ! -d "${srcDir}" ]; then
    echo "source directory does not exist: ${srcDir}"
    exit 1  
  fi
  
  #WARNING: specify destination directory carefully - this script will destroy it!
  if [ -d "${destDir}" ]; then
    rm -rf "${destDir}"
  fi

  mkdir -p "${destDir}"
  
  resizeGraphics ${targetScreenWidth} ${targetScreenHeight} "${srcDir}" "${destDir}"
}

main ${@}

generate_atlases.sh

This script feeds the resized graphics in the intermediary directory into the sprite sheet generating script.

#!/bin/bash

# Generates sprite sheets for each atlas

# depends on .atl file(s), compress.tmp file, and mkatlas.pl script
# (which requires etc1tool and texturetool for compression)

set -u #referencing undefined variable causes error
set -x #print out everything it does
set -e #anything with non-zero exit status aborts script

usage()
{
echo "USAGE: <compression (pvr, etc1 or none)> <image-src-dir> <atlas-dest-dir>"
}

if [ $# != 3 ]; then
usage
exit 1
fi

compression="${1}"
#source dir must contain a compress.tmp metadata file
srcDir="${2}"
destDir="${3}"

etc1FileEnding='.pkm'
pvrFileEnding='.pvr'
atlasFileEnding='.atl'
compressFileEnding=0


# if 'none' is specified as the compression param,
# no extension is specified and the default output from script mkatlas.pl is .png
# otherwise...
if [ ${compression} == 'etc1' ]; then
compressFileEnding=${etc1FileEnding}
elif [ ${compression} == 'pvr' ]; then
compressFileEnding=${pvrFileEnding}
fi

# compress.tmp file generated by resize_assets.sh script indicates which atlases need compression
# each line in compress.tmp is formatted as follows:
# <atlasname>:compression=<(yes or no)>
cat "${srcDir}/compress.tmp" | while read atlasLine; do

atlasName=$(echo ${atlasLine} | cut -s -d: -f1)
atlasCompress=$(echo ${atlasLine} | cut -s -d: -f2 | cut -s -d= -f2)

# mkatlas params: -f configfile [-s size (128)] [-r imgrootdir (.)] [-p pvrspacing (0)] (cont'd)
# [-e output compressFileEnding (.png)] [-g group name (1)]

if [[ ${atlasCompress} = 'yes' && ${compression} != 'none' ]]; then
perl ./mkatlas.pl -r "${srcDir}" -f "${srcDir}/${atlasName}${atlasFileEnding}" -s 1024 -p 4 -g "${atlasName}" -e "${compressFileEnding}" || { echo "FAILED!"; exit 1; }
mv -f "${srcDir}/${atlasName}"*".atlas" "${srcDir}/${atlasName}"*"Atlas${compressFileEnding}" "${destDir}"
elif [[ ${atlasCompress} = 'no' || ${compression} = 'none' ]]; then
perl ./mkatlas.pl -r "${srcDir}" -f "${srcDir}/${atlasName}${atlasFileEnding}" -s 1024 -p 2 -g "${atlasName}" || { echo "FAILED!"; exit 1; }
mv -f "${srcDir}/${atlasName}"*".atlas" "${srcDir}/${atlasName}"*"Atlas.png" "${destDir}"
else
echo "Error: compression flag error in compress.tmp on line ${atlasLine}"
fi


done

resize_fonts.sh

You’ll notice that we deal with font.resize.data files. We wanted to use the script to generate scaled bitmap fonts which follow some different rules than other graphics, so we added another metadata file-type (called font.resize.data). We put this in a separate resize_fonts.sh script which the main prepare_graphics.sh script calls.

Example “font.resize.data” file line:

font=TheMilkmanConspiracy:outputname=TheMilkmanConspiracy:ratio=0.0225:height=129

The font name and output name seem redundant, but actually allow for multiple sizes of the same font to be generated. Depending on the situation, this might be useful.

Here’s the script that resizes the bitmap fonts:

#!/bin/bash

# generates a resized font image with scaled .atlas file according to a target screen height parameter

# depends on a font.resize.data file in the following format:
# font=<sourcefontname>:outputname=<targetfontname>:ratio=<float value-letterheight-to-screenheight-ratio>:
# height=<sourceletterheight>
# the outputname parameter allows for multiple sizes of a font to be generated from a single source font image.
# the ratio parameter should be set according to how large the font should appear relative to the screen height

# depends on a <fontname>.png font with an associated <fontname>.atlas file in the following format:
# note: extra spacing is used between letters in the generated font-image in order to preserve adequate spacing
# (approx. 4 pixels) after down-scaling.

# image: fonts/TheMilkmanConspiracy.png
# size: 2048 2048
# group: TheMilkmanConspiracy
# quad: fonts/TheMilkmanConspiracy/032 50 50 40 129 1
# quad: fonts/TheMilkmanConspiracy/033 140 50 40 129 1
# quad: fonts/TheMilkmanConspiracy/034 230 50 51 129 1
# ...

# <fontname>.atlas body line format: 
# "quad: dir/name/letterNum xLetterCoord yLetterCoord letterWidth letterHeight"

set -u #referencing undefined variable causes error
set -x #print out everything it does
set -e #anything with non-zero exit status aborts script

usage()
{
  echo "USAGE: <target-Screen-Height> <font-image-src-dir> <font-image-dest-dir>"
  exit 1
}

main()
{
  if [ $# -ne 3 ]
  then
    usage
  fi
  
  ending='.png'
  targetScreenHeight=${1}
  srcDir="${2}"
  destDir="${3}"

  mkdir -p "${destDir}"

  find "${srcDir}" -type f -name font.resize.data | while read fontResizeFile; do
  
    resizeDir=$(dirname "${fontResizeFile}")
    
    cat ${fontResizeFile} | while read fontResizeLine; do  
      
      fontLineWithNewLineDelim=$(echo ${fontResizeLine} | tr ':' '\n')
        
      echo "${fontLineWithNewLineDelim}" > ${resizeDir}/resize.tmp

      sourceFontName=$(grep 'font=' "${resizeDir}/resize.tmp"  | cut -s -d= -f2)
      targetFontName=$(grep 'outputname=' "${resizeDir}/resize.tmp"  | cut -s -d= -f2)
      letterToScreenHeightRatio=$(grep 'ratio=' "${resizeDir}/resize.tmp"  | cut -s -d= -f2)
      ginormousLetterHeight=$(grep 'height=' "${resizeDir}/resize.tmp"  | cut -s -d= -f2)
      
      fontImage=$(echo "${sourceFontName}${ending}")
      ginormousFontWidth=$(convert "${resizeDir}/${fontImage}" -format %w info:)
      
      rm "${resizeDir}/resize.tmp"

      targetLetterHeight=$(echo "scale=12; ${targetScreenHeight} * ${letterToScreenHeightRatio}" | bc)
      fontScale=$(echo "scale=12; ${targetLetterHeight} / ${ginormousLetterHeight}" | bc)
      targetFontWidth=$(echo "scale=12; ${ginormousFontWidth} * ${fontScale}" | bc)

      convert ${resizeDir}/${fontImage} -define png:color-type=6 -resize ${targetFontWidth} ${destDir}/resized${fontImage}

      #font atlas needs to be square
      #calculate next highest power of two
      powOf2=1

      while [ $(echo "scale=1; ${powOf2} < ${targetFontWidth}" | bc) -ne 0 ]; do 
        powOf2=$(echo "scale=0; ${powOf2} * 2" | bc)
      done
      
      #pad image on bottom and right with transparent pixels to the above calculated next highest power of 2
      convert ${destDir}/resized${fontImage} -background transparent -gravity NorthWest -extent ${powOf2}x${powOf2} -define png:color-type=6 ${destDir}/${fontImage}

      rm -f ${destDir}/resized${fontImage}

      #scaling font atlas file
      
      #Line 1: copy straight over - image: fonts/TheMilkmanConspiracy.png
      image=$(grep 'image:' "${resizeDir}/${sourceFontName}.atlas" | cut -s -d' ' -f2)
      #Line 3: copy straight over - group: TheMilkmanConspiracy
      group=$(grep 'group:' "${resizeDir}/${sourceFontName}.atlas" | cut -s -d' ' -f2)
      
      #font atlas header
      echo image: ${image} > ${destDir}/${sourceFontName}.atlas
      echo size: ${powOf2} ${powOf2} >> ${destDir}/${sourceFontName}.atlas
      echo group: ${group} >> ${destDir}/${sourceFontName}.atlas
      
      #font atlas body line format: quad: dir/name/letterNum xLetterBound yLetterBound letterWidth letterHeight
      #example - quad: fonts/TheMilkmanConspiracy/032 4 4 40 129 1
      
      grep 'quad:' ${resizeDir}/${sourceFontName}.atlas | while read quadLine; do
      
        letterID=$(echo "${quadLine}" | cut -s -d' ' -f2)
        xLetterBound=$(echo "${quadLine}" | cut -s -d' ' -f3) 
        yLetterBound=$(echo "${quadLine}" | cut -s -d' ' -f4) 
        letterWidth=$(echo "${quadLine}" | cut -s -d' ' -f5) 
        letterHeight=$(echo "${quadLine}" | cut -s -d' ' -f6)
        
        #scale and round letter bounds to nearest whole number
        newXLetterBound=$(echo "scale=12; ${xLetterBound} * ${fontScale}" | bc | xargs printf "%1.0f") 
        newYLetterBound=$(echo "scale=12; ${yLetterBound} * ${fontScale}" | bc | xargs printf "%1.0f")
        newLetterWidth=$(echo "scale=12; ${letterWidth} * ${fontScale}" | bc | xargs printf "%1.0f")
        newLetterHeight=$(echo "scale=12; ${letterHeight} * ${fontScale}" | bc | xargs printf "%1.0f")
        
        xScale=1
        yScale=1
        
        echo quad: "${letterID} ${newXLetterBound} ${newYLetterBound} ${newLetterWidth} ${newLetterHeight} ${xScale} ${yScale}" >> ${destDir}/${sourceFontName}.atlas
      done
      
      
    done
  done

}

main ${@}

scaleRoadBounds.sh, scaleRealRect.sh

These are additional scripts called by resize_graphics.sh that scale engine specific meta-data files. We haven’t included these scaling script here because they might bore you to tears and they probably don’t apply to your engine, but you get the idea-just scale any metadata while resizing the images to make your life easier.

WELCOME TO THE FUTURE, MARTY - RETINA DISPLAY

For future versions of this script, we plan to add support for the iPhone retina display which uses the image_name@2x.png option to load high-resolution images (see apple developer resource guide for details). This would simply require producing two versions of each iPhone image, one double the dimensions of the other with “@2x” added into the name. Easy, right?

PACKING IT ALL UP

Automating the graphics processing has not only allowed us to focus more on the game-play itself, but has also provided a stream-lined way to add new graphics into our game as we add features.

Sunday, August 21, 2011

Writing a game from scratch, part 2: Activity Stubs

A word of warning here.  This blog in its raw state will cycle from too much detail like a tutorial to completely abstract.  Apologies in advance for those expecting a consistent tutorial throughout.

My natural instinct is to be a bottom-up developer.  As such, when presented with a problem, I get the overview of what needs to be done and then start building the tools and frameworks that I need to complete it.  I have to fight that urge a bit and write high-level stubs, filled with FIXME and TODO descriptions of what should be plugged in.

That is where things will begin here, to allow the most visible parts (the most important parts to the user) to be stubbed first.  This forces the people writing the guts to see them on a regular basis and hopefully clean them up or make suggestions to better them.

So, where do we start?  Pick a platform.  As I happen to have an Android phone and the SDK installed, it makes an obvious choice.  If you do not have it installed, install it, eclipse, and the android plugins for eclipse.  You can find the SDK and, more indirectly, instructions for the plugin here: http://developer.android.com/sdk/index.html

Okay, now what?  We need a project with a bunch of activities.  On each, we will initially stub out some components to reach the others.  While this could be done in a classic game programming style with everything relating to the game completed within a single game loop, I think it is easier -- at least initially -- to do things as separate activities or components.

Create a project called "AfroScratch" with package "com.example.afroscratch", and a new activity "TitleScreen".  You can pick the version of android you wish to target, but 2.1 should provide the widest range of devices.  If all goes well, and you have an AVD configured, you should be able to immediately run your new program in all of its "Hello World" glory.  When satisfied that things run, start adding more activities.  We need the "MainMenu" and "Game" activities to proceed, both of which should belong to the same package as the "TitleScreen".  To create these, add a new class of the desired name and package with the superclass as "android.app.Activity".

To make things more modular, we need multiple views (layouts) defined.  The default "main.xml" should be renamed to "titlescreen.xml", and another should be added for each of the activities you added above, noting that android only allows lowercase characters for resource files.  It is probably also a good habit to create a new string resource file when creating a layout, so they can both be moved to a new project if required.

Now we need to define the initial stubbed UI and hook up the controls.

Title Screen
On the title screen, we need a simple TextView that we can use to display our initial message and act as a button when someone presses it.
To do so, we define the layout in res/layout/titlescreen.xml as:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/TitleText"
android:id="@+id/TitleScreenButton"
/>
</LinearLayout>

That defines a new ID for the control as "TitleScreenButton" and uses a string reference from a newly-added res/values/titlestring.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="TitleText">Click here to skip the title screen</string>
</resources>
 A run can be done to show what the title screen looks like (just a text label), but it still does not do anything.  To make it useful, we need to add a time-delay switch to the MainMenu activity and a button-press action to do the same.

With android applications, it should be noted that all updates to controls must be done through the main thread.  It is possible to schedule the main thread to call back at a later time to update something.  We are going to use this behavior to add our time-delay switch.  Although not technically necessary for switching activities, this may serve useful in your future projects.

Our time delay will make use of a android.os.Handler and a Runnable in src/com/example/afroscratch/TitleScreen.java:
package com.example.afroscratch;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

public class TitleScreen extends Activity implements View.OnClickListener
{
private static final String LOG_TAG = "TitleScreen";
private void switchToMainMenu()
{
// Switch to the new activity.
startActivity(new android.content.Intent(getApplicationContext(), MainMenu.class)); 
finish(); // Quit the current activity
}
private int m_countdown = 10;
private void updateCountdown()
{
Log.d(LOG_TAG, "run called with countdown=" + m_countdown);
if( m_countdown > 0 )
{
// Decrease the timer by a second and force the handler to update again in the main thread.
--m_countdown;
m_handler.postDelayed(m_updateTask, 1000);
return;
}

switchToMainMenu();
}
private android.os.Handler m_handler = new android.os.Handler();
private Runnable m_updateTask = new Runnable() {
public void run()
{
updateCountdown();
}
};
public void onClick(View v)
{
// Stop the timer.
m_handler.removeCallbacks(m_updateTask);
switchToMainMenu();
}
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.titlescreen);
        
        // Tell the TextView to notify us when it is clicked.
        TextView tv = (TextView)findViewById(R.id.TitleScreenButton);
        tv.setOnClickListener(this);
        
        // Start the timeout update cycle, giving time for this function to return.
        m_handler.postDelayed(m_updateTask, 100);
    }
}

For the activity switch to actually work, the target activity must be listed in the AndroidManifest.xml:
<activity android:name=".MainMenu" android:label="@string/app_name" />
<activity android:name=".Game" android:label="@string/app_name" />

We still need some glue for the main menu and the game.  The activity switch from the main menu to the game will be done in the same manor as from the title screen to the main menu, with the exception of the timer.

More resources and glue are needed...

MainMenu:
  • res/layout/mainmenu.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    >
    <TextView
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="@string/Welcome"
    android:id="@+id/WelcomeLabel"
    />
    <Button
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="@string/NewGame"
    android:id="@+id/NewGameButton"
    />
    <Button
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:text="@string/Quit"
    android:id="@+id/QuitButton"
    />
    </LinearLayout>
  • res/values/mainmenu.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="Welcome">Welcome to AfroScratch</string>
<string name="NewGame">New Game</string>
<string name="Quit">Quit</string>
</resources> 
  •  src/com/example/afroscratch/MainMenu.java:
package com.example.afroscratch;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

public class MainMenu extends Activity implements OnClickListener
{
/** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.mainmenu);
        
        Button newGame = (Button)findViewById(R.id.NewGameButton);
        Button quitButton = (Button)findViewById(R.id.QuitButton);
        
        newGame.setOnClickListener(this);
        quitButton.setOnClickListener(this);
    }
    
    public void onClick(View v)
    {
    if( v.getId() == R.id.QuitButton )
    {
    finish();
    }
    else if( v.getId() == R.id.NewGameButton )
    {
    startActivity(new android.content.Intent(getApplicationContext(), Game.class));
    }
    }
}
And finally the game screen:

  • res/layout/game.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent">
<TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="FIXME! Replace me with a game display" />
</LinearLayout>

  • src/com/example/afroscratch/Game.java:
package com.example.afroscratch;

import android.app.Activity;
import android.os.Bundle;

public class Game extends Activity 
{
/** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.game);
    }
}

If all goes as planned, the title screen should appear for about 10 seconds, switching automatically to the main menu.  Switching can be done faster by clicking on the title screen's one and only label.