Building Chrome’s T-Rex Game in Jetpack Compose
Wajahat Karim
wajahatkarim.com
WajahatKarim
Building Chrome’s T-Rex Game in Jetpack Compose
a modern UI toolkit which simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.
a modern UI toolkit which simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.
Games have different structure than software/apps
Game Loop
// A Simple Game Loop
int main()
{
initialize();
while(true)
{
processInputs();
updateGameData();
renderGame();
checkShutdown();
}
shutdown();
}
A simple Spacer() to allow you to draw anything on it
@Composable
fun Canvas
(
modifier: Modifier,
onDraw: DrawScope.() -> Unit
) = Spacer(modifier.drawBehind(onDraw))
fun DrawScope.drawMyShape() { }
DrawScope - Handles the drawing API
drawRect()
drawOval()
drawLine()
drawImage()
drawRoundRect()
drawCircle()
drawArc()
drawPath()
fun DrawScope.drawMyShape() { }
Custom View Example in Canvas
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.Red,
radius = 300f
)
drawCircle(
color = Color.Green,
radius = 200f
)
drawCircle(
color = Color.Blue,
radius = 100f
)
}
Basics
// Device Width in Pixels
var deviceMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(deviceMetrics)
deviceWidthInPixels = deviceMetrics.widthPixels
deviceWithInPixels
X
Y
Earth Y
// Earth Y position
const val EARTH_Y_POSITION = 500f
Earth
fun DrawScope.EarthView(earthState: EarthState)
{
// Ground Line
drawLine(
color = Color.DarkGray,
start = Offset(x = 0f, y = EARTH_Y_POSITION),
end = Offset(x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION),
strokeWidth = EARTH_GROUND_STROKE_WIDTH
)
// Dirt Line 1
drawLine(
color = Color.DarkGray,
start = Offset(x = 0f, y = EARTH_Y_POSITION + 20),
end = Offset(x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION + 20),
strokeWidth = EARTH_GROUND_STROKE_WIDTH / 5,
pathEffect = DashPathEffect(floatArrayOf(20f, 40f), 0f)
)
// Dirt Line 2
drawLine(
color = Color.DarkGray,
start = Offset(x = 0f, y = EARTH_Y_POSITION + 30),
end = Offset(x = x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION + 30),
strokeWidth = EARTH_GROUND_STROKE_WIDTH / 5,
pathEffect = DashPathEffect(floatArrayOf(15f, 50f), 40f)
)
}
Vector Icons (SVG)
Icons made by Freepik from www.flaticon.com
Vector Icons Issues
I resized all to 200x200 using this website
Clouds
Text
No method to draw VectorDrawables in Canvas yet!
Clouds
// Cloud Path
private var CLOUD_PATH_STR = "M169.895,88.699C167.27,71.887 152.695,58.984 135.156,58.984C128.559,58.984 122.219,60.809 116.719,64.215C108.355,50.156 93.293,41.406 76.563,41.406C50.715,41.406 29.688,62.434 29.688,88.281C29.688,88.441 29.688,88.609 29.691,88.766C13.082,91.566 0,106.047 0,123.438C0,142.824 16.16,158.594 35.547,158.594L164.453,158.594C183.84,158.594 200,142.824 200,123.438C200,105.898 186.707,91.324 169.895,88.699ZM169.895,88.699"
private var cloudPath = PathParser().parsePathString(CLOUD_PATH_STR)
fun CloudPathNodes() = cloudPath.toNodes()
fun CloudPath(): Path {
var path = cloudPath.toPath()
var scaleMatrix = Matrix()
scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
var androidPath = path.asAndroidPath()
androidPath.transform(scaleMatrix)
return androidPath.asComposePath()
}
// Draw Cloud
fun DrawScope.CloudsView(cloudState: CloudState)
{
drawPath(
path = cloud.path,
color = Color(0xFFC5C5C5),
style = Stroke(2f)
)
}
Cactus
// Cactus Path
private var CACTUS_PATH_STR = "M57.449,111.191L85.246,111.191L85.246,200.145L118.605,200.145L118.605,137.137L142.695,137.137C149.859,137.137 155.668,131.328 155.668,124.164L155.668,59.301C155.668,52.137 149.859,46.328 142.695,46.328C135.531,46.328 129.723,52.137 129.723,59.301L129.723,111.191L118.605,111.191L118.605,16.68C118.605,7.469 111.137,0 101.926,0C92.715,0 85.246,7.469 85.246,16.68L85.246,85.246L70.422,85.246L70.422,37.063C70.422,29.898 64.613,24.09 57.449,24.09C50.285,24.09 44.477,29.898 44.477,37.063L44.477,98.219C44.477,105.383 50.285,111.191 57.449,111.191ZM57.449,111.191"
private var cactusPath = PathParser().parsePathString(CACTUS_PATH_STR)
fun CactusPathNodes() = cactusPath.toNodes()
fun CactusPath(): Path {
var path = cactusPath.toPath()
var scaleMatrix = Matrix()
scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
var androidPath = path.asAndroidPath()
androidPath.transform(scaleMatrix)
return androidPath.asComposePath()
}
// Draw Cactus
fun DrawScope.CactusView(cactusState: CactusState)
{
drawPath(
path = cactus.path,
color = Color(0xFF000000),
style = Fill
)
}
T-Rex
// T-Rex Dino
private var TREX_DINO_PATH_STR = "M93.027,18.996L173.41,18.996L173.41,60.836L93.027,60.836ZM93.027,18.996 M99.27,14.727L167.168,14.727L167.168,56.57L99.27,56.57ZM99.27,14.727 M93.027,37.715L127.531,37.715L127.531,79.555L93.027,79.555ZM93.027,37.715 M93.027,71.113L153.223,71.113L153.223,79.555L93.027,79.555ZM93.027,71.113 M107.113,25.676L115.113,25.676L115.113,33.676L107.113,33.676ZM107.113,25.676 M93.027,37.715L120.188,37.715L120.188,122.5L93.027,122.5ZM93.027,37.715 M93.027,98.848L136.707,98.848L136.707,107.289L93.027,107.289ZM93.027,98.848 M130.098,99.008L136.707,99.008L136.707,114.422L130.098,114.422ZM130.098,99.008 M86.629,84.328L113.789,84.328L113.789,137.914L86.629,137.914ZM86.629,84.328 M73.828,91.793L105.762,91.793L105.762,145.379L73.828,145.379ZM73.828,91.793 M63.16,99.262L90.324,99.262L90.324,152.848L63.16,152.848ZM63.16,99.262 M64.953,106.727L79.656,106.727L79.656,160.313L64.953,160.313ZM64.953,106.727 M86.629,106.727L96.539,106.727L96.539,160.313L86.629,160.313ZM86.629,106.727 M91.031,106.727L96.539,106.727L96.539,185.273L91.031,185.273ZM91.031,106.727 M91.031,179.766L106.082,179.766L106.082,185.273L91.031,185.273ZM91.031,179.766 M63.301,106.727L68.805,106.727L68.805,170.59L63.301,170.59ZM63.301,106.727 M63.301,165.086L78.348,165.086L78.348,170.59L63.301,170.59ZM63.301,165.086 M54.285,106.727L68.988,106.727L68.988,153.566L54.285,153.566ZM54.285,106.727 M45.695,106.727L58.324,106.727L58.324,146.301L45.695,146.301ZM45.695,106.727 M39.293,99.25L49.789,99.25L49.789,137.766L39.293,137.766ZM39.293,99.25 M31.828,91.781L42.324,91.781L42.324,130.301L31.828,130.301ZM31.828,91.781 M26.59,84.316L32.723,84.316L32.723,122.832L26.59,122.832ZM26.59,84.316 "
private var trexPath = PathParser().parsePathString(TREX_DINO_PATH_STR)
fun DinoPathNodes() = trexPath.toNodes()
fun DinoPath(): Path {
var path = trexPath.toPath()
var scaleMatrix = Matrix()
scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
var androidPath = path.asAndroidPath()
androidPath.transform(scaleMatrix)
return androidPath.asComposePath()
}
// Draw Dino
fun DrawScope.DinoView(dinoState: DinoState) {
{
drawPath(
path = dinoState.path
color = Color(0xFF000000),
style = Fill
)
}
We need to set positions for each element dynamically
@Composable
fun DinoGameScene()
{
Canvas(modifier = Modifier.weight(1f)) {
EarthView(earthState)
CloudsView(cloudsState)
DinoView(dinoState)
CactusView(cactusState)
}
}
I assumed this
Attempt # 1 - Playing with states
// Cloud Model
data class CloudModel(
var xPos: Int = 0,
var yPos: Int = 0,
var path: Path = CloudPath()
)
// Cloud State
data class CloudState(
val cloudsList: ArrayList<CloudModel> = arrayListOf<CloudModel>(),
val maxClouds: Int = 3,
val speed: Int = 1
)
// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
// Game Loop
while(true) {
cloudsState.cloudsList.forEach { cloud ->
cloud.x++
}
}
Attempt # 1 - Playing with states
// Cloud Model
data class CloudModel(
var xPos: Int = 0,
var yPos: Int = 0,
var path: Path = CloudPath()
)
// Cloud State
data class CloudState(
val cloudsList: ArrayList<CloudModel> = arrayListOf<CloudModel>(),
val maxClouds: Int = 3,
val speed: Int = 1
)
// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
// Game Loop
while(true) {
cloudsState.cloudsList.forEach { cloud ->
cloud.x++
}
}
Failed. No Cloud Animation
Attempt # 2 - FrameCallback
// Cloud State
data class CloudState( /* ... */ ) {
fun moveForward()
{
cactusList.forEach { cactus ->
cactus.xPos -= cactusSpeed
}
}
// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
// Game Loop
val gameLoopCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
cloudsState.value = cloudsState.value.moveForward()
Choreographer.getInstance().postFrameCallback(this)
}
}
Choreographer.getInstance().postFrameCallback(gameLoopCallback)
Failed. No Cloud Animation
Attempt # 3 - Compose Couroutines
@Composable
fun animationTimeMillis(gameloopCallback: () -> Unit): State<Long>
{
val millisState = remember { mutableStateOf(0L) }
val lifecycleOwner = LifecycleOwnerAmbient.current
launchInComposition {
val startTime = withFrameMillis { it }
lifecycleOwner.whenStarted {
while(true) {
withFrameMillis { frameTimeMillis: Long ->
millisState.value = frameTimeMillis - startTime
}
gameloopCallback.invoke()
}
}
}
return millisState
}
Attempt # 3 - Compose Couroutines
@Composable
fun DinoGameScene()
{
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
val timeState = animationTimeMillis {
cloudsState.moveForward()
}
var millis = timeState.millis
Canvas(modifier = Modifier.weight(1f)) {
EarthView(earthState)
CloudsView(cloudsState)
DinoView(dinoState)
CactusView(cactusState)
}
}
@Composable
fun DinoGameScene()
{
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
val animatedProgress = animatedFloat(initVal = 0f)
onActive {
animatedProgress.animateTo(
targetValue = 1f,
anim = repeatable(
iterations = AnimationConstants.Infinite,
animation = tween(durationMillis = 1000, easing = LinearEasing)
)
)
}
val millis = animatedProgress.value
if (!gameState.isGameOver)
{
// Game Loop
cloudsState.moveForward()
}
Canvas(modifier = Modifier.weight(1f)) {
CloudsView(cloudsState)
}
}
Attempt # 4 - Compose Way
fun DrawScope.CloudsView(cloudState: CloudState)
{
cloudState.cloudsList.forEach {cloud ->
withTransform({
translate(
left = cloud.xPos.toFloat(),
top = cloud.yPos.toFloat()
)
})
{
drawPath(
path = cloudState.cloudsList.first().path,
color = Color(0xFFC5C5C5),
style = Stroke(2f)
)
}
}
}
- translate
- rotate
- scale
Two blocks chasing each other
Two blocks chasing each other
data class EarthState( /* ... */ ) {
fun moveForward()
{
var endPos = blocksList[maxBlocks-1].xPos
+ blocksList[maxBlocks-1].size
for (i in 0 until maxBlocks)
{
var block = blocksList[i]
block.xPos -= speed
// If first block reached end,
// move it after second
if ((block.xPos + block.size) < -EARTH_OFFSET ) {
block.xPos = endPos
}
}
}
}
Getting spawned one by one
data class CloudState( /* ... */ ) {
fun moveForward()
{
for (i in 0 until maxClouds)
{
var cloud = cloudsList[i]
cloud.xPos -= speed
// If cloud is out of screen,
// Reposition it at start
if (cloud.xPos < -100) {
cloud.xPos = rand(deviceWidthInPixels,
deviceWidthInPixels * rand(1,2))
cloud.yPos = rand(0, 100)
}
}
}
}
Detecting Tap first
Column(modifier = Modifier.fillMaxWidth().clickable(
onClick = {
if (!gameState.isGameOver)
dinoState.jump()
else
{
cactusState.initCactus()
dinoState.init()
gameState.replay()
}
},
indication = null)
) {
Canvas(modifier = Modifier.weight(1f)) {
EarthView(earthState)
CloudsView(cloudsState)
DinoView(dinoState)
CactusView(cactusState)
}
}
Making Dino Jump
data class DinoState( /* ... */ ) {
fun move() {
yPos += velocityY
velocityY += gravity
if (yPos > EARTH_Y_POSITION) {
yPos = EARTH_Y_POSITION
gravity = 0f
velocityY = 0f
isJumping = false
}
}
fun jump() {
// Adding negative force
if (yPos == EARTH_Y_POSITION) {
isJumping = true
velocityY = -40f
gravity = 3f
}
}
}
Keyframes in Jetpack Compose
data class DinoState(
// ....
var keyframe: Int = 0,
private var pathList: ArrayList<Path> = arrayListOf(),
) {
// Current Vector Path of keyframe
val path: Path
get() = if (keyframe == 0) pathList[0] else pathList[1]
fun changeKeyframe()
{
keyframe++
if (keyframe == 1)
keyframe = 0
}
}
Debugging with Bounding Box
fun DrawScope.drawBoundingBox(color: Color, rect: Rect) {
if (showBounds.value)
{
drawRect(color, rect.topLeft, rect.size, style = Stroke(3f))
drawRect(color, rect.deflate(DOUBT_FACTOR).topLeft,
rect.deflate(DOUBT_FACTOR).size,
style = Stroke(width = 3f,
pathEffect = DashPathEffect(floatArrayOf(2f, 4f), 0f))
)
}
}
fun DrawScope.CactusView(cactusState: CactusState) {
// ....
drawBoundingBox(color = Color.Red, rect = cactus.path.getBounds())
}
fun DrawScope.DinoView(dinoState: DinoState) {
// ....
drawBoundingBox(color = Color.Green, rect = dino.path.getBounds())
}
Calculating Overlap
if (!gameState.isGameOver)
{
// Game Loop
gameState.increaseScore()
cloudsState.moveForward()
earthState.moveForward()
cactusState.moveForward()
dinoState.move()
// Collision Check
cactusState.cactusList.forEach {
if (dinoState.getBounds().deflate(DOUBT_FACTOR).overlaps(
it.getBounds().deflate(DOUBT_FACTOR))) {
gameState.isGameOver = true
return@forEach
}
}
}
Updating Scores
Column(modifier = Modifier.fillMaxWidth().clickable(
onClick = { /* ... */ },
indication = null)
) {
HighScoreTextViews(gameState)
Canvas(modifier = Modifier.weight(1f)) {
// Game drawing
}
}
@Composable
fun HighScoreTextViews(gameState: GameState)
{
Spacer(modifier = Modifier.padding(top = 50.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(end = 20.dp),
horizontalArrangement = Arrangement.End
) {
Text(text = "HI")
Spacer(modifier = Modifier.padding(start = 10.dp))
Text(text = "${gameState.highScore}".padStart(5, '0'))
Spacer(modifier = Modifier.padding(start = 10.dp))
Text(text = "${gameState.currentScore}".padStart(5, '0'))
}
}
Finally...
Column(modifier = Modifier.fillMaxWidth().clickable(
onClick = { /* ... */ },
indication = null)
) {
HighScoreTextViews(gameState)
Canvas(modifier = Modifier.weight(1f)) {
// Game drawing
}
GameOverTextView(gameState.isGameOver,
modifier = Modifier.align(Alignment.Center))
}
@Composable
fun GameOverTextView(isGameOver: Boolean = true, modifier: Modifier = Modifier)
{
Column(modifier = modifier) {
Text(
text = if (isGameOver) "GAME OVER" else "",
// ... More attributes
)
if (isGameOver) {
Image(
asset = vectorResource(id = R.drawable.ic_replay),
modifier = Modifier.preferredSize(40.dp)
.align(alignment = Alignment.CenterHorizontally)
)
}
}
}
It was super fun to make a game in Jetpack Compose
Code is available at github.com/wajahatkarim3/DinoCompose
WajahatKarim
wajahatkarim.com/subscribe
wajahatkarim.com