1 /*
<lambda>null2 * 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.Build
21 import android.os.Bundle
22 import android.util.Log
23 import androidx.activity.ComponentActivity
24 import androidx.activity.compose.setContent
25 import androidx.activity.enableEdgeToEdge
26 import androidx.compose.animation.AnimatedVisibility
27 import androidx.compose.animation.core.animateFloatAsState
28 import androidx.compose.animation.core.withInfiniteAnimationFrameNanos
29 import androidx.compose.foundation.Canvas
30 import androidx.compose.foundation.border
31 import androidx.compose.foundation.gestures.awaitFirstDown
32 import androidx.compose.foundation.gestures.forEachGesture
33 import androidx.compose.foundation.gestures.rememberTransformableState
34 import androidx.compose.foundation.gestures.transformable
35 import androidx.compose.foundation.layout.Box
36 import androidx.compose.foundation.layout.BoxWithConstraints
37 import androidx.compose.foundation.layout.Column
38 import androidx.compose.foundation.layout.WindowInsets
39 import androidx.compose.foundation.layout.fillMaxSize
40 import androidx.compose.foundation.layout.fillMaxWidth
41 import androidx.compose.foundation.layout.padding
42 import androidx.compose.foundation.layout.safeContent
43 import androidx.compose.foundation.layout.windowInsetsPadding
44 import androidx.compose.material.Text
45 import androidx.compose.runtime.Composable
46 import androidx.compose.runtime.LaunchedEffect
47 import androidx.compose.runtime.MutableState
48 import androidx.compose.runtime.getValue
49 import androidx.compose.runtime.mutableStateOf
50 import androidx.compose.runtime.remember
51 import androidx.compose.runtime.setValue
52 import androidx.compose.ui.AbsoluteAlignment.Left
53 import androidx.compose.ui.Alignment
54 import androidx.compose.ui.Modifier
55 import androidx.compose.ui.draw.drawBehind
56 import androidx.compose.ui.geometry.Offset
57 import androidx.compose.ui.geometry.Rect
58 import androidx.compose.ui.graphics.Color
59 import androidx.compose.ui.graphics.PathEffect
60 import androidx.compose.ui.graphics.drawscope.Stroke
61 import androidx.compose.ui.graphics.drawscope.translate
62 import androidx.compose.ui.input.pointer.PointerEvent
63 import androidx.compose.ui.input.pointer.pointerInput
64 import androidx.compose.ui.text.TextStyle
65 import androidx.compose.ui.text.font.FontFamily
66 import androidx.compose.ui.text.font.FontWeight
67 import androidx.compose.ui.text.toUpperCase
68 import androidx.compose.ui.tooling.preview.Devices
69 import androidx.compose.ui.tooling.preview.Preview
70 import androidx.compose.ui.unit.dp
71 import androidx.compose.ui.unit.sp
72 import androidx.core.math.MathUtils.clamp
73 import androidx.lifecycle.Lifecycle
74 import androidx.lifecycle.lifecycleScope
75 import androidx.lifecycle.repeatOnLifecycle
76 import androidx.window.layout.FoldingFeature
77 import androidx.window.layout.WindowInfoTracker
78 import java.lang.Float.max
79 import java.lang.Float.min
80 import java.util.Calendar
81 import java.util.GregorianCalendar
82 import kotlin.math.absoluteValue
83 import kotlin.math.floor
84 import kotlin.math.sqrt
85 import kotlin.random.Random
86 import kotlinx.coroutines.Dispatchers
87 import kotlinx.coroutines.delay
88 import kotlinx.coroutines.launch
89
90 enum class RandomSeedType {
91 Fixed,
92 Daily,
93 Evergreen
94 }
95
96 const val TEST_UNIVERSE = false
97
98 val RANDOM_SEED_TYPE = RandomSeedType.Daily
99
100 const val FIXED_RANDOM_SEED = 5038L
101 const val DEFAULT_CAMERA_ZOOM = 1f
102 const val MIN_CAMERA_ZOOM = 250f / UNIVERSE_RANGE // 0.0025f
103 const val MAX_CAMERA_ZOOM = 5f
104 var TOUCH_CAMERA_PAN = false
105 var TOUCH_CAMERA_ZOOM = false
106 var DYNAMIC_ZOOM = false
107
dailySeednull108 fun dailySeed(): Long {
109 val today = GregorianCalendar()
110 return today.get(Calendar.YEAR) * 10_000L +
111 today.get(Calendar.MONTH) * 100L +
112 today.get(Calendar.DAY_OF_MONTH)
113 }
114
randomSeednull115 fun randomSeed(): Long {
116 return when (RANDOM_SEED_TYPE) {
117 RandomSeedType.Fixed -> FIXED_RANDOM_SEED
118 RandomSeedType.Daily -> dailySeed()
119 else -> Random.Default.nextLong().mod(10_000_000).toLong()
120 }.absoluteValue
121 }
122
getDessertCodenull123 fun getDessertCode(): String =
124 when (Build.VERSION.SDK_INT) {
125 Build.VERSION_CODES.LOLLIPOP -> "LMP"
126 Build.VERSION_CODES.LOLLIPOP_MR1 -> "LM1"
127 Build.VERSION_CODES.M -> "MNC"
128 Build.VERSION_CODES.N -> "NYC"
129 Build.VERSION_CODES.N_MR1 -> "NM1"
130 Build.VERSION_CODES.O -> "OC"
131 Build.VERSION_CODES.P -> "PIE"
132 Build.VERSION_CODES.Q -> "QT"
133 Build.VERSION_CODES.R -> "RVC"
134 Build.VERSION_CODES.S -> "SC"
135 Build.VERSION_CODES.S_V2 -> "SC2"
136 Build.VERSION_CODES.TIRAMISU -> "TM"
137 Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "UDC"
138 Build.VERSION_CODES.VANILLA_ICE_CREAM -> "VIC"
139 else -> Build.VERSION.RELEASE_OR_CODENAME.replace(Regex("[a-z]*"), "")
140 }
141
142
143 val DEBUG_TEXT = mutableStateOf("Hello Universe")
144 const val SHOW_DEBUG_TEXT = false
145
146 @Composable
DebugTextnull147 fun DebugText(text: MutableState<String>) {
148 if (SHOW_DEBUG_TEXT) {
149 Text(
150 modifier = Modifier.fillMaxWidth().border(0.5.dp, color = Color.Yellow).padding(2.dp),
151 fontFamily = FontFamily.Monospace,
152 fontWeight = FontWeight.Medium,
153 fontSize = 9.sp,
154 color = Color.Yellow,
155 text = text.value
156 )
157 }
158 }
159
160 @Composable
Telemetrynull161 fun Telemetry(universe: VisibleUniverse) {
162 var topVisible by remember { mutableStateOf(false) }
163 var bottomVisible by remember { mutableStateOf(false) }
164
165 var catalogFontSize by remember { mutableStateOf(9.sp) }
166
167 val textStyle =
168 TextStyle(
169 fontFamily = FontFamily.Monospace,
170 fontWeight = FontWeight.Medium,
171 fontSize = 12.sp,
172 letterSpacing = 1.sp,
173 lineHeight = 12.sp,
174 )
175
176 LaunchedEffect("blah") {
177 delay(1000)
178 bottomVisible = true
179 delay(1000)
180 topVisible = true
181 }
182
183 universe.triggerDraw.value // recompose on every frame
184
185 val explored = universe.planets.filter { it.explored }
186
187 BoxWithConstraints(
188 modifier =
189 Modifier.fillMaxSize().padding(6.dp).windowInsetsPadding(WindowInsets.safeContent),
190 ) {
191 val wide = maxWidth > maxHeight
192 Column(
193 modifier =
194 Modifier.align(if (wide) Alignment.BottomEnd else Alignment.BottomStart)
195 .fillMaxWidth(if (wide) 0.45f else 1.0f)
196 ) {
197 universe.ship.autopilot?.let { autopilot ->
198 if (autopilot.enabled) {
199 AnimatedVisibility(
200 modifier = Modifier,
201 visible = bottomVisible,
202 enter = flickerFadeIn
203 ) {
204 Text(
205 style = textStyle,
206 color = Colors.Autopilot,
207 modifier = Modifier.align(Left),
208 text = autopilot.telemetry
209 )
210 }
211 }
212 }
213
214 AnimatedVisibility(
215 modifier = Modifier,
216 visible = bottomVisible,
217 enter = flickerFadeIn
218 ) {
219 Text(
220 style = textStyle,
221 color = Colors.Console,
222 modifier = Modifier.align(Left),
223 text =
224 with(universe.ship) {
225 val closest = universe.closestPlanet()
226 val distToClosest = ((closest.pos - pos).mag() - closest.radius).toInt()
227 listOfNotNull(
228 landing?.let {
229 "LND: ${it.planet.name.toUpperCase()}\nJOB: ${it.text}"
230 }
231 ?: if (distToClosest < 10_000) {
232 "ALT: $distToClosest"
233 } else null,
234 "THR: %.0f%%".format(thrust.mag() * 100f),
235 "POS: %s".format(pos.str("%+7.0f")),
236 "VEL: %.0f".format(velocity.mag())
237 )
238 .joinToString("\n")
239 }
240 )
241 }
242 }
243
244 AnimatedVisibility(
245 modifier = Modifier.align(Alignment.TopStart),
246 visible = topVisible,
247 enter = flickerFadeIn
248 ) {
249 Text(
250 style = textStyle,
251 fontSize = catalogFontSize,
252 lineHeight = catalogFontSize,
253 letterSpacing = 1.sp,
254 color = Colors.Console,
255 onTextLayout = { textLayoutResult ->
256 if (textLayoutResult.didOverflowHeight) {
257 catalogFontSize = 8.sp
258 }
259 },
260 text =
261 (with(universe.star) {
262 listOf(
263 " STAR: $name (${getDessertCode()}-" +
264 "${universe.randomSeed % 100_000})",
265 " CLASS: ${cls.name}",
266 "RADIUS: ${radius.toInt()}",
267 " MASS: %.3g".format(mass),
268 "BODIES: ${explored.size} / ${universe.planets.size}",
269 ""
270 )
271 } +
272 explored
273 .map {
274 listOf(
275 " BODY: ${it.name}",
276 " TYPE: ${it.description.capitalize()}",
277 " ATMO: ${it.atmosphere.capitalize()}",
278 " FAUNA: ${it.fauna.capitalize()}",
279 " FLORA: ${it.flora.capitalize()}",
280 ""
281 )
282 }
283 .flatten())
284 .joinToString("\n")
285
286 // TODO: different colors, highlight latest discovery
287 )
288 }
289 }
290 }
291
292 class MainActivity : ComponentActivity() {
293 private var foldState = mutableStateOf<FoldingFeature?>(null)
294
onCreatenull295 override fun onCreate(savedInstanceState: Bundle?) {
296 super.onCreate(savedInstanceState)
297
298 onWindowLayoutInfoChange()
299
300 enableEdgeToEdge()
301
302 val universe = VisibleUniverse(namer = Namer(resources), randomSeed = randomSeed())
303
304 if (TEST_UNIVERSE) {
305 universe.initTest()
306 } else {
307 universe.initRandom()
308 }
309
310 com.android.egg.ComponentActivationActivity.lockUnlockComponents(applicationContext)
311
312 // for autopilot testing in the activity
313 // val autopilot = Autopilot(universe.ship, universe)
314 // universe.ship.autopilot = autopilot
315 // universe.add(autopilot)
316 // autopilot.enabled = true
317 // DYNAMIC_ZOOM = autopilot.enabled
318
319 setContent {
320 Spaaaace(modifier = Modifier.fillMaxSize(), u = universe, foldState = foldState)
321 DebugText(DEBUG_TEXT)
322
323 val minRadius = 50.dp.toLocalPx()
324 val maxRadius = 100.dp.toLocalPx()
325 FlightStick(
326 modifier = Modifier.fillMaxSize(),
327 minRadius = minRadius,
328 maxRadius = maxRadius,
329 color = Color.Green
330 ) { vec ->
331 (universe.follow as? Spacecraft)?.let { ship ->
332 if (vec == Vec2.Zero) {
333 ship.thrust = Vec2.Zero
334 } else {
335 val a = vec.angle()
336 ship.angle = a
337
338 val m = vec.mag()
339 if (m < minRadius) {
340 // within this radius, just reorient
341 ship.thrust = Vec2.Zero
342 } else {
343 ship.thrust =
344 Vec2.makeWithAngleMag(
345 a,
346 lexp(minRadius, maxRadius, m).coerceIn(0f, 1f)
347 )
348 }
349 }
350 }
351 }
352 Telemetry(universe)
353 }
354 }
355
onWindowLayoutInfoChangenull356 private fun onWindowLayoutInfoChange() {
357 val windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
358
359 lifecycleScope.launch(Dispatchers.Main) {
360 lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
361 windowInfoTracker.windowLayoutInfo(this@MainActivity).collect { layoutInfo ->
362 foldState.value =
363 layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
364 Log.v("Landroid", "fold updated: $foldState")
365 }
366 }
367 }
368 }
369 }
370
371 @Preview(name = "phone", device = Devices.PHONE)
372 @Preview(name = "fold", device = Devices.FOLDABLE)
373 @Preview(name = "tablet", device = Devices.TABLET)
374 @Composable
MainActivityPreviewnull375 fun MainActivityPreview() {
376 val universe = VisibleUniverse(namer = Namer(Resources.getSystem()), randomSeed = randomSeed())
377
378 universe.initTest()
379
380 Spaaaace(modifier = Modifier.fillMaxSize(), universe)
381 DebugText(DEBUG_TEXT)
382 Telemetry(universe)
383 }
384
385 @Composable
FlightSticknull386 fun FlightStick(
387 modifier: Modifier,
388 minRadius: Float = 0f,
389 maxRadius: Float = 1000f,
390 color: Color = Color.Green,
391 onStickChanged: (vector: Vec2) -> Unit
392 ) {
393 val origin = remember { mutableStateOf(Vec2.Zero) }
394 val target = remember { mutableStateOf(Vec2.Zero) }
395
396 Box(
397 modifier =
398 modifier
399 .pointerInput(Unit) {
400 forEachGesture {
401 awaitPointerEventScope {
402 // ACTION_DOWN
403 val down = awaitFirstDown(requireUnconsumed = false)
404 origin.value = down.position
405 target.value = down.position
406
407 do {
408 // ACTION_MOVE
409 val event: PointerEvent = awaitPointerEvent()
410 target.value = event.changes[0].position
411
412 onStickChanged(target.value - origin.value)
413 } while (
414 !event.changes.any { it.isConsumed } &&
415 event.changes.count { it.pressed } == 1
416 )
417
418 // ACTION_UP / CANCEL
419 target.value = Vec2.Zero
420 origin.value = Vec2.Zero
421
422 onStickChanged(Vec2.Zero)
423 }
424 }
425 }
426 .drawBehind {
427 if (origin.value != Vec2.Zero) {
428 val delta = target.value - origin.value
429 val mag = min(maxRadius, delta.mag())
430 val r = max(minRadius, mag)
431 val a = delta.angle()
432 drawCircle(
433 color = color,
434 center = origin.value,
435 radius = r,
436 style =
437 Stroke(
438 width = 2f,
439 pathEffect =
440 if (mag < minRadius)
441 PathEffect.dashPathEffect(
442 floatArrayOf(this.density * 1f, this.density * 2f)
443 )
444 else null
445 )
446 )
447 drawLine(
448 color = color,
449 start = origin.value,
450 end = origin.value + Vec2.makeWithAngleMag(a, mag),
451 strokeWidth = 2f
452 )
453 }
454 }
455 )
456 }
457
458 @Composable
Spaaaacenull459 fun Spaaaace(
460 modifier: Modifier,
461 u: VisibleUniverse,
462 foldState: MutableState<FoldingFeature?> = mutableStateOf(null)
463 ) {
464 LaunchedEffect(u) {
465 while (true) withInfiniteAnimationFrameNanos { frameTimeNanos ->
466 u.simulateAndDrawFrame(frameTimeNanos)
467 }
468 }
469
470 var cameraZoom by remember { mutableStateOf(1f) }
471 var cameraOffset by remember { mutableStateOf(Offset.Zero) }
472
473 val transformableState =
474 rememberTransformableState { zoomChange, offsetChange, rotationChange ->
475 if (TOUCH_CAMERA_PAN) cameraOffset += offsetChange / cameraZoom
476 if (TOUCH_CAMERA_ZOOM)
477 cameraZoom = clamp(cameraZoom * zoomChange, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM)
478 }
479
480 var canvasModifier = modifier
481
482 if (TOUCH_CAMERA_PAN || TOUCH_CAMERA_ZOOM) {
483 canvasModifier = canvasModifier.transformable(transformableState)
484 }
485
486 val halfFolded = foldState.value?.let { it.state == FoldingFeature.State.HALF_OPENED } ?: false
487 val horizontalFold =
488 foldState.value?.let { it.orientation == FoldingFeature.Orientation.HORIZONTAL } ?: false
489
490 val centerFracX: Float by
491 animateFloatAsState(if (halfFolded && !horizontalFold) 0.25f else 0.5f, label = "centerX")
492 val centerFracY: Float by
493 animateFloatAsState(if (halfFolded && horizontalFold) 0.25f else 0.5f, label = "centerY")
494
495 Canvas(modifier = canvasModifier) {
496 drawRect(Colors.Eigengrau, Offset.Zero, size)
497
498 val closest = u.closestPlanet()
499 val distToNearestSurf = max(0f, (u.ship.pos - closest.pos).mag() - closest.radius * 1.2f)
500 // val normalizedDist = clamp(distToNearestSurf, 50f, 50_000f) / 50_000f
501 if (DYNAMIC_ZOOM) {
502 cameraZoom =
503 expSmooth(
504 cameraZoom,
505 clamp(500f / distToNearestSurf, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM),
506 dt = u.dt,
507 speed = 1.5f
508 )
509 } else if (!TOUCH_CAMERA_ZOOM) cameraZoom = DEFAULT_CAMERA_ZOOM
510 if (!TOUCH_CAMERA_PAN) cameraOffset = (u.follow?.pos ?: Vec2.Zero) * -1f
511
512 // cameraZoom: metersToPixels
513 // visibleSpaceSizeMeters: meters
514 // cameraOffset: meters ≈ vector pointing from ship to (0,0) (e.g. -pos)
515 val visibleSpaceSizeMeters = size / cameraZoom // meters x meters
516 val visibleSpaceRectMeters =
517 Rect(
518 -cameraOffset -
519 Offset(
520 visibleSpaceSizeMeters.width * centerFracX,
521 visibleSpaceSizeMeters.height * centerFracY
522 ),
523 visibleSpaceSizeMeters
524 )
525
526 var gridStep = 1000f
527 while (gridStep * cameraZoom < 32.dp.toPx()) gridStep *= 10
528
529 DEBUG_TEXT.value =
530 ("SIMULATION //\n" +
531 // "normalizedDist=${normalizedDist} \n" +
532 "entities: ${u.entities.size} // " +
533 "zoom: ${"%.4f".format(cameraZoom)}x // " +
534 "fps: ${"%3.0f".format(1f / u.dt)} " +
535 "dt: ${u.dt}\n" +
536 ((u.follow as? Spacecraft)?.let {
537 "ship: p=%s v=%7.2f a=%6.3f t=%s\n".format(
538 it.pos.str("%+7.1f"),
539 it.velocity.mag(),
540 it.angle,
541 it.thrust.str("%+5.2f")
542 )
543 }
544 ?: "") +
545 "star: '${u.star.name}' designation=UDC-${u.randomSeed % 100_000} " +
546 "class=${u.star.cls.name} r=${u.star.radius.toInt()} m=${u.star.mass}\n" +
547 "planets: ${u.planets.size}\n" +
548 u.planets.joinToString("\n") {
549 val range = (u.ship.pos - it.pos).mag()
550 val vorbit = sqrt(GRAVITATION * it.mass / range)
551 val vescape = sqrt(2 * GRAVITATION * it.mass / it.radius)
552 " * ${it.name}:\n" +
553 if (it.explored) {
554 " TYPE: ${it.description.capitalize()}\n" +
555 " ATMO: ${it.atmosphere.capitalize()}\n" +
556 " FAUNA: ${it.fauna.capitalize()}\n" +
557 " FLORA: ${it.flora.capitalize()}\n"
558 } else {
559 " (Unexplored)\n"
560 } +
561 " orbit=${(it.pos - it.orbitCenter).mag().toInt()}" +
562 " radius=${it.radius.toInt()}" +
563 " mass=${"%g".format(it.mass)}" +
564 " vel=${(it.speed).toInt()}" +
565 " // range=${"%.0f".format(range)}" +
566 " vorbit=${vorbit.toInt()} vescape=${vescape.toInt()}"
567 })
568
569 zoom(cameraZoom) {
570 // All coordinates are space coordinates now.
571
572 translate(
573 -visibleSpaceRectMeters.center.x + size.width * 0.5f,
574 -visibleSpaceRectMeters.center.y + size.height * 0.5f
575 ) {
576 // debug outer frame
577 // drawRect(
578 // Colors.Eigengrau2,
579 // visibleSpaceRectMeters.topLeft,
580 // visibleSpaceRectMeters.size,
581 // style = Stroke(width = 10f / cameraZoom)
582 // )
583
584 var x = floor(visibleSpaceRectMeters.left / gridStep) * gridStep
585 while (x < visibleSpaceRectMeters.right) {
586 drawLine(
587 color = Colors.Eigengrau2,
588 start = Offset(x, visibleSpaceRectMeters.top),
589 end = Offset(x, visibleSpaceRectMeters.bottom),
590 strokeWidth = (if ((x % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom
591 )
592 x += gridStep
593 }
594
595 var y = floor(visibleSpaceRectMeters.top / gridStep) * gridStep
596 while (y < visibleSpaceRectMeters.bottom) {
597 drawLine(
598 color = Colors.Eigengrau2,
599 start = Offset(visibleSpaceRectMeters.left, y),
600 end = Offset(visibleSpaceRectMeters.right, y),
601 strokeWidth = (if ((y % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom
602 )
603 y += gridStep
604 }
605
606 this@zoom.drawUniverse(u)
607 }
608 }
609 }
610 }
611