/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.egg.landroid import android.content.res.Resources import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.withInfiniteAnimationFrameNanos import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.currentRecomposeScope import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.AbsoluteAlignment.Left import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.math.MathUtils.clamp import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker import java.lang.Float.max import java.lang.Float.min import java.util.Calendar import java.util.GregorianCalendar import kotlin.math.absoluteValue import kotlin.math.floor import kotlin.math.sqrt import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch enum class RandomSeedType { Fixed, Daily, Evergreen, } const val TEST_UNIVERSE = false val RANDOM_SEED_TYPE = RandomSeedType.Daily const val FIXED_RANDOM_SEED = 5038L const val DEFAULT_CAMERA_ZOOM = 1f const val MIN_CAMERA_ZOOM = 250f / UNIVERSE_RANGE // 0.0025f const val MAX_CAMERA_ZOOM = 5f var TOUCH_CAMERA_PAN = false var TOUCH_CAMERA_ZOOM = false var DYNAMIC_ZOOM = false fun dailySeed(): Long { val today = GregorianCalendar() return today.get(Calendar.YEAR) * 10_000L + today.get(Calendar.MONTH) * 100L + today.get(Calendar.DAY_OF_MONTH) } fun randomSeed(): Long { return when (RANDOM_SEED_TYPE) { RandomSeedType.Fixed -> FIXED_RANDOM_SEED RandomSeedType.Daily -> dailySeed() else -> Random.Default.nextLong().mod(10_000_000).toLong() }.absoluteValue } fun getDessertCode(): String = when (Build.VERSION.SDK_INT) { Build.VERSION_CODES.LOLLIPOP -> "LMP" Build.VERSION_CODES.LOLLIPOP_MR1 -> "LM1" Build.VERSION_CODES.M -> "MNC" Build.VERSION_CODES.N -> "NYC" Build.VERSION_CODES.N_MR1 -> "NM1" Build.VERSION_CODES.O -> "OC" Build.VERSION_CODES.P -> "PIE" Build.VERSION_CODES.Q -> "QT" Build.VERSION_CODES.R -> "RVC" Build.VERSION_CODES.S -> "SC" Build.VERSION_CODES.S_V2 -> "SC2" Build.VERSION_CODES.TIRAMISU -> "TM" Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "UDC" Build.VERSION_CODES.VANILLA_ICE_CREAM -> "VIC" else -> Build.VERSION.RELEASE_OR_CODENAME.replace(Regex("[a-z]*"), "") } fun getSystemDesignation(universe: Universe): String { return "${getDessertCode()}-${universe.randomSeed % 100_000}" } val DEBUG_TEXT = mutableStateOf("Hello Universe") const val SHOW_DEBUG_TEXT = false @Composable fun DebugText(text: MutableState) { if (SHOW_DEBUG_TEXT) { Text( modifier = Modifier.fillMaxWidth().border(0.5.dp, color = Color.Yellow).padding(2.dp), fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 9.sp, color = Color.Yellow, text = text.value, ) } } @Composable fun Telemetry(universe: Universe, showControls: Boolean) { var topVisible by remember { mutableStateOf(false) } var bottomVisible by remember { mutableStateOf(false) } var catalogFontSize by remember { mutableStateOf(9.sp) } val textStyle = TextStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 12.sp, letterSpacing = 1.sp, lineHeight = 12.sp, ) LaunchedEffect("blah") { delay(1000) bottomVisible = true topVisible = true } val explored = universe.planets.filter { it.explored } // TODO: Narrow the scope of invalidation here to the specific data needed; // the behavior below mimics the previous implementation of a snapshot ticker value val recomposeScope = currentRecomposeScope Telescope(universe) { recomposeScope.invalidate() } BoxWithConstraints( modifier = Modifier.fillMaxSize().padding(6.dp).windowInsetsPadding(WindowInsets.safeContent) ) { val wide = maxWidth > maxHeight Column( modifier = Modifier.align(if (wide) Alignment.BottomEnd else Alignment.BottomStart) .fillMaxWidth(if (wide) 0.45f else 1.0f) ) { val autopilotEnabled = universe.ship.autopilot?.enabled == true if (autopilotEnabled) { universe.ship.autopilot?.let { autopilot -> AnimatedVisibility( modifier = Modifier, visible = bottomVisible, enter = flickerFadeIn, ) { Text( style = textStyle, color = Colors.Autopilot, modifier = Modifier.align(Left), text = autopilot.telemetry, ) } } } Row(modifier = Modifier.padding(top = 6.dp)) { AnimatedVisibility( modifier = Modifier.weight(1f), visible = bottomVisible, enter = flickerFadeIn, ) { Text( style = textStyle, color = Colors.Console, text = with(universe.ship) { val closest = universe.closestPlanet() val distToClosest = ((closest.pos - pos).mag() - closest.radius).toInt() listOfNotNull( landing?.let { "LND: ${it.planet.name.uppercase()}\n" + "JOB: ${it.text.uppercase()}" } ?: if (distToClosest < 10_000) { "ALT: $distToClosest" } else null, "THR: %.0f%%".format(thrust.mag() * 100f), "POS: %s".format(pos.str("%+7.0f")), "VEL: %.0f".format(velocity.mag()), ) .joinToString("\n") }, ) } if (showControls) { AnimatedVisibility( visible = bottomVisible, enter = flickerFadeInAfterDelay(500), ) { ConsoleButton( textStyle = textStyle, color = Colors.Console, bgColor = if (autopilotEnabled) Colors.Autopilot else Color.Transparent, borderColor = Colors.Console, text = "AUTO", ) { universe.ship.autopilot?.let { it.enabled = !it.enabled DYNAMIC_ZOOM = it.enabled if (!it.enabled) universe.ship.thrust = Vec2.Zero } } } } } } AnimatedVisibility( modifier = Modifier.align(Alignment.TopStart), visible = topVisible, enter = flickerFadeInAfterDelay(1000), ) { Text( style = textStyle, fontSize = catalogFontSize, lineHeight = catalogFontSize, letterSpacing = 1.sp, color = Colors.Console, onTextLayout = { textLayoutResult -> if (textLayoutResult.didOverflowHeight) { catalogFontSize = 8.sp } }, text = (with(universe.star) { listOf( " STAR: $name (${getSystemDesignation(universe)})", " CLASS: ${cls.name}", "RADIUS: ${radius.toInt()}", " MASS: %.3g".format(mass), "BODIES: ${explored.size} / ${universe.planets.size}", "", ) } + explored .map { listOf( " BODY: ${it.name}", " TYPE: ${it.description.capitalize()}", " ATMO: ${it.atmosphere.capitalize()}", " FAUNA: ${it.fauna.capitalize()}", " FLORA: ${it.flora.capitalize()}", "", ) } .flatten()) .joinToString("\n"), // TODO: different colors, highlight latest discovery ) } } } class MainActivity : ComponentActivity() { private var notifier: UniverseProgressNotifier? = null private var foldState = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) onWindowLayoutInfoChange() enableEdgeToEdge() val universe = Universe(namer = Namer(resources), randomSeed = randomSeed()) if (TEST_UNIVERSE) { universe.initTest() } else { universe.initRandom() } com.android.egg.ComponentActivationActivity.lockUnlockComponents(applicationContext) // set up the autopilot in case we need it val autopilot = Autopilot(universe.ship, universe) universe.ship.autopilot = autopilot universe.add(autopilot) autopilot.enabled = false notifier = UniverseProgressNotifier(this, universe) setContent { Spaaaace(modifier = Modifier.fillMaxSize(), u = universe, foldState = foldState) DebugText(DEBUG_TEXT) val minRadius = 50.dp.toLocalPx() val maxRadius = 100.dp.toLocalPx() FlightStick( modifier = Modifier.fillMaxSize(), minRadius = minRadius, maxRadius = maxRadius, color = Color.Green, ) { vec -> (universe.follow as? Spacecraft)?.let { ship -> if (vec == Vec2.Zero) { ship.thrust = Vec2.Zero } else { val a = vec.angle() ship.angle = a val m = vec.mag() if (m < minRadius) { // within this radius, just reorient ship.thrust = Vec2.Zero } else { ship.thrust = Vec2.makeWithAngleMag( a, lexp(minRadius, maxRadius, m).coerceIn(0f, 1f), ) } } } } Telemetry(universe, true) } } private fun onWindowLayoutInfoChange() { val windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity) lifecycleScope.launch(Dispatchers.Main) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { windowInfoTracker.windowLayoutInfo(this@MainActivity).collect { layoutInfo -> foldState.value = layoutInfo.displayFeatures.filterIsInstance().firstOrNull() Log.v("Landroid", "fold updated: $foldState") } } } } } @Preview(name = "phone", device = Devices.PHONE) @Preview(name = "fold", device = Devices.FOLDABLE) @Preview(name = "tablet", device = Devices.TABLET) @Composable fun MainActivityPreview() { val universe = Universe(namer = Namer(Resources.getSystem()), randomSeed = randomSeed()) universe.initTest() Spaaaace(modifier = Modifier.fillMaxSize(), universe) DebugText(DEBUG_TEXT) Telemetry(universe, true) } @Composable fun FlightStick( modifier: Modifier, minRadius: Float = 0f, maxRadius: Float = 1000f, color: Color = Color.Green, onStickChanged: (vector: Vec2) -> Unit, ) { val origin = remember { mutableStateOf(Vec2.Zero) } val target = remember { mutableStateOf(Vec2.Zero) } Box( modifier = modifier .pointerInput(Unit) { forEachGesture { awaitPointerEventScope { // ACTION_DOWN val down = awaitFirstDown(requireUnconsumed = false) origin.value = down.position target.value = down.position do { // ACTION_MOVE val event: PointerEvent = awaitPointerEvent() target.value = event.changes[0].position onStickChanged(target.value - origin.value) } while ( !event.changes.any { it.isConsumed } && event.changes.count { it.pressed } == 1 ) // ACTION_UP / CANCEL target.value = Vec2.Zero origin.value = Vec2.Zero onStickChanged(Vec2.Zero) } } } .drawBehind { if (origin.value != Vec2.Zero) { val delta = target.value - origin.value val mag = min(maxRadius, delta.mag()) val r = max(minRadius, mag) val a = delta.angle() drawCircle( color = color, center = origin.value, radius = r, style = Stroke( width = 2f, pathEffect = if (mag < minRadius) PathEffect.dashPathEffect( floatArrayOf(this.density * 1f, this.density * 2f) ) else null, ), ) drawLine( color = color, start = origin.value, end = origin.value + Vec2.makeWithAngleMag(a, mag), strokeWidth = 2f, ) } } ) } @Composable fun Spaaaace( modifier: Modifier, u: Universe, foldState: MutableState = mutableStateOf(null), ) { LaunchedEffect(u) { while (true) withInfiniteAnimationFrameNanos { frameTimeNanos -> u.step(frameTimeNanos) } } var cameraZoom by remember { mutableFloatStateOf(DEFAULT_CAMERA_ZOOM) } var cameraOffset by remember { mutableStateOf(Offset.Zero) } val transformableState = rememberTransformableState { zoomChange, offsetChange, rotationChange -> if (TOUCH_CAMERA_PAN) cameraOffset += offsetChange / cameraZoom if (TOUCH_CAMERA_ZOOM) cameraZoom = clamp(cameraZoom * zoomChange, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM) } var canvasModifier = modifier if (TOUCH_CAMERA_PAN || TOUCH_CAMERA_ZOOM) { canvasModifier = canvasModifier.transformable(transformableState) } val halfFolded = foldState.value?.let { it.state == FoldingFeature.State.HALF_OPENED } ?: false val horizontalFold = foldState.value?.let { it.orientation == FoldingFeature.Orientation.HORIZONTAL } ?: false val centerFracX: Float by animateFloatAsState(if (halfFolded && !horizontalFold) 0.25f else 0.5f, label = "centerX") val centerFracY: Float by animateFloatAsState(if (halfFolded && horizontalFold) 0.25f else 0.5f, label = "centerY") UniverseCanvas(u, canvasModifier) { u -> drawRect(Colors.Eigengrau, Offset.Zero, size) val closest = u.closestPlanet() val distToNearestSurf = max(0f, (u.ship.pos - closest.pos).mag() - closest.radius * 1.2f) // val normalizedDist = clamp(distToNearestSurf, 50f, 50_000f) / 50_000f val targetZoom = if (DYNAMIC_ZOOM) { clamp(500f / distToNearestSurf, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM) } else { DEFAULT_CAMERA_ZOOM } if (!TOUCH_CAMERA_ZOOM) { cameraZoom = expSmooth(cameraZoom, targetZoom, dt = u.dt, speed = 1.5f) } if (!TOUCH_CAMERA_PAN) cameraOffset = (u.follow?.pos ?: Vec2.Zero) * -1f // cameraZoom: metersToPixels // visibleSpaceSizeMeters: meters // cameraOffset: meters ≈ vector pointing from ship to (0,0) (e.g. -pos) val visibleSpaceSizeMeters = size / cameraZoom // meters x meters val visibleSpaceRectMeters = Rect( -cameraOffset - Offset( visibleSpaceSizeMeters.width * centerFracX, visibleSpaceSizeMeters.height * centerFracY, ), visibleSpaceSizeMeters, ) var gridStep = 1000f while (gridStep * cameraZoom < 32.dp.toPx()) gridStep *= 10 DEBUG_TEXT.value = ("SIMULATION //\n" + // "normalizedDist=${normalizedDist} \n" + "entities: ${u.entities.size} // " + "zoom: ${"%.4f".format(cameraZoom)}x // " + "fps: ${"%3.0f".format(1f / u.dt)} " + "dt: ${u.dt}\n" + ((u.follow as? Spacecraft)?.let { "ship: p=%s v=%7.2f a=%6.3f t=%s\n" .format( it.pos.str("%+7.1f"), it.velocity.mag(), it.angle, it.thrust.str("%+5.2f"), ) } ?: "") + "star: '${u.star.name}' designation=UDC-${u.randomSeed % 100_000} " + "class=${u.star.cls.name} r=${u.star.radius.toInt()} m=${u.star.mass}\n" + "planets: ${u.planets.size}\n" + u.planets.joinToString("\n") { val range = (u.ship.pos - it.pos).mag() val vorbit = sqrt(GRAVITATION * it.mass / range) val vescape = sqrt(2 * GRAVITATION * it.mass / it.radius) " * ${it.name}:\n" + if (it.explored) { " TYPE: ${it.description.capitalize()}\n" + " ATMO: ${it.atmosphere.capitalize()}\n" + " FAUNA: ${it.fauna.capitalize()}\n" + " FLORA: ${it.flora.capitalize()}\n" } else { " (Unexplored)\n" } + " orbit=${(it.pos - it.orbitCenter).mag().toInt()}" + " radius=${it.radius.toInt()}" + " mass=${"%g".format(it.mass)}" + " vel=${(it.speed).toInt()}" + " // range=${"%.0f".format(range)}" + " vorbit=${vorbit.toInt()} vescape=${vescape.toInt()}" }) zoom(cameraZoom) { // All coordinates are space coordinates now. translate( -visibleSpaceRectMeters.center.x + size.width * 0.5f, -visibleSpaceRectMeters.center.y + size.height * 0.5f, ) { // debug outer frame // drawRect( // Colors.Eigengrau2, // visibleSpaceRectMeters.topLeft, // visibleSpaceRectMeters.size, // style = Stroke(width = 10f / cameraZoom) // ) var x = floor(visibleSpaceRectMeters.left / gridStep) * gridStep while (x < visibleSpaceRectMeters.right) { drawLine( color = Colors.Eigengrau2, start = Offset(x, visibleSpaceRectMeters.top), end = Offset(x, visibleSpaceRectMeters.bottom), strokeWidth = (if ((x % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom, ) x += gridStep } var y = floor(visibleSpaceRectMeters.top / gridStep) * gridStep while (y < visibleSpaceRectMeters.bottom) { drawLine( color = Colors.Eigengrau2, start = Offset(visibleSpaceRectMeters.left, y), end = Offset(visibleSpaceRectMeters.right, y), strokeWidth = (if ((y % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom, ) y += gridStep } this@zoom.drawUniverse(u) } } } }