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.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
dailySeednull104 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
randomSeednull111 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
DebugTextnull123 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
ConsoleTextnull137 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
Telemetrynull166 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
onCreatenull244 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
onWindowLayoutInfoChangenull294 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
MainActivityPreviewnull313 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
FlightSticknull324 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
Spaaaacenull397 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