diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/AboutActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/AboutActivity.kt deleted file mode 100644 index 77b8d2b..0000000 --- a/app/src/main/java/partisan/weforge/xyz/pulse/AboutActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package partisan.weforge.xyz.pulse - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class AboutActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // placeholder - setContentView(androidx.appcompat.R.layout.abc_action_bar_title_item) - } -} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/AboutFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/AboutFragment.kt new file mode 100644 index 0000000..0f30615 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/AboutFragment.kt @@ -0,0 +1,65 @@ +package partisan.weforge.xyz.pulse + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewStub +import androidx.fragment.app.Fragment +import partisan.weforge.xyz.pulse.databinding.FragmentAboutBinding + +class AboutFragment : Fragment() { + + private var _binding: FragmentAboutBinding? = null + private val binding get() = _binding!! + + private var tapCount = 0 + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAboutBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.sourceButton.setOnClickListener { + openUrl("https://weforge.xyz/partisan/Pulse") + } + + binding.licenseButton.setOnClickListener { + openUrl("https://www.gnu.org/licenses/gpl-3.0.en.html") + } + + binding.appIcon.setOnClickListener { + tapCount++ + if (tapCount >= 5) { + binding.secretButton.visibility = View.VISIBLE + } + } + + binding.secretButton.setOnClickListener { + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, SecretFragment()) + .addToBackStack(null) + .commit() + } + } + + private fun openUrl(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt index 137fb96..10d060c 100644 --- a/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt +++ b/app/src/main/java/partisan/weforge/xyz/pulse/MainActivity.kt @@ -48,7 +48,10 @@ class MainActivity : AppCompatActivity() { true } R.id.action_about -> { - startActivity(Intent(this, AboutActivity::class.java)) + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, AboutFragment()) + .addToBackStack(null) + .commit() true } R.id.action_contacts -> { diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt new file mode 100644 index 0000000..14ed3db --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretFragment.kt @@ -0,0 +1,18 @@ +package partisan.weforge.xyz.pulse + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment + +class SecretFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return SecretView(requireContext()) + } +} diff --git a/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt new file mode 100644 index 0000000..8450d28 --- /dev/null +++ b/app/src/main/java/partisan/weforge/xyz/pulse/SecretView.kt @@ -0,0 +1,493 @@ +package partisan.weforge.xyz.pulse + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.util.AttributeSet +import android.view.Choreographer +import android.view.MotionEvent +import android.view.View +import com.google.android.material.color.MaterialColors +import kotlin.math.* +import kotlin.random.Random + +class SecretView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs), Choreographer.FrameCallback { + + private val bulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + strokeWidth = 6f + } + private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 40 + style = Paint.Style.FILL + } + private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + } + private val enemyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + } + private val rocketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.RED + style = Paint.Style.STROKE + strokeWidth = 3f + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 64f + typeface = Typeface.DEFAULT_BOLD + } + private val retryRect = RectF() + private val retryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.DKGRAY + } + private val retryTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 48f + } + + private var playerX = 0f + private var viewWidth = 0f + private var viewHeight = 0f + + private var isTouching = false + private var bulletCooldownMs = 0L + private var gameOver = false + private var score = 0 + + private val bullets = mutableListOf() + private val mediumBulletsToFire = mutableListOf() + private val enemies = mutableListOf() + private val rockets = mutableListOf() + private val stars = mutableListOf() + private val explosions = mutableListOf() + private val rocketTrails = mutableListOf>() + + private data class Bullet(var x: Float, var y: Float, val dy: Float = -15f) + private data class Star(var x: Float, var y: Float, val radius: Float, val speed: Float) + private data class Rocket(var x: Float, var y: Float, var angle: Float, val trail: MutableList> = mutableListOf()) + private data class Explosion(var x: Float, var y: Float, var timer: Int = 12) + + private var lastLogicTime = 0L + private val logicStepMs = 16L + + private var waveTimer = 0L + private var currentWave = 0 + private var enemiesLeftInWave = 0 + private var currentWaveType = "" + + init { + for (i in 0 until 50) { + stars.add(Star(Random.nextFloat() * 1080f, Random.nextFloat() * 1920f, Random.nextFloat() * 2f + 1f, Random.nextFloat() * 2f + 0.5f)) + } + + val colorAttrs = intArrayOf(com.google.android.material.R.attr.colorPrimaryVariant) + context.obtainStyledAttributes(colorAttrs).use { + val primary = it.getColor(0, Color.CYAN) + playerPaint.color = primary + enemyPaint.color = primary + } + + Choreographer.getInstance().postFrameCallback(this) + lastLogicTime = System.nanoTime() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + viewWidth = w.toFloat() + viewHeight = h.toFloat() + // Kinda hacky way to center things, but I'd rather do this here than in update(), since it can't be in init() as the screen size isn't initialized at that point. + playerX = viewWidth / 2f + retryRect.set(viewWidth / 2f - 120f, viewHeight / 2f + 60f, viewWidth / 2f + 120f, viewHeight / 2f + 130f) + } + + override fun doFrame(frameTimeNanos: Long) { + if (!gameOver) { + val now = System.nanoTime() + while ((now - lastLogicTime) / 1_000_000 >= logicStepMs) { + update(logicStepMs) + lastLogicTime += logicStepMs * 1_000_000 + } + invalidate() + Choreographer.getInstance().postFrameCallback(this) + } + } + + private fun update(deltaMs: Long) { + stars.forEach { + it.y += it.speed * deltaMs / 16f + if (it.y > viewHeight) { + it.y = 0f + it.x = Random.nextFloat() * viewWidth + } + } + + rockets.forEach { rocket -> + rocket.trail.add(0, rocket.x to rocket.y) + if (rocket.trail.size > 20) { + rocket.trail.removeLast() + } + } + + explosions.forEach { it.timer-- } + explosions.removeIf { it.timer <= 0 } + + + bullets.forEach { it.y += it.dy * deltaMs / 16f } + bullets.removeIf { it.y < 0 } + + bulletCooldownMs -= deltaMs + if (isTouching && bulletCooldownMs <= 0) { + bullets.add(Bullet(playerX, viewHeight - 130f)) + bulletCooldownMs = 150L + } + + enemies.forEach { + it.update(deltaMs) + it.x = max(20f, min(it.x, viewWidth - 20f)) + if (it.y > viewHeight + 100f) it.y = -40f // respawn at top + } + + rockets.forEach { + val targetAngle = atan2(viewHeight - 100f - it.y, playerX - it.x) + it.angle += ((targetAngle - it.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f + it.x += cos(it.angle) * 6f + it.y += sin(it.angle) * 6f + } + rockets.removeIf { it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight } + + bullets.addAll(mediumBulletsToFire) + mediumBulletsToFire.clear() + + checkCollisions() + spawnEnemies(deltaMs) + } + + private fun checkCollisions() { + val enemyIter = enemies.iterator() + while (enemyIter.hasNext()) { + val enemy = enemyIter.next() + if (hypot(playerX - enemy.x, viewHeight - 100f - enemy.y) < 40f) { + explosions.add(Explosion(playerX, viewHeight - 100f)) + gameOver = true + return + } + for (b in bullets) { + if (hypot(b.x - enemy.x, b.y - enemy.y) < 30f) { + explosions.add(Explosion(enemy.x, enemy.y)) + enemyIter.remove() + score += 10 + break + } + } + } + + val rocketIter = rockets.iterator() + while (rocketIter.hasNext()) { + val rocket = rocketIter.next() + if (hypot(playerX - rocket.x, viewHeight - 100f - rocket.y) < 30f) { + explosions.add(Explosion(playerX, viewHeight - 100f)) + gameOver = true + return + } + for (b in bullets) { + if (hypot(b.x - rocket.x, b.y - rocket.y) < 20f) { + explosions.add(Explosion(rocket.x, rocket.y)) + rocketIter.remove() + score += 15 + break + } + } + } + } + + private fun spawnEnemies(deltaMs: Long) { + waveTimer -= deltaMs + + // Passive asteroids + if (Random.nextFloat() < 0.002f) { + enemies.add(EnemyAsteroid(Random.nextFloat() * viewWidth, -40f, viewWidth)) + } + + // Setup new wave + if (enemiesLeftInWave <= 0 && waveTimer <= 0) { + currentWave++ + currentWaveType = when (currentWave % 3) { + 0 -> "easy" + 1 -> "medium" + else -> "hard" + } + + enemiesLeftInWave = when (currentWaveType) { + "easy" -> 3 + currentWave + "medium" -> 2 + currentWave / 2 + "hard" -> 1 + currentWave / 4 + else -> 3 + } + + waveTimer = 3000L + } + + // Spawn enemies in group + if (enemiesLeftInWave > 0 && enemies.count { it !is EnemyAsteroid } < 3) { + val baseX = Random.nextFloat() * (viewWidth - 100f) + 50f + val baseY = -40f + val spacing = 35f + val sharedOffset = Random.nextFloat() * 1000f + val sharedFireTime = Random.nextLong(2000L, 4000L) + + val formationSize = when (currentWaveType) { + "easy" -> 5 + "medium" -> 3 + "hard" -> 1 + else -> 3 + } + + for (i in 0 until min(formationSize, enemiesLeftInWave)) { + val offsetX = (i - (formationSize - 1) / 2f) * spacing + val x = baseX + offsetX + + // The group sync isn't working as enemies gradually get out of sync, but whatever, it's fine. + val enemy = when (currentWaveType) { + "easy" -> EnemyEasy(x, baseY) + "medium" -> EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { mediumBulletsToFire.add(it) } + "hard" -> EnemyHard(x, baseY, { rockets.add(it) }, sharedOffset, sharedFireTime) + else -> EnemyEasy(x, baseY) + } + + enemies.add(enemy) + enemiesLeftInWave-- + } + } + } + + private fun resetGame() { + bullets.clear() + enemies.clear() + rockets.clear() + explosions.clear() + rocketTrails.clear() + score = 0 + currentWave = 0 + enemiesLeftInWave = 0 + gameOver = false + playerX = viewWidth / 2f + } + + override fun onDraw(canvas: Canvas) { + viewWidth = width.toFloat() + viewHeight = height.toFloat() + + canvas.drawColor(Color.parseColor("#121212")) + stars.forEach { canvas.drawCircle(it.x, it.y, it.radius, starPaint) } + + rockets.forEach { rocket -> + rocket.trail.forEachIndexed { index, (x, y) -> + val alpha = ((1f - index / 20f.toFloat()) * 255).toInt() + rocketPaint.alpha = alpha + canvas.drawCircle(x, y, 2f, rocketPaint) + } + } + rocketPaint.alpha = 255 + + explosions.forEach { + val radius = 40f * (1f - it.timer / 12f.toFloat()) + val alpha = (255 * (it.timer / 12f.toFloat())).toInt() + val paint = Paint().apply { + color = Color.YELLOW + this.alpha = alpha + style = Paint.Style.STROKE + strokeWidth = 3f + } + canvas.drawCircle(it.x, it.y, radius, paint) + } + + bullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y - 20f, bulletPaint) } + enemies.forEach { it.draw(canvas, enemyPaint) } + rockets.forEach { canvas.drawCircle(it.x, it.y, 10f, rocketPaint) } + + if (!gameOver) { + val baseY = viewHeight - 100f + val safeX = max(30f, min(playerX, viewWidth - 30f)) + playerX = safeX + val path = Path().apply { + moveTo(safeX, baseY - 30f) + lineTo(safeX - 30f, baseY + 30f) + lineTo(safeX + 30f, baseY + 30f) + close() + } + canvas.drawPath(path, playerPaint) + } else { + canvas.drawText("Game Over", viewWidth / 2f, viewHeight / 2f - 60f, textPaint) + canvas.drawText("Score: $score", viewWidth / 2f, viewHeight / 2f + 10f, textPaint) + canvas.drawRoundRect(retryRect, 20f, 20f, retryPaint) + canvas.drawText("Retry", retryRect.centerX(), retryRect.centerY() + 16f, retryTextPaint) + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (gameOver && event.action == MotionEvent.ACTION_DOWN) { + if (retryRect.contains(event.x, event.y)) { + resetGame() + return true + } + } + + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + playerX = max(30f, min(event.x, width - 30f)) + isTouching = true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouching = false + } + return true + } + + private interface Enemy { + var x: Float + var y: Float + fun update(deltaMs: Long) + fun draw(canvas: Canvas, paint: Paint) + } + + private class EnemyAsteroid( + override var x: Float, + override var y: Float, + private val screenWidth: Float + ) : Enemy { + private var vx = Random.nextFloat() * 2f - 1f + private val vy = Random.nextFloat() * 2f + 2f + private var angle = Random.nextFloat() * 360f + private val rotationSpeed = Random.nextFloat() * 2f * if (Random.nextBoolean()) 1 else -1 + + override fun update(deltaMs: Long) { + x += vx * deltaMs / 16f + y += vy * deltaMs / 16f + angle = (angle + rotationSpeed * deltaMs / 16f) % 360f + + if (x < 20f || x > screenWidth - 20f) { + vx = -vx + x = max(20f, min(x, screenWidth - 20f)) + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + canvas.save() + canvas.rotate(angle, x, y) + val path = Path().apply { + moveTo(x - 20f, y) + lineTo(x - 10f, y - 15f) + lineTo(x + 10f, y - 15f) + lineTo(x + 20f, y) + lineTo(x + 10f, y + 15f) + lineTo(x - 10f, y + 15f) + close() + } + canvas.drawPath(path, paint) + canvas.restore() + } + } + + private class EnemyEasy(override var x: Float, override var y: Float) : Enemy { + private val offset = Random.nextFloat() * 1000f + override fun update(deltaMs: Long) { + y += 2f * deltaMs / 16f + x += sin((y + offset) / 50f) * 2f + } + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 20f) + lineTo(x + 20f, y) + lineTo(x, y + 20f) + lineTo(x - 20f, y) + close() + } + canvas.drawPath(path, paint) + } + } + + private class EnemyMedium( + override var x: Float, + override var y: Float, + private val offset: Float, + private var fireTimer: Long, + val fireBullet: (Bullet) -> Unit + ) : Enemy { + override fun update(deltaMs: Long) { + y += 2.5f * deltaMs / 16f + x += sin((y + offset) / 40f) * 3f + + fireTimer -= deltaMs + if (fireTimer <= 0) { + fireBullet(Bullet(x, y + 30f, dy = 10f)) + fireTimer = Random.nextLong(3000L, 6000L) + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 25f) + lineTo(x + 15f, y) + lineTo(x, y + 25f) + lineTo(x - 15f, y) + close() + } + canvas.drawPath(path, paint) + } + } + + private class EnemyHard( + override var x: Float, + override var y: Float, + val fireRocket: (Rocket) -> Unit, + private val offset: Float = Random.nextFloat() * 1000f, + private var fireTimer: Long = Random.nextLong(2000L, 5000L) + ) : Enemy { + private var cooldown = 0L + private var firing = false + + override fun update(deltaMs: Long) { + if (firing) { + cooldown += deltaMs + if (cooldown > 500 && cooldown < 1300) { + fireRocket(Rocket(x, y - 30f, -PI.toFloat() / 2)) + cooldown = 1300 + } else if (cooldown > 2000) { + cooldown = 0L + fireTimer = Random.nextLong(15000L, 25000L) + firing = false + } + return + } + + y += 3f * deltaMs / 16f + x += sin((y + offset) / 25f) * 4f + fireTimer -= deltaMs + if (fireTimer <= 0) { + firing = true + cooldown = 0L + } + } + + override fun draw(canvas: Canvas, paint: Paint) { + val path = Path().apply { + moveTo(x, y - 25f) + lineTo(x + 10f, y - 10f) + lineTo(x + 20f, y + 10f) + lineTo(x, y + 25f) + lineTo(x - 20f, y + 10f) + lineTo(x - 10f, y - 10f) + close() + } + canvas.drawPath(path, paint) + } + } +} diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml new file mode 100644 index 0000000..3e85ab7 --- /dev/null +++ b/app/src/main/res/layout/fragment_about.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + +