Skip to content

Instantly share code, notes, and snippets.

@ataulm
Last active October 4, 2023 07:34
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save ataulm/7a6b4b63ed45a5cfda013427c2212536 to your computer and use it in GitHub Desktop.
Save ataulm/7a6b4b63ed45a5cfda013427c2212536 to your computer and use it in GitHub Desktop.
Code for "Pew pew! Making a Game with Compose Canvas on Wear OS 👾"
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
package com.example.genericspaceship
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.rotary.onRotaryScrollEvent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@ExperimentalComposeUiApi
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SpaceCanvas()
}
}
}
@ExperimentalComposeUiApi
@Composable
fun SpaceCanvas(
modifier: Modifier = Modifier,
viewModel: GameEngineViewModel = viewModel()
) {
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
val isRound = LocalConfiguration.current.isScreenRound
val gameController = remember {
object : GameController {
override fun onPressDownEvent() {
viewModel.onPressEventDown()
}
override fun onPressUpEvent() {
viewModel.onPressEventUp()
}
override fun onDoubleTapEvent() {
viewModel.onDoubleTap()
}
override fun onRotateEvent(rotationPixels: Float) {
viewModel.onRotate(rotationPixels)
}
}
}
SpaceCanvas(
shouldDrawJet = uiState.value.spaceship.thrustersEngaged && uiState.value.ticks % 2 == 0L,
spaceship = uiState.value.spaceship,
shotsFired = uiState.value.shotsFired,
gameController = gameController,
modifier = modifier.onSizeChanged {
viewModel.onCanvasSizeChange(it, isRound)
}
)
}
@ExperimentalComposeUiApi
@Composable
fun SpaceCanvas(
shouldDrawJet: Boolean,
spaceship: UiState.Spaceship,
shotsFired: List<UiState.Shot>,
gameController: GameController,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
Canvas(
modifier = modifier
.fillMaxSize()
// pass `gameController` as key so that if it changes, the
// PointerInputScope is remade with ref to the new one
.pointerInput(key1 = gameController) {
detectTapGestures(
onPress = {
gameController.onPressDownEvent()
tryAwaitRelease()
gameController.onPressUpEvent()
},
onDoubleTap = {
gameController.onDoubleTapEvent()
}
)
}
.focusRequester(focusRequester)
.onRotaryScrollEvent {
gameController.onRotateEvent(it.verticalScrollPixels)
true
}
.focusable()
) {
shotsFired.forEach { draw(it) }
draw(shouldDrawJet, spaceship)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
/**
* It's drawn facing right, which is 0 degrees.
*/
private fun DrawScope.draw(shot: UiState.Shot) {
drawCircle(
color = Color.White,
center = Offset(shot.positionX, shot.positionY),
radius = 2f
)
}
/**
* It's drawn facing right, which is 0 degrees.
*/
private fun DrawScope.draw(
shouldDrawJet: Boolean,
spaceship: UiState.Spaceship
) {
rotate(
degrees = spaceship.rotationDegrees,
pivot = Offset(spaceship.positionX, spaceship.positionY)
) {
if (shouldDrawJet) {
val jetPath = Path().apply {
moveTo(
spaceship.positionX - 0.4f * spaceship.length,
spaceship.positionY
)
lineTo(
spaceship.positionX,
spaceship.positionY + 0.3f * spaceship.width
)
lineTo(
spaceship.positionX,
spaceship.positionY - 0.3f * spaceship.width
)
close()
}
drawPath(jetPath, color = Color.Yellow, style = Stroke(width = 2f))
}
val spaceshipPath = Path().apply {
moveTo(spaceship.positionX + 0.7f * spaceship.length, spaceship.positionY)
lineTo(
spaceship.positionX - 0.3f * spaceship.length,
spaceship.positionY + 0.5f * spaceship.width
)
lineTo(
spaceship.positionX - 0.1f * spaceship.length,
spaceship.positionY
)
lineTo(
spaceship.positionX - 0.3f * spaceship.length,
spaceship.positionY - 0.5f * spaceship.width
)
close()
}
drawPath(spaceshipPath, color = Color.Black)
drawPath(spaceshipPath, color = Color.White, style = Stroke(width = 2f))
}
}
interface GameController {
fun onPressDownEvent()
fun onPressUpEvent()
fun onDoubleTapEvent()
fun onRotateEvent(rotationPixels: Float)
}
/* ----------------------------------------------------------------- */
data class UiState(
val ticks: Long = 0L,
val spaceship: Spaceship = Spaceship(),
val shotsFired: List<Shot> = emptyList()
) {
data class Spaceship(
val width: Float = 0f,
val length: Float = 0f,
val positionX: Float = 0f,
val positionY: Float = 0f,
val rotationDegrees: Float = 0f,
val thrustersEngaged: Boolean = false
)
data class Shot(
val positionX: Float,
val positionY: Float
)
}
/* ----------------------------------------------------------------- */
class GameEngineViewModel(
private val state: GameState = GameState(),
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
while (true) {
if (state.size != IntSize.Zero) {
update()
}
delay(UPDATE_INTERVAL)
}
}
}
fun onCanvasSizeChange(newSize: IntSize, isRound: Boolean) {
state.isRound = isRound
if (state.size != newSize) {
state.size = newSize
state.spaceship.positionX = newSize.width * 0.5f
state.spaceship.positionY = newSize.height * 0.5f
}
}
fun onPressEventDown() {
state.spaceship.thrustersEngaged = true
}
fun onPressEventUp() {
state.spaceship.thrustersEngaged = false
}
fun onDoubleTap() {
val spaceship = state.spaceship
spaceship.thrustersEngaged = false
state.shotsFired.add(
GameState.Shot(
positionX = spaceship.positionX,
positionY = spaceship.positionY,
bearingRads = spaceship.rotationRads,
endPositionX = spaceship.positionX +
SpaceshipConstants.SHOT_RANGE_MULTIPLIER * SpaceshipConstants.SHOT_SPEED * cos(
spaceship.rotationRads
).toFloat(),
endPositionY = spaceship.positionY +
SpaceshipConstants.SHOT_RANGE_MULTIPLIER * SpaceshipConstants.SHOT_SPEED * sin(
spaceship.rotationRads
).toFloat(),
)
)
}
fun onRotate(rotationPixels: Float) {
state.spaceship.rotationDegrees += rotationPixels
}
private fun update() {
state.ticks++
val spaceship = state.spaceship
if (spaceship.thrustersEngaged) {
spaceship.applyThrust()
} else {
spaceship.applyFriction()
}
spaceship.positionX += spaceship.thrustX
spaceship.positionY += spaceship.thrustY
spaceship.stayOnScreen()
state.shotsFired.forEach { shot ->
shot.positionX += SpaceshipConstants.SHOT_SPEED * cos(shot.bearingRads).toFloat()
shot.positionY += SpaceshipConstants.SHOT_SPEED * sin(shot.bearingRads).toFloat()
}
state.shotsFired.removeAll { shot -> shot.hasExhaustedRange() }
emitLatestState()
}
private fun GameState.Shot.hasExhaustedRange(): Boolean {
return positionX >= endPositionX && positionY >= endPositionY
}
/**
* Update [GameState.Spaceship.thrustX] and [GameState.Spaceship.thrustY] accounting for
* for the rotation so that the ship moves towards the direction it's pointing.
*/
private fun GameState.Spaceship.applyThrust() {
thrustX += (SpaceshipConstants.THRUST_RATE * cos(rotationRads)).toFloat()
thrustY += (SpaceshipConstants.THRUST_RATE * sin(rotationRads)).toFloat()
thrustX = thrustX.coerceIn(
minimumValue = SpaceshipConstants.MAX_THRUST * -1,
maximumValue = SpaceshipConstants.MAX_THRUST
)
thrustY = thrustY.coerceIn(
minimumValue = SpaceshipConstants.MAX_THRUST * -1,
maximumValue = SpaceshipConstants.MAX_THRUST
)
}
/**
* Update [GameState.Spaceship.thrustX] and [GameState.Spaceship.thrustY] so that it
* trends towards 0 (no movement).
*/
private fun GameState.Spaceship.applyFriction() {
thrustX = when {
abs(thrustX - 0) < SpaceshipConstants.FRICTION -> 0F
thrustX > 0 -> thrustX - SpaceshipConstants.FRICTION
else -> thrustX + SpaceshipConstants.FRICTION
}
thrustY = when {
abs(thrustY - 0) < SpaceshipConstants.FRICTION -> 0F
thrustY > 0 -> thrustY - SpaceshipConstants.FRICTION
else -> thrustY + SpaceshipConstants.FRICTION
}
}
/**
* Teleport the ship if it goes offscreen
*/
private fun GameState.Spaceship.stayOnScreen() {
if (state.isRound) {
stayOnScreenRound()
} else {
stayOnScreenRectangle()
}
}
private fun GameState.Spaceship.stayOnScreenRectangle() {
val halfLength = length * 0.5f
if (positionX + halfLength < 0) {
positionX = state.size.width.toFloat()
} else if (positionX - halfLength > state.size.width) {
positionX = 0f
}
if (positionY + halfLength < 0) {
positionY = state.size.height.toFloat()
} else if (positionY - halfLength > state.size.height) {
positionY = 0f
}
}
/**
* For round devices, it looks really weird if we assume a square canvas because it takes longer
* for the ship to reappear if it flies through the corners of the square which are further away
* from the center of the screen than the sides.
*/
private fun GameState.Spaceship.stayOnScreenRound() {
val radius = state.size.width * 0.5
val centerX = radius
val centerY = radius
// pythagoras' theorem to see if ship is outside of the circle
val a = abs(positionX - centerX)
val b = abs(positionY - centerY)
val distFromCenter = sqrt(a.pow(2) + b.pow(2))
val halfLength = length * 0.5f
if (distFromCenter > radius + halfLength + 1) {
positionX = when {
positionX < centerX -> centerX + a
positionX > centerX -> centerX - a
else -> positionX
}.toFloat()
positionY = when {
positionY < centerY -> centerY + b
positionY > centerY -> centerY - b
else -> positionY
}.toFloat()
}
}
private fun emitLatestState() {
_uiState.update {
UiState(
ticks = state.ticks,
spaceship = UiState.Spaceship(
width = state.spaceship.width,
length = state.spaceship.length,
positionX = state.spaceship.positionX,
positionY = state.spaceship.positionY,
rotationDegrees = state.spaceship.rotationDegrees,
thrustersEngaged = state.spaceship.thrustersEngaged
),
shotsFired = state.shotsFired.map {
UiState.Shot(
positionX = it.positionX,
positionY = it.positionY
)
}
)
}
}
companion object {
private const val FPS = 30
private const val UPDATE_INTERVAL = 1000L / FPS
}
}
object SpaceshipConstants {
const val MAX_THRUST = 12f
const val THRUST_RATE = 0.25f
const val FRICTION = 0.05f
const val SHOT_SPEED = MAX_THRUST + 3f
const val SHOT_RANGE_MULTIPLIER = 30f
}
data class GameState(
var ticks: Long = 0L,
var size: IntSize = IntSize.Zero,
var isRound: Boolean = true,
val shotsFired: MutableList<Shot> = mutableListOf(),
val spaceship: Spaceship = Spaceship()
) {
data class Shot(
var positionX: Float,
var positionY: Float,
val bearingRads: Double,
val endPositionX: Float,
val endPositionY: Float
)
data class Spaceship(
/**
* Bounding box length.
* TODO: should this be based on [GameState.size]?
*/
var length: Float = 50f,
/**
* Rotation around the pivot point.
*/
var rotationDegrees: Float = 0f,
/**
* Pivot point's x-offset from origin.
*/
var positionX: Float = 0f,
/**
* Pivot point's y-offset from origin.
*/
var positionY: Float = 0f,
var thrustX: Float = 0f,
var thrustY: Float = 0f,
var thrustersEngaged: Boolean = false,
) {
/**
* Bounding box width.
*/
val width
get() = length * 0.7f
val rotationRads
get() = rotationDegrees / 180 * Math.PI
}
}
/* ----------------------------------------------------------------- */
/*
// build.gradle
dependencies {
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.compose.ui:ui:1.4.0-alpha03'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
implementation 'androidx.wear.compose:compose-foundation:1.1.0'
}
*/
/* ----------------------------------------------------------------- */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment