1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.egg.landroid 18 19 import android.content.res.Resources 20 import android.os.Bundle 21 import android.util.Log 22 import androidx.activity.ComponentActivity 23 import androidx.activity.compose.setContent 24 import androidx.compose.animation.AnimatedVisibility 25 import androidx.compose.animation.core.CubicBezierEasing 26 import androidx.compose.animation.core.animateFloatAsState 27 import androidx.compose.animation.core.tween 28 import androidx.compose.animation.core.withInfiniteAnimationFrameNanos 29 import androidx.compose.animation.fadeIn 30 import androidx.compose.foundation.Canvas 31 import androidx.compose.foundation.border 32 import androidx.compose.foundation.gestures.awaitFirstDown 33 import androidx.compose.foundation.gestures.forEachGesture 34 import androidx.compose.foundation.gestures.rememberTransformableState 35 import androidx.compose.foundation.gestures.transformable 36 import androidx.compose.foundation.layout.Box 37 import androidx.compose.foundation.layout.Column 38 import androidx.compose.foundation.layout.ColumnScope 39 import androidx.compose.foundation.layout.Spacer 40 import androidx.compose.foundation.layout.fillMaxSize 41 import androidx.compose.foundation.layout.fillMaxWidth 42 import androidx.compose.foundation.layout.padding 43 import androidx.compose.material.Text 44 import androidx.compose.runtime.Composable 45 import androidx.compose.runtime.LaunchedEffect 46 import androidx.compose.runtime.MutableState 47 import androidx.compose.runtime.getValue 48 import androidx.compose.runtime.mutableStateOf 49 import androidx.compose.runtime.remember 50 import androidx.compose.runtime.setValue 51 import androidx.compose.ui.AbsoluteAlignment.Left 52 import androidx.compose.ui.Modifier 53 import androidx.compose.ui.draw.drawBehind 54 import androidx.compose.ui.geometry.Offset 55 import androidx.compose.ui.geometry.Rect 56 import androidx.compose.ui.graphics.Color 57 import androidx.compose.ui.graphics.PathEffect 58 import androidx.compose.ui.graphics.drawscope.Stroke 59 import androidx.compose.ui.graphics.drawscope.translate 60 import androidx.compose.ui.input.pointer.PointerEvent 61 import androidx.compose.ui.input.pointer.pointerInput 62 import androidx.compose.ui.text.font.FontFamily 63 import androidx.compose.ui.text.font.FontWeight 64 import androidx.compose.ui.tooling.preview.Devices 65 import androidx.compose.ui.tooling.preview.Preview 66 import androidx.compose.ui.unit.dp 67 import androidx.compose.ui.unit.sp 68 import androidx.core.math.MathUtils.clamp 69 import androidx.lifecycle.Lifecycle 70 import androidx.lifecycle.lifecycleScope 71 import androidx.lifecycle.repeatOnLifecycle 72 import androidx.window.layout.FoldingFeature 73 import androidx.window.layout.WindowInfoTracker 74 import java.lang.Float.max 75 import java.lang.Float.min 76 import java.util.Calendar 77 import java.util.GregorianCalendar 78 import kotlin.math.absoluteValue 79 import kotlin.math.floor 80 import kotlin.math.sqrt 81 import kotlin.random.Random 82 import kotlinx.coroutines.Dispatchers 83 import kotlinx.coroutines.delay 84 import kotlinx.coroutines.launch 85 86 enum class RandomSeedType { 87 Fixed, 88 Daily, 89 Evergreen 90 } 91 92 const val TEST_UNIVERSE = false 93 94 val RANDOM_SEED_TYPE = RandomSeedType.Daily 95 96 const val FIXED_RANDOM_SEED = 5038L 97 const val DEFAULT_CAMERA_ZOOM = 0.25f 98 const val MIN_CAMERA_ZOOM = 250f / UNIVERSE_RANGE // 0.0025f 99 const val MAX_CAMERA_ZOOM = 5f 100 const val TOUCH_CAMERA_PAN = false 101 const val TOUCH_CAMERA_ZOOM = true 102 const val DYNAMIC_ZOOM = false // @@@ FIXME 103 104 fun dailySeed(): Long { 105 val today = GregorianCalendar() 106 return today.get(Calendar.YEAR) * 10_000L + 107 today.get(Calendar.MONTH) * 100L + 108 today.get(Calendar.DAY_OF_MONTH) 109 } 110 111 fun randomSeed(): Long { 112 return when (RANDOM_SEED_TYPE) { 113 RandomSeedType.Fixed -> FIXED_RANDOM_SEED 114 RandomSeedType.Daily -> dailySeed() 115 else -> Random.Default.nextLong().mod(10_000_000).toLong() 116 }.absoluteValue 117 } 118 119 val DEBUG_TEXT = mutableStateOf("Hello Universe") 120 const val SHOW_DEBUG_TEXT = false 121 122 @Composable 123 fun DebugText(text: MutableState<String>) { 124 if (SHOW_DEBUG_TEXT) { 125 Text( 126 modifier = Modifier.fillMaxWidth().border(0.5.dp, color = Color.Yellow).padding(2.dp), 127 fontFamily = FontFamily.Monospace, 128 fontWeight = FontWeight.Medium, 129 fontSize = 9.sp, 130 color = Color.Yellow, 131 text = text.value 132 ) 133 } 134 } 135 136 @Composable 137 fun ColumnScope.ConsoleText( 138 modifier: Modifier = Modifier, 139 visible: Boolean = true, 140 random: Random = Random.Default, 141 text: String 142 ) { 143 AnimatedVisibility( 144 modifier = modifier, 145 visible = visible, 146 enter = 147 fadeIn( 148 animationSpec = 149 tween( 150 durationMillis = 1000, 151 easing = flickerFadeEasing(random) * CubicBezierEasing(0f, 1f, 1f, 0f) 152 ) 153 ) 154 ) { 155 Text( 156 fontFamily = FontFamily.Monospace, 157 fontWeight = FontWeight.Medium, 158 fontSize = 12.sp, 159 color = Color(0xFFFF8000), 160 text = text 161 ) 162 } 163 } 164 165 @Composable 166 fun Telemetry(universe: VisibleUniverse) { 167 var topVisible by remember { mutableStateOf(false) } 168 var bottomVisible by remember { mutableStateOf(false) } 169 170 LaunchedEffect("blah") { 171 delay(1000) 172 bottomVisible = true 173 delay(1000) 174 topVisible = true 175 } 176 177 Column(modifier = Modifier.fillMaxSize().padding(6.dp)) { 178 universe.triggerDraw.value // recompose on every frame 179 val explored = universe.planets.filter { it.explored } 180 181 AnimatedVisibility(modifier = Modifier, visible = topVisible, enter = flickerFadeIn) { 182 Text( 183 fontFamily = FontFamily.Monospace, 184 fontWeight = FontWeight.Medium, 185 fontSize = 12.sp, 186 color = Colors.Console, 187 modifier = Modifier.align(Left), 188 text = 189 with(universe.star) { 190 " STAR: $name (UDC-${universe.randomSeed % 100_000})\n" + 191 " CLASS: ${cls.name}\n" + 192 "RADIUS: ${radius.toInt()}\n" + 193 " MASS: %.3g\n".format(mass) + 194 "BODIES: ${explored.size} / ${universe.planets.size}\n" + 195 "\n" 196 } + 197 explored 198 .map { 199 " BODY: ${it.name}\n" + 200 " TYPE: ${it.description.capitalize()}\n" + 201 " ATMO: ${it.atmosphere.capitalize()}\n" + 202 " FAUNA: ${it.fauna.capitalize()}\n" + 203 " FLORA: ${it.flora.capitalize()}\n" 204 } 205 .joinToString("\n") 206 207 // TODO: different colors, highlight latest discovery 208 ) 209 } 210 211 Spacer(modifier = Modifier.weight(1f)) 212 213 AnimatedVisibility(modifier = Modifier, visible = bottomVisible, enter = flickerFadeIn) { 214 Text( 215 fontFamily = FontFamily.Monospace, 216 fontWeight = FontWeight.Medium, 217 fontSize = 12.sp, 218 color = Colors.Console, 219 modifier = Modifier.align(Left), 220 text = 221 with(universe.ship) { 222 val closest = universe.closestPlanet() 223 val distToClosest = (closest.pos - pos).mag().toInt() 224 listOfNotNull( 225 landing?.let { "LND: ${it.planet.name}" } 226 ?: if (distToClosest < 10_000) { 227 "ALT: $distToClosest" 228 } else null, 229 if (thrust != Vec2.Zero) "THR: %.0f%%".format(thrust.mag() * 100f) 230 else null, 231 "POS: %s".format(pos.str("%+7.0f")), 232 "VEL: %.0f".format(velocity.mag()) 233 ) 234 .joinToString("\n") 235 } 236 ) 237 } 238 } 239 } 240 241 class MainActivity : ComponentActivity() { 242 private var foldState = mutableStateOf<FoldingFeature?>(null) 243 244 override fun onCreate(savedInstanceState: Bundle?) { 245 super.onCreate(savedInstanceState) 246 247 onWindowLayoutInfoChange() 248 249 val universe = VisibleUniverse(namer = Namer(resources), randomSeed = randomSeed()) 250 251 if (TEST_UNIVERSE) { 252 universe.initTest() 253 } else { 254 universe.initRandom() 255 } 256 257 setContent { 258 Spaaaace(modifier = Modifier.fillMaxSize(), u = universe, foldState = foldState) 259 DebugText(DEBUG_TEXT) 260 261 val minRadius = 50.dp.toLocalPx() 262 val maxRadius = 100.dp.toLocalPx() 263 FlightStick( 264 modifier = Modifier.fillMaxSize(), 265 minRadius = minRadius, 266 maxRadius = maxRadius, 267 color = Color.Green 268 ) { vec -> 269 (universe.follow as? Spacecraft)?.let { ship -> 270 if (vec == Vec2.Zero) { 271 ship.thrust = Vec2.Zero 272 } else { 273 val a = vec.angle() 274 ship.angle = a 275 276 val m = vec.mag() 277 if (m < minRadius) { 278 // within this radius, just reorient 279 ship.thrust = Vec2.Zero 280 } else { 281 ship.thrust = 282 Vec2.makeWithAngleMag( 283 a, 284 lexp(minRadius, maxRadius, m).coerceIn(0f, 1f) 285 ) 286 } 287 } 288 } 289 } 290 Telemetry(universe) 291 } 292 } 293 294 private fun onWindowLayoutInfoChange() { 295 val windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity) 296 297 lifecycleScope.launch(Dispatchers.Main) { 298 lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { 299 windowInfoTracker.windowLayoutInfo(this@MainActivity).collect { layoutInfo -> 300 foldState.value = 301 layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull() 302 Log.v("Landroid", "fold updated: $foldState") 303 } 304 } 305 } 306 } 307 } 308 309 @Preview(name = "phone", device = Devices.PHONE) 310 @Preview(name = "fold", device = Devices.FOLDABLE) 311 @Preview(name = "tablet", device = Devices.TABLET) 312 @Composable 313 fun MainActivityPreview() { 314 val universe = VisibleUniverse(namer = Namer(Resources.getSystem()), randomSeed = randomSeed()) 315 316 universe.initTest() 317 318 Spaaaace(modifier = Modifier.fillMaxSize(), universe) 319 DebugText(DEBUG_TEXT) 320 Telemetry(universe) 321 } 322 323 @Composable 324 fun FlightStick( 325 modifier: Modifier, 326 minRadius: Float = 0f, 327 maxRadius: Float = 1000f, 328 color: Color = Color.Green, 329 onStickChanged: (vector: Vec2) -> Unit 330 ) { 331 val origin = remember { mutableStateOf(Vec2.Zero) } 332 val target = remember { mutableStateOf(Vec2.Zero) } 333 334 Box( 335 modifier = 336 modifier 337 .pointerInput(Unit) { 338 forEachGesture { 339 awaitPointerEventScope { 340 // ACTION_DOWN 341 val down = awaitFirstDown(requireUnconsumed = false) 342 origin.value = down.position 343 target.value = down.position 344 345 do { 346 // ACTION_MOVE 347 val event: PointerEvent = awaitPointerEvent() 348 target.value = event.changes[0].position 349 350 onStickChanged(target.value - origin.value) 351 } while ( 352 !event.changes.any { it.isConsumed } && 353 event.changes.count { it.pressed } == 1 354 ) 355 356 // ACTION_UP / CANCEL 357 target.value = Vec2.Zero 358 origin.value = Vec2.Zero 359 360 onStickChanged(Vec2.Zero) 361 } 362 } 363 } 364 .drawBehind { 365 if (origin.value != Vec2.Zero) { 366 val delta = target.value - origin.value 367 val mag = min(maxRadius, delta.mag()) 368 val r = max(minRadius, mag) 369 val a = delta.angle() 370 drawCircle( 371 color = color, 372 center = origin.value, 373 radius = r, 374 style = 375 Stroke( 376 width = 2f, 377 pathEffect = 378 if (mag < minRadius) 379 PathEffect.dashPathEffect( 380 floatArrayOf(this.density * 1f, this.density * 2f) 381 ) 382 else null 383 ) 384 ) 385 drawLine( 386 color = color, 387 start = origin.value, 388 end = origin.value + Vec2.makeWithAngleMag(a, mag), 389 strokeWidth = 2f 390 ) 391 } 392 } 393 ) 394 } 395 396 @Composable 397 fun Spaaaace( 398 modifier: Modifier, 399 u: VisibleUniverse, 400 foldState: MutableState<FoldingFeature?> = mutableStateOf(null) 401 ) { 402 LaunchedEffect(u) { 403 while (true) withInfiniteAnimationFrameNanos { frameTimeNanos -> 404 u.simulateAndDrawFrame(frameTimeNanos) 405 } 406 } 407 408 var cameraZoom by remember { mutableStateOf(1f) } 409 var cameraOffset by remember { mutableStateOf(Offset.Zero) } 410 411 val transformableState = 412 rememberTransformableState { zoomChange, offsetChange, rotationChange -> 413 if (TOUCH_CAMERA_PAN) cameraOffset += offsetChange / cameraZoom 414 if (TOUCH_CAMERA_ZOOM) 415 cameraZoom = clamp(cameraZoom * zoomChange, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM) 416 } 417 418 var canvasModifier = modifier 419 420 if (TOUCH_CAMERA_PAN || TOUCH_CAMERA_ZOOM) { 421 canvasModifier = canvasModifier.transformable(transformableState) 422 } 423 424 val halfFolded = foldState.value?.let { it.state == FoldingFeature.State.HALF_OPENED } ?: false 425 val horizontalFold = 426 foldState.value?.let { it.orientation == FoldingFeature.Orientation.HORIZONTAL } ?: false 427 428 val centerFracX: Float by 429 animateFloatAsState(if (halfFolded && !horizontalFold) 0.25f else 0.5f, label = "centerX") 430 val centerFracY: Float by 431 animateFloatAsState(if (halfFolded && horizontalFold) 0.25f else 0.5f, label = "centerY") 432 433 Canvas(modifier = canvasModifier) { 434 drawRect(Colors.Eigengrau, Offset.Zero, size) 435 436 val closest = u.closestPlanet() 437 val distToNearestSurf = max(0f, (u.ship.pos - closest.pos).mag() - closest.radius * 1.2f) 438 // val normalizedDist = clamp(distToNearestSurf, 50f, 50_000f) / 50_000f 439 if (DYNAMIC_ZOOM) { 440 // cameraZoom = lerp(0.1f, 5f, smooth(1f-normalizedDist)) 441 cameraZoom = clamp(500f / distToNearestSurf, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM) 442 } else if (!TOUCH_CAMERA_ZOOM) cameraZoom = DEFAULT_CAMERA_ZOOM 443 if (!TOUCH_CAMERA_PAN) cameraOffset = (u.follow?.pos ?: Vec2.Zero) * -1f 444 445 // cameraZoom: metersToPixels 446 // visibleSpaceSizeMeters: meters 447 // cameraOffset: meters ≈ vector pointing from ship to (0,0) (e.g. -pos) 448 val visibleSpaceSizeMeters = size / cameraZoom // meters x meters 449 val visibleSpaceRectMeters = 450 Rect( 451 -cameraOffset - 452 Offset( 453 visibleSpaceSizeMeters.width * centerFracX, 454 visibleSpaceSizeMeters.height * centerFracY 455 ), 456 visibleSpaceSizeMeters 457 ) 458 459 var gridStep = 1000f 460 while (gridStep * cameraZoom < 32.dp.toPx()) gridStep *= 10 461 462 DEBUG_TEXT.value = 463 ("SIMULATION //\n" + 464 // "normalizedDist=${normalizedDist} \n" + 465 "entities: ${u.entities.size} // " + 466 "zoom: ${"%.4f".format(cameraZoom)}x // " + 467 "fps: ${"%3.0f".format(1f / u.dt)} " + 468 "dt: ${u.dt}\n" + 469 ((u.follow as? Spacecraft)?.let { 470 "ship: p=%s v=%7.2f a=%6.3f t=%s\n".format( 471 it.pos.str("%+7.1f"), 472 it.velocity.mag(), 473 it.angle, 474 it.thrust.str("%+5.2f") 475 ) 476 } 477 ?: "") + 478 "star: '${u.star.name}' designation=UDC-${u.randomSeed % 100_000} " + 479 "class=${u.star.cls.name} r=${u.star.radius.toInt()} m=${u.star.mass}\n" + 480 "planets: ${u.planets.size}\n" + 481 u.planets.joinToString("\n") { 482 val range = (u.ship.pos - it.pos).mag() 483 val vorbit = sqrt(GRAVITATION * it.mass / range) 484 val vescape = sqrt(2 * GRAVITATION * it.mass / it.radius) 485 " * ${it.name}:\n" + 486 if (it.explored) { 487 " TYPE: ${it.description.capitalize()}\n" + 488 " ATMO: ${it.atmosphere.capitalize()}\n" + 489 " FAUNA: ${it.fauna.capitalize()}\n" + 490 " FLORA: ${it.flora.capitalize()}\n" 491 } else { 492 " (Unexplored)\n" 493 } + 494 " orbit=${(it.pos - it.orbitCenter).mag().toInt()}" + 495 " radius=${it.radius.toInt()}" + 496 " mass=${"%g".format(it.mass)}" + 497 " vel=${(it.speed).toInt()}" + 498 " // range=${"%.0f".format(range)}" + 499 " vorbit=${vorbit.toInt()} vescape=${vescape.toInt()}" 500 }) 501 502 zoom(cameraZoom) { 503 // All coordinates are space coordinates now. 504 505 translate( 506 -visibleSpaceRectMeters.center.x + size.width * 0.5f, 507 -visibleSpaceRectMeters.center.y + size.height * 0.5f 508 ) { 509 // debug outer frame 510 // drawRect( 511 // Colors.Eigengrau2, 512 // visibleSpaceRectMeters.topLeft, 513 // visibleSpaceRectMeters.size, 514 // style = Stroke(width = 10f / cameraZoom) 515 // ) 516 517 var x = floor(visibleSpaceRectMeters.left / gridStep) * gridStep 518 while (x < visibleSpaceRectMeters.right) { 519 drawLine( 520 color = Colors.Eigengrau2, 521 start = Offset(x, visibleSpaceRectMeters.top), 522 end = Offset(x, visibleSpaceRectMeters.bottom), 523 strokeWidth = (if ((x % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom 524 ) 525 x += gridStep 526 } 527 528 var y = floor(visibleSpaceRectMeters.top / gridStep) * gridStep 529 while (y < visibleSpaceRectMeters.bottom) { 530 drawLine( 531 color = Colors.Eigengrau2, 532 start = Offset(visibleSpaceRectMeters.left, y), 533 end = Offset(visibleSpaceRectMeters.right, y), 534 strokeWidth = (if ((y % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom 535 ) 536 y += gridStep 537 } 538 539 this@zoom.drawUniverse(u) 540 } 541 } 542 } 543 } 544