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