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