Add title screen with falling tetrominos and fix game resume functionality

This commit is contained in:
cmclark00 2025-03-26 19:13:43 -04:00
parent a56f08afb9
commit 9fbffc00d0
6 changed files with 401 additions and 76 deletions

View file

@ -15,6 +15,7 @@ import com.mintris.databinding.ActivityMainBinding
import com.mintris.game.GameHaptics
import com.mintris.game.GameView
import com.mintris.game.NextPieceView
import com.mintris.game.TitleScreen
import android.view.HapticFeedbackConstants
import com.mintris.model.GameBoard
import com.mintris.audio.GameMusic
@ -27,6 +28,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var gameHaptics: GameHaptics
private lateinit var gameBoard: GameBoard
private lateinit var gameMusic: GameMusic
private lateinit var titleScreen: TitleScreen
// Game state
private var isSoundEnabled = true
@ -43,12 +45,36 @@ class MainActivity : AppCompatActivity() {
gameBoard = GameBoard()
gameHaptics = GameHaptics(this)
gameView = binding.gameView
titleScreen = binding.titleScreen
gameMusic = GameMusic(this)
// Set up game view
gameView.setGameBoard(gameBoard)
gameView.setHaptics(gameHaptics)
// Set up title screen
titleScreen.onStartGame = {
titleScreen.visibility = View.GONE
gameView.visibility = View.VISIBLE
binding.gameControlsContainer.visibility = View.VISIBLE
startGame()
}
// Initially hide the game view and show title screen
gameView.visibility = View.GONE
binding.gameControlsContainer.visibility = View.GONE
titleScreen.visibility = View.VISIBLE
// Set up pause button to show settings menu
binding.pauseButton.setOnClickListener {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
gameView.pause()
gameMusic.pause()
showPauseMenu()
binding.pauseStartButton.visibility = View.GONE
binding.resumeButton.visibility = View.VISIBLE
}
// Set up next piece preview
binding.nextPieceView.setGameView(gameView)
gameBoard.onNextPieceChanged = {
@ -62,9 +88,6 @@ class MainActivity : AppCompatActivity() {
updateMusicToggleUI()
}
// Start game immediately
startGame()
// Set up callbacks
gameView.onGameStateChanged = { score, level, lines ->
updateUI(score, level, lines)
@ -99,7 +122,7 @@ class MainActivity : AppCompatActivity() {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
hideGameOver()
gameView.reset()
gameView.start()
startGame()
}
binding.resumeButton.setOnClickListener {
@ -118,14 +141,14 @@ class MainActivity : AppCompatActivity() {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
hidePauseMenu()
gameView.reset()
gameView.start()
startGame()
}
binding.pauseRestartButton.setOnClickListener {
gameHaptics.performHapticFeedback(it, HapticFeedbackConstants.VIRTUAL_KEY)
hidePauseMenu()
gameView.reset()
gameView.start()
startGame()
}
binding.pauseLevelUpButton.setOnClickListener {
@ -250,50 +273,27 @@ class MainActivity : AppCompatActivity() {
showPauseMenu()
}
private fun pauseGame() {
gameView.pause()
gameMusic.pause()
}
private fun resumeGame() {
gameView.resume()
if (isMusicEnabled) {
gameMusic.start()
gameMusic.resume()
}
// Force a redraw to ensure pieces aren't frozen
gameView.invalidate()
}
override fun onPause() {
super.onPause()
if (!gameView.isGameOver()) {
pauseGame()
showPauseMenu()
binding.pauseStartButton.visibility = View.GONE
binding.resumeButton.visibility = View.VISIBLE
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.gameOverContainer.visibility == View.VISIBLE) {
hideGameOver()
gameView.reset()
return
}
if (binding.pauseContainer.visibility == View.GONE) {
if (gameView.visibility == View.VISIBLE) {
gameView.pause()
showPauseMenu()
binding.pauseStartButton.visibility = View.GONE
binding.resumeButton.visibility = View.VISIBLE
} else {
hidePauseMenu()
resumeGame()
gameMusic.pause()
}
}
override fun onResume() {
super.onResume()
if (!gameView.isGameOver()) {
// If we're on the title screen, don't auto-resume the game
if (titleScreen.visibility == View.GONE && gameView.visibility == View.VISIBLE && binding.gameOverContainer.visibility == View.GONE && binding.pauseContainer.visibility == View.GONE) {
resumeGame()
}
}
@ -302,4 +302,16 @@ class MainActivity : AppCompatActivity() {
super.onDestroy()
gameMusic.release()
}
/**
* Show title screen (for game restart)
*/
private fun showTitleScreen() {
gameView.reset()
gameView.visibility = View.GONE
binding.gameControlsContainer.visibility = View.GONE
binding.gameOverContainer.visibility = View.GONE
binding.pauseContainer.visibility = View.GONE
titleScreen.visibility = View.VISIBLE
}
}

View file

@ -59,6 +59,17 @@ class GameMusic(private val context: Context) {
}
}
fun resume() {
try {
Log.d("GameMusic", "Resuming music playback")
if (isEnabled && mediaPlayer?.isPlaying != true) {
mediaPlayer?.start()
}
} catch (e: Exception) {
Log.e("GameMusic", "Error resuming music", e)
}
}
fun stop() {
try {
Log.d("GameMusic", "Stopping music playback")

View file

@ -664,9 +664,15 @@ class GameView @JvmOverloads constructor(
fun resume() {
if (!isRunning) {
isRunning = true
handler.post(gameLoopRunnable)
}
isPaused = false
// Restart the game loop immediately
handler.removeCallbacks(gameLoopRunnable)
handler.post(gameLoopRunnable)
// Force an update to ensure pieces move immediately
update()
invalidate()
}
}

View file

@ -0,0 +1,259 @@
package com.mintris.game
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import java.util.Random
import android.util.Log
class TitleScreen @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint()
private val glowPaint = Paint()
private val titlePaint = Paint()
private val promptPaint = Paint()
private val cellSize = 30f
private val random = Random()
private var width = 0
private var height = 0
private val tetrominosToAdd = mutableListOf<Tetromino>()
// Callback for when the user touches the screen
var onStartGame: (() -> Unit)? = null
// Define tetromino shapes (I, O, T, S, Z, J, L)
private val tetrominoShapes = arrayOf(
// I
arrayOf(
intArrayOf(0, 0, 0, 0),
intArrayOf(1, 1, 1, 1),
intArrayOf(0, 0, 0, 0),
intArrayOf(0, 0, 0, 0)
),
// O
arrayOf(
intArrayOf(1, 1),
intArrayOf(1, 1)
),
// T
arrayOf(
intArrayOf(0, 1, 0),
intArrayOf(1, 1, 1),
intArrayOf(0, 0, 0)
),
// S
arrayOf(
intArrayOf(0, 1, 1),
intArrayOf(1, 1, 0),
intArrayOf(0, 0, 0)
),
// Z
arrayOf(
intArrayOf(1, 1, 0),
intArrayOf(0, 1, 1),
intArrayOf(0, 0, 0)
),
// J
arrayOf(
intArrayOf(1, 0, 0),
intArrayOf(1, 1, 1),
intArrayOf(0, 0, 0)
),
// L
arrayOf(
intArrayOf(0, 0, 1),
intArrayOf(1, 1, 1),
intArrayOf(0, 0, 0)
)
)
// Tetromino class to represent falling pieces
private class Tetromino(
var x: Float,
var y: Float,
val shape: Array<IntArray>,
val speed: Float,
val scale: Float,
val rotation: Int = 0
)
private val tetrominos = mutableListOf<Tetromino>()
init {
// Title text settings
titlePaint.apply {
color = Color.WHITE
textSize = 120f
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
isAntiAlias = true
}
// "Touch to start" text settings
promptPaint.apply {
color = Color.WHITE
textSize = 40f
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
isAntiAlias = true
alpha = 180
}
// General paint settings for tetrominos (white)
paint.apply {
color = Color.WHITE
style = Paint.Style.FILL
isAntiAlias = true
}
// Glow paint settings for tetrominos
glowPaint.apply {
color = Color.WHITE
style = Paint.Style.FILL
isAntiAlias = true
alpha = 60
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
width = w
height = h
// Clear existing tetrominos
tetrominos.clear()
// Initialize some tetrominos
repeat(20) {
val tetromino = createRandomTetromino()
tetrominos.add(tetromino)
}
}
private fun createRandomTetromino(): Tetromino {
val x = random.nextFloat() * (width - 150) + 50 // Keep away from edges
val y = -cellSize * 4 - (random.nextFloat() * height / 2)
val shapeIndex = random.nextInt(tetrominoShapes.size)
val shape = tetrominoShapes[shapeIndex]
val speed = 1f + random.nextFloat() * 2f
val scale = 0.8f + random.nextFloat() * 0.4f
val rotation = random.nextInt(4) * 90
return Tetromino(x, y, shape, speed, scale, rotation)
}
override fun onDraw(canvas: Canvas) {
try {
super.onDraw(canvas)
// Draw background
canvas.drawColor(Color.BLACK)
// Add any pending tetrominos
tetrominos.addAll(tetrominosToAdd)
tetrominosToAdd.clear()
// Update and draw falling tetrominos
val tetrominosToRemove = mutableListOf<Tetromino>()
for (tetromino in tetrominos) {
tetromino.y += tetromino.speed
// Remove tetrominos that have fallen off the screen
if (tetromino.y > height) {
tetrominosToRemove.add(tetromino)
tetrominosToAdd.add(createRandomTetromino())
} else {
try {
// Save canvas state before rotation
canvas.save()
// Translate to the tetromino's position
canvas.translate(tetromino.x, tetromino.y)
// Scale according to the tetromino's scale factor
canvas.scale(tetromino.scale, tetromino.scale)
// Rotate around the center of the tetromino
val centerX = tetromino.shape.size * cellSize / 2
val centerY = tetromino.shape.size * cellSize / 2
canvas.rotate(tetromino.rotation.toFloat(), centerX, centerY)
// Draw the tetromino
for (row in tetromino.shape.indices) {
for (col in 0 until tetromino.shape[row].size) {
if (tetromino.shape[row][col] == 1) {
// Draw larger glow effect
glowPaint.alpha = 30
canvas.drawRect(
col * cellSize - 8,
row * cellSize - 8,
(col + 1) * cellSize + 8,
(row + 1) * cellSize + 8,
glowPaint
)
// Draw medium glow
glowPaint.alpha = 60
canvas.drawRect(
col * cellSize - 4,
row * cellSize - 4,
(col + 1) * cellSize + 4,
(row + 1) * cellSize + 4,
glowPaint
)
// Draw main block
canvas.drawRect(
col * cellSize,
row * cellSize,
(col + 1) * cellSize,
(row + 1) * cellSize,
paint
)
}
}
}
// Restore canvas state
canvas.restore()
} catch (e: Exception) {
Log.e("TitleScreen", "Error drawing tetromino", e)
}
}
}
// Remove tetrominos that fell off the screen
tetrominos.removeAll(tetrominosToRemove)
// Draw title
val titleY = height * 0.4f
canvas.drawText("mintris", width / 2f, titleY, titlePaint)
// Draw "touch to start" prompt
canvas.drawText("touch to start", width / 2f, height * 0.6f, promptPaint)
// Request another frame
invalidate()
} catch (e: Exception) {
Log.e("TitleScreen", "Error in onDraw", e)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_DOWN) {
onStartGame?.invoke()
return true
}
return super.onTouchEvent(event)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -31,54 +31,81 @@
android:layout_height="match_parent"
android:background="@drawable/glow_border" />
</FrameLayout>
<!-- Title Screen -->
<com.mintris.game.TitleScreen
android:id="@+id/titleScreen"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- HUD Container - Score, Level, Lines -->
<LinearLayout
android:id="@+id/hudContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/scoreText"
android:id="@+id/gameControlsContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/hudContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Score: 0" />
android:layout_margin="16dp"
android:layout_gravity="end"
android:orientation="vertical">
<TextView
android:id="@+id/currentLevelText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Level: 1" />
<TextView
android:id="@+id/scoreText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Score: 0" />
<TextView
android:id="@+id/linesText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Lines: 0" />
<TextView
android:id="@+id/currentLevelText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Level: 1" />
<TextView
android:id="@+id/linesText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="24sp"
android:fontFamily="sans-serif-light"
tools:text="Lines: 0" />
</LinearLayout>
<!-- Next Piece Preview -->
<com.mintris.game.NextPieceView
android:id="@+id/nextPieceView"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_margin="16dp"
android:layout_gravity="end" />
<!-- Settings button -->
<ImageButton
android:id="@+id/pauseButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="start"
android:layout_margin="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:padding="12dp"
android:src="@drawable/ic_pause" />
</LinearLayout>
<!-- Next Piece Preview -->
<com.mintris.game.NextPieceView
android:id="@+id/nextPieceView"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/hudContainer" />
<!-- Game Over overlay -->
<LinearLayout
android:id="@+id/gameOverContainer"