mintris/app/src/main/java/com/pixelmintdrop/game/GameView.kt

1512 lines
56 KiB
Kotlin
Raw Normal View History

package com.mintris.game
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Shader
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.animation.LinearInterpolator
import android.hardware.display.DisplayManager
import android.view.Display
import com.mintris.model.GameBoard
import com.mintris.model.Tetromino
import com.mintris.model.TetrominoType
import kotlin.math.abs
import kotlin.math.min
/**
* GameView that renders the Tetris game and handles touch input
*/
class GameView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
companion object {
private const val TAG = "GameView"
}
// Game board model
private var gameBoard = GameBoard()
private var gameHaptics: GameHaptics? = null
// Game state
private var isRunning = false
var isPaused = false // Changed from private to public to allow access from MainActivity
private var score = 0
// Callbacks
var onNextPieceChanged: (() -> Unit)? = null
// Rendering
private val blockPaint = Paint().apply {
color = Color.WHITE
isAntiAlias = true
}
private val borderGlowPaint = Paint().apply {
color = Color.WHITE
alpha = 60
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 2f
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER)
}
private val ghostBlockPaint = Paint().apply {
color = Color.WHITE
alpha = 80 // 30% opacity
isAntiAlias = true
}
private val gridPaint = Paint().apply {
color = Color.parseColor("#222222") // Very dark gray
alpha = 20 // Reduced from 40 to be more subtle
isAntiAlias = true
strokeWidth = 1f
style = Paint.Style.STROKE
maskFilter = null // Ensure no blur effect on grid lines
}
private val glowPaint = Paint().apply {
color = Color.WHITE
alpha = 40 // Reduced from 80 for more subtlety
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.5f
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER)
}
private val blockGlowPaint = Paint().apply {
color = Color.WHITE
alpha = 60
isAntiAlias = true
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER)
}
// Add a new paint for the pulse effect
private val pulsePaint = Paint().apply {
color = Color.CYAN
alpha = 255
isAntiAlias = true
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(32f, BlurMaskFilter.Blur.OUTER) // Increased from 16f to 32f
}
// Pre-allocate paint objects to avoid GC
private val tmpPaint = Paint()
// Calculate block size based on view dimensions and board size
private var blockSize = 0f
private var boardLeft = 0f
private var boardTop = 0f
// Game loop handler and runnable
private val handler = Handler(Looper.getMainLooper())
private val gameLoopRunnable = object : Runnable {
override fun run() {
if (isRunning && !isPaused) {
update()
invalidate()
handler.postDelayed(this, gameBoard.dropInterval)
}
}
}
// Touch parameters
private var lastTouchX = 0f
private var lastTouchY = 0f
private var startX = 0f
private var startY = 0f
private var lastTapTime = 0L
private var lastRotationTime = 0L
private var lastMoveTime = 0L
private var lastHardDropTime = 0L // Track when the last hard drop occurred
private val hardDropCooldown = 250L // Reduced from 500ms to 250ms
private var touchFreezeUntil = 0L // Time until which touch events should be ignored
private val pieceLockFreezeTime = 300L // Time to freeze touch events after piece locks
private var minSwipeVelocity = 1200 // Increased from 800 to require more deliberate swipes
private val maxTapMovement = 30f // Increased from 20f to 30f for more lenient tap detection
private val minTapTime = 100L // Minimum time for a tap (in milliseconds)
private val rotationCooldown = 150L // Minimum time between rotations (in milliseconds)
private val moveCooldown = 50L // Minimum time between move haptics (in milliseconds)
private val doubleTapTimeout = 400L // Increased from 300ms to 400ms for more lenient double tap detection
private var lastTapX = 0f // X coordinate of last tap
private var lastTapY = 0f // Y coordinate of last tap
private var lastHoldTime = 0L // Track when the last hold occurred
private val holdCooldown = 250L // Minimum time between holds
private var lockedDirection: Direction? = null // Track the locked movement direction
private val minMovementThreshold = 0.3f // Reduced from 0.5f for more sensitive horizontal movement
private val directionLockThreshold = 1.0f // Reduced from 1.5f to make direction locking less aggressive
private val isStrictDirectionLock = true // Re-enabled strict direction locking to prevent diagonal inputs
private val minHardDropDistance = 2.5f // Increased from 1.5f to require more deliberate hard drops
private val minHoldDistance = 2.0f // Minimum distance (in blocks) for hold gesture
private val maxSoftDropDistance = 1.5f // Maximum distance for soft drop before considering hard drop
// Block skin
private var currentBlockSkin: String = "block_skin_1"
private val blockSkinPaints = mutableMapOf<String, Paint>()
2025-03-29 01:59:14 -04:00
private var currentThemeColor = Color.WHITE
private enum class Direction {
HORIZONTAL, VERTICAL
}
// Callback for game events
var onGameStateChanged: ((score: Int, level: Int, lines: Int) -> Unit)? = null
var onGameOver: ((score: Int) -> Unit)? = null
var onLineClear: ((Int) -> Unit)? = null // New callback for line clear events
var onPieceMove: (() -> Unit)? = null // New callback for piece movement
var onPieceLock: (() -> Unit)? = null // New callback for piece locking
// Animation state
private var pulseAnimator: ValueAnimator? = null
private var pulseAlpha = 0f
private var isPulsing = false
private var linesToPulse = mutableListOf<Int>() // Track which lines are being cleared
private var gameOverAnimator: ValueAnimator? = null
private var gameOverAlpha = 0f
private var isGameOverAnimating = false
// Add new game over animation properties
private var gameOverBlocksY = mutableListOf<Float>()
private var gameOverBlocksX = mutableListOf<Float>()
private var gameOverBlocksRotation = mutableListOf<Float>()
private var gameOverBlocksSpeed = mutableListOf<Float>()
private var gameOverBlocksSize = mutableListOf<Float>()
private var gameOverColorTransition = 0f
private var gameOverShakeAmount = 0f
private var gameOverFinalAlpha = 0.8f
private val ghostPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 2f
color = Color.WHITE
alpha = 180 // Increased from 100 for better visibility
}
private val ghostBackgroundPaint = Paint().apply {
style = Paint.Style.FILL
color = Color.WHITE
alpha = 30 // Very light background for better contrast
}
private val ghostBorderPaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 1f
color = Color.WHITE
alpha = 100 // Subtle border for better definition
}
init {
// Start with paused state
pause()
// Load saved block skin
val prefs = context.getSharedPreferences("mintris_progression", Context.MODE_PRIVATE)
currentBlockSkin = prefs.getString("selected_block_skin", "block_skin_1") ?: "block_skin_1"
// Connect our callbacks to the GameBoard
gameBoard.onPieceMove = { onPieceMove?.invoke() }
gameBoard.onPieceLock = {
// Freeze touch events for a brief period after a piece locks
touchFreezeUntil = System.currentTimeMillis() + pieceLockFreezeTime
Log.d(TAG, "Piece locked - freezing touch events until ${touchFreezeUntil}")
onPieceLock?.invoke()
}
gameBoard.onLineClear = { lineCount, clearedLines ->
Log.d(TAG, "Received line clear from GameBoard: $lineCount lines")
try {
onLineClear?.invoke(lineCount)
// Use the lines that were cleared directly
linesToPulse.clear()
linesToPulse.addAll(clearedLines)
Log.d(TAG, "Found ${linesToPulse.size} lines to pulse")
startPulseAnimation(lineCount)
Log.d(TAG, "Forwarded line clear callback")
} catch (e: Exception) {
Log.e(TAG, "Error forwarding line clear callback", e)
}
}
// Force hardware acceleration - This is critical for performance
setLayerType(LAYER_TYPE_HARDWARE, null)
// Set better frame rate using modern APIs
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
displayManager.getDisplay(Display.DEFAULT_DISPLAY)
} else {
displayManager.displays.firstOrNull()
}
display?.let { disp ->
val refreshRate = disp.refreshRate
// Set game loop interval based on refresh rate, but don't go faster than the base interval
val targetFps = refreshRate.toInt()
if (targetFps > 0) {
gameBoard.dropInterval = gameBoard.dropInterval.coerceAtMost(1000L / targetFps)
}
}
// Enable edge-to-edge rendering
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setSystemGestureExclusionRects(listOf(Rect(0, 0, width, height)))
}
// Initialize block skin paints
initializeBlockSkinPaints()
}
/**
* Initialize paints for different block skins
*/
private fun initializeBlockSkinPaints() {
// Classic skin
blockSkinPaints["block_skin_1"] = Paint().apply {
color = Color.WHITE
isAntiAlias = true
style = Paint.Style.FILL
}
// Neon skin
blockSkinPaints["block_skin_2"] = Paint().apply {
color = Color.parseColor("#FF00FF")
isAntiAlias = true
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.OUTER)
}
// Retro skin
blockSkinPaints["block_skin_3"] = Paint().apply {
color = Color.parseColor("#FF5A5F")
isAntiAlias = false // Pixelated look
style = Paint.Style.FILL
}
// Minimalist skin
blockSkinPaints["block_skin_4"] = Paint().apply {
color = Color.BLACK
isAntiAlias = true
style = Paint.Style.FILL
}
// Galaxy skin
blockSkinPaints["block_skin_5"] = Paint().apply {
color = Color.parseColor("#66FCF1")
isAntiAlias = true
maskFilter = BlurMaskFilter(12f, BlurMaskFilter.Blur.OUTER)
}
}
/**
* Set the current block skin
*/
fun setBlockSkin(skinId: String) {
Log.d("BlockSkin", "Setting block skin from: $currentBlockSkin to: $skinId")
// Check if the skin exists in our map
if (!blockSkinPaints.containsKey(skinId)) {
Log.e("BlockSkin", "Warning: Unknown block skin: $skinId - available skins: ${blockSkinPaints.keys}")
// Fall back to default skin if the requested one doesn't exist
if (blockSkinPaints.containsKey("block_skin_1")) {
currentBlockSkin = "block_skin_1"
}
} else {
// Set the skin
currentBlockSkin = skinId
}
// Save the selection to SharedPreferences
val prefs = context.getSharedPreferences("mintris_progression", Context.MODE_PRIVATE)
prefs.edit().putString("selected_block_skin", skinId).commit()
// Force a refresh of the view
invalidate()
// Log confirmation
Log.d("BlockSkin", "Block skin is now: $currentBlockSkin")
}
/**
* Get the current block skin
*/
fun getCurrentBlockSkin(): String = currentBlockSkin
/**
* Start the game
*/
fun start() {
// Reset game over animation state
isGameOverAnimating = false
gameOverAlpha = 0f
gameOverBlocksY.clear()
gameOverBlocksX.clear()
gameOverBlocksRotation.clear()
gameOverBlocksSpeed.clear()
gameOverBlocksSize.clear()
gameOverColorTransition = 0f
gameOverShakeAmount = 0f
isPaused = false
isRunning = true
gameBoard.startGame() // Add this line to ensure a new piece is spawned
handler.post(gameLoopRunnable)
invalidate()
}
/**
* Pause the game
*/
fun pause() {
isPaused = true
handler.removeCallbacks(gameLoopRunnable)
invalidate()
}
/**
* Reset the game
*/
fun reset() {
isRunning = false
isPaused = true
// Reset game over animation state
isGameOverAnimating = false
gameOverAlpha = 0f
gameOverBlocksY.clear()
gameOverBlocksX.clear()
gameOverBlocksRotation.clear()
gameOverBlocksSpeed.clear()
gameOverBlocksSize.clear()
gameOverColorTransition = 0f
gameOverShakeAmount = 0f
gameBoard.reset()
gameBoard.startGame() // Add this line to ensure a new piece is spawned
handler.removeCallbacks(gameLoopRunnable)
invalidate()
}
/**
* Update game state (called on game loop)
*/
private fun update() {
if (gameBoard.isGameOver) {
// Only trigger game over handling once when transitioning to game over state
if (isRunning) {
Log.d(TAG, "Game has ended - transitioning to game over state")
isRunning = false
isPaused = true
// Always trigger animation for each game over
Log.d(TAG, "Triggering game over animation from update()")
startGameOverAnimation()
onGameOver?.invoke(gameBoard.score)
}
return
}
// Update the game state
gameBoard.update()
// Update UI with current game state
onGameStateChanged?.invoke(gameBoard.score, gameBoard.level, gameBoard.lines)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Force hardware acceleration - Critical for performance
setLayerType(LAYER_TYPE_HARDWARE, null)
// Update gesture exclusion rect for edge-to-edge rendering
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setSystemGestureExclusionRects(listOf(Rect(0, 0, w, h)))
}
calculateDimensions(w, h)
}
/**
* Calculate dimensions for the board and blocks based on view size
*/
private fun calculateDimensions(width: Int, height: Int) {
// Calculate block size based on available space
val horizontalBlocks = gameBoard.width
val verticalBlocks = gameBoard.height
// Account for all glow effects and borders
val borderPadding = 16f // Padding for border glow effects
// Calculate block size to fit the height exactly, accounting for all padding
blockSize = (height.toFloat() - (borderPadding * 2)) / verticalBlocks
// Calculate total board width
val totalBoardWidth = blockSize * horizontalBlocks
// Center horizontally
boardLeft = (width - totalBoardWidth) / 2
boardTop = borderPadding // Start with border padding from top
// Calculate the total height needed for the board
val totalHeight = blockSize * verticalBlocks
// Log dimensions for debugging
Log.d(TAG, "Board dimensions: width=$width, height=$height, blockSize=$blockSize, boardLeft=$boardLeft, boardTop=$boardTop, totalHeight=$totalHeight")
}
override fun onDraw(canvas: Canvas) {
// Set hardware layer type during draw for better performance
val wasHardwareAccelerated = isHardwareAccelerated
if (!wasHardwareAccelerated) {
setLayerType(LAYER_TYPE_HARDWARE, null)
}
super.onDraw(canvas)
// Draw background (already black from theme)
// Draw board border glow
drawBoardBorder(canvas)
// Draw grid (very subtle)
drawGrid(canvas)
// Draw locked pieces
drawLockedBlocks(canvas)
if (!gameBoard.isGameOver && isRunning) {
// Draw ghost piece (landing preview)
drawGhostPiece(canvas)
// Draw active piece
drawActivePiece(canvas)
}
// Draw game over effect if animating
if (isGameOverAnimating) {
// First layer - full screen glow
val gameOverPaint = Paint().apply {
color = Color.RED // Change to red for more striking game over indication
alpha = (230 * gameOverAlpha).toInt() // Increased opacity
isAntiAlias = true
style = Paint.Style.FILL
// Only apply blur if alpha is greater than 0
if (gameOverAlpha > 0) {
maskFilter = BlurMaskFilter(64f * gameOverAlpha, BlurMaskFilter.Blur.OUTER)
}
}
// Apply screen shake if active
if (gameOverShakeAmount > 0) {
canvas.save()
val shakeOffsetX = (Math.random() * 2 - 1) * gameOverShakeAmount * 20 // Doubled for more visible shake
val shakeOffsetY = (Math.random() * 2 - 1) * gameOverShakeAmount * 20
canvas.translate(shakeOffsetX.toFloat(), shakeOffsetY.toFloat())
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gameOverPaint)
// Second layer - color transition effect
val gameOverPaint2 = Paint().apply {
// Transition from bright red to theme color
val transitionColor = if (gameOverColorTransition < 0.5f) {
Color.RED
} else {
val transition = (gameOverColorTransition - 0.5f) * 2f
val red = Color.red(currentThemeColor)
val green = Color.green(currentThemeColor)
val blue = Color.blue(currentThemeColor)
Color.argb(
(150 * gameOverAlpha).toInt(), // Increased opacity
(255 - (255-red) * transition).toInt(),
(green * transition).toInt(), // Transition from 0 (red) to theme green
(blue * transition).toInt() // Transition from 0 (red) to theme blue
)
}
color = transitionColor
alpha = (150 * gameOverAlpha).toInt() // Increased opacity
isAntiAlias = true
style = Paint.Style.FILL
// Only apply blur if alpha is greater than 0
if (gameOverAlpha > 0) {
maskFilter = BlurMaskFilter(48f * gameOverAlpha, BlurMaskFilter.Blur.OUTER) // Increased blur
}
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gameOverPaint2)
// Draw "GAME OVER" text
if (gameOverAlpha > 0.5f) {
val textPaint = Paint().apply {
color = Color.WHITE
alpha = (255 * Math.min(1f, (gameOverAlpha - 0.5f) * 2)).toInt()
isAntiAlias = true
textSize = blockSize * 1.5f // Reduced from 2f to 1.5f to fit on screen
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
style = Paint.Style.FILL_AND_STROKE
strokeWidth = blockSize * 0.08f // Proportionally reduced from 0.1f
}
// Draw text with glow
val glowPaint = Paint(textPaint).apply {
maskFilter = BlurMaskFilter(blockSize * 0.4f, BlurMaskFilter.Blur.NORMAL) // Reduced from 0.5f
alpha = (200 * Math.min(1f, (gameOverAlpha - 0.5f) * 2)).toInt()
color = Color.RED
strokeWidth = blockSize * 0.15f // Reduced from 0.2f
}
val xPos = width / 2f
val yPos = height / 3f
// Measure text width to check if it fits
val textWidth = textPaint.measureText("GAME OVER")
// If text would still be too wide, scale it down further
if (textWidth > width * 0.9f) {
val scaleFactor = width * 0.9f / textWidth
textPaint.textSize *= scaleFactor
glowPaint.textSize *= scaleFactor
}
canvas.drawText("GAME OVER", xPos, yPos, glowPaint)
canvas.drawText("GAME OVER", xPos, yPos, textPaint)
}
// Draw falling blocks
if (gameOverBlocksY.isNotEmpty()) {
for (i in gameOverBlocksY.indices) {
val x = gameOverBlocksX[i]
val y = gameOverBlocksY[i]
val rotation = gameOverBlocksRotation[i]
val size = gameOverBlocksSize[i] * blockSize
// Skip blocks that have fallen off the screen
if (y > height) continue
// Draw each falling block with rotation
canvas.save()
canvas.translate(x, y)
canvas.rotate(rotation)
// Create a pulsing effect for the falling blocks
val blockPaint = Paint(blockPaint)
blockPaint.alpha = (255 * gameOverAlpha * (1.0f - y / height.toFloat() * 0.7f)).toInt()
// Draw block with glow effect
val blockGlowPaint = Paint(blockGlowPaint)
blockGlowPaint.alpha = (200 * gameOverAlpha * (1.0f - y / height.toFloat() * 0.5f)).toInt()
canvas.drawRect(-size/2, -size/2, size/2, size/2, blockGlowPaint)
canvas.drawRect(-size/2, -size/2, size/2, size/2, blockPaint)
canvas.restore()
}
}
// Reset any transformations from screen shake
if (gameOverShakeAmount > 0) {
canvas.restore()
}
}
}
/**
* Draw glowing border around the playable area
*/
private fun drawBoardBorder(canvas: Canvas) {
val left = boardLeft
val top = boardTop
val right = boardLeft + gameBoard.width * blockSize
val bottom = boardTop + gameBoard.height * blockSize
val rect = RectF(left, top, right, bottom)
// Draw base border with increased glow
borderGlowPaint.apply {
alpha = 80 // Increased from 60
maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.OUTER) // Increased from 8f
}
canvas.drawRect(rect, borderGlowPaint)
// Draw pulsing border if animation is active
if (isPulsing) {
val pulseBorderPaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 6f + (16f * pulseAlpha) // Increased from 4f+12f to 6f+16f
alpha = (255 * pulseAlpha).toInt()
isAntiAlias = true
maskFilter = BlurMaskFilter(32f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Increased from 24f to 32f
}
// Draw the border with a slight inset to prevent edge artifacts
val inset = 1f
canvas.drawRect(
left + inset,
top + inset,
right - inset,
bottom - inset,
pulseBorderPaint
)
// Add an additional outer glow for more dramatic effect
val outerGlowPaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 2f
alpha = (128 * pulseAlpha).toInt()
isAntiAlias = true
maskFilter = BlurMaskFilter(48f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER)
}
canvas.drawRect(
left - 4f,
top - 4f,
right + 4f,
bottom + 4f,
outerGlowPaint
)
// Add extra bright glow for side borders during line clear
val sideGlowPaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = 8f + (24f * pulseAlpha) // Thicker stroke for side borders
alpha = (255 * pulseAlpha).toInt()
isAntiAlias = true
maskFilter = BlurMaskFilter(64f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER) // Larger blur for side borders
}
// Draw left border with extra glow
canvas.drawLine(
left + inset,
top + inset,
left + inset,
bottom - inset,
sideGlowPaint
)
// Draw right border with extra glow
canvas.drawLine(
right - inset,
top + inset,
right - inset,
bottom - inset,
sideGlowPaint
)
}
}
/**
* Draw the grid lines (very subtle)
*/
private fun drawGrid(canvas: Canvas) {
// Save the canvas state to prevent any effects from affecting the grid
canvas.save()
// Draw vertical grid lines
for (x in 0..gameBoard.width) {
val xPos = boardLeft + x * blockSize
canvas.drawLine(
xPos, boardTop,
xPos, boardTop + gameBoard.height * blockSize,
gridPaint
)
}
// Draw horizontal grid lines
for (y in 0..gameBoard.height) {
val yPos = boardTop + y * blockSize
canvas.drawLine(
boardLeft, yPos,
boardLeft + gameBoard.width * blockSize, yPos,
gridPaint
)
}
// Restore the canvas state
canvas.restore()
}
/**
* Draw the locked blocks on the board
*/
private fun drawLockedBlocks(canvas: Canvas) {
for (y in 0 until gameBoard.height) {
for (x in 0 until gameBoard.width) {
if (gameBoard.isOccupied(x, y)) {
drawBlock(canvas, x, y, false, y in linesToPulse)
}
}
}
}
/**
* Draw the currently active tetromino
*/
private fun drawActivePiece(canvas: Canvas) {
val piece = gameBoard.getCurrentPiece() ?: return
for (y in 0 until piece.getHeight()) {
for (x in 0 until piece.getWidth()) {
if (piece.isBlockAt(x, y)) {
val boardX = piece.x + x
val boardY = piece.y + y
// Draw piece regardless of vertical position
if (boardX >= 0 && boardX < gameBoard.width) {
drawBlock(canvas, boardX, boardY, false, false)
}
}
}
}
}
/**
* Draw the ghost piece (landing preview)
*/
private fun drawGhostPiece(canvas: Canvas) {
val piece = gameBoard.getCurrentPiece() ?: return
val ghostY = gameBoard.getGhostY()
// Draw semi-transparent background for each block
for (y in 0 until piece.getHeight()) {
for (x in 0 until piece.getWidth()) {
if (piece.isBlockAt(x, y)) {
val boardX = piece.x + x
val boardY = ghostY + y
if (boardX >= 0 && boardX < gameBoard.width) {
val screenX = boardLeft + boardX * blockSize
val screenY = boardTop + boardY * blockSize
// Draw background
canvas.drawRect(
screenX + 1f,
screenY + 1f,
screenX + blockSize - 1f,
screenY + blockSize - 1f,
ghostBackgroundPaint
)
// Draw border
canvas.drawRect(
screenX + 1f,
screenY + 1f,
screenX + blockSize - 1f,
screenY + blockSize - 1f,
ghostBorderPaint
)
// Draw outline
canvas.drawRect(
screenX + 1f,
screenY + 1f,
screenX + blockSize - 1f,
screenY + blockSize - 1f,
ghostPaint
)
}
}
}
}
}
/**
* Draw a single tetris block at the given grid position
*/
private fun drawBlock(canvas: Canvas, x: Int, y: Int, isGhost: Boolean, isPulsingLine: Boolean) {
val left = boardLeft + x * blockSize
val top = boardTop + y * blockSize
val right = left + blockSize
val bottom = top + blockSize
// Save canvas state before drawing block effects
canvas.save()
// Get the current block skin paint
val paint = blockSkinPaints[currentBlockSkin] ?: blockSkinPaints["block_skin_1"]!!
// Create a clone of the paint to avoid modifying the original
val blockPaint = Paint(paint)
// Draw block based on current skin
when (currentBlockSkin) {
"block_skin_1" -> { // Classic
// Draw outer glow
blockGlowPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE
canvas.drawRect(left - 2f, top - 2f, right + 2f, bottom + 2f, blockGlowPaint)
// Draw block
blockPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE
blockPaint.alpha = if (isGhost) 30 else 255
canvas.drawRect(left, top, right, bottom, blockPaint)
// Draw inner glow
glowPaint.color = if (isGhost) Color.argb(30, 255, 255, 255) else Color.WHITE
canvas.drawRect(left + 1f, top + 1f, right - 1f, bottom - 1f, glowPaint)
}
"block_skin_2" -> { // Neon
// Stronger outer glow for neon skin
blockGlowPaint.color = if (isGhost) Color.argb(30, 255, 0, 255) else Color.parseColor("#FF00FF")
blockGlowPaint.maskFilter = BlurMaskFilter(16f, BlurMaskFilter.Blur.OUTER)
canvas.drawRect(left - 4f, top - 4f, right + 4f, bottom + 4f, blockGlowPaint)
// For neon, use semi-translucent fill with strong glowing edges
blockPaint.style = Paint.Style.FILL_AND_STROKE
blockPaint.strokeWidth = 2f
blockPaint.maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
if (isGhost) {
blockPaint.color = Color.argb(30, 255, 0, 255)
blockPaint.alpha = 30
} else {
blockPaint.color = Color.parseColor("#66004D") // Darker magenta fill
blockPaint.alpha = 170 // More opaque to be more visible
}
// Draw block with neon effect
canvas.drawRect(left, top, right, bottom, blockPaint)
// Draw a brighter border for better visibility
val borderPaint = Paint().apply {
color = Color.parseColor("#FF00FF")
style = Paint.Style.STROKE
strokeWidth = 3f
alpha = 255
isAntiAlias = true
maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL)
}
canvas.drawRect(left, top, right, bottom, borderPaint)
// Inner glow for neon blocks
glowPaint.color = if (isGhost) Color.argb(10, 255, 0, 255) else Color.parseColor("#FF00FF")
glowPaint.alpha = if (isGhost) 10 else 100
glowPaint.style = Paint.Style.STROKE
glowPaint.strokeWidth = 2f
glowPaint.maskFilter = BlurMaskFilter(4f, BlurMaskFilter.Blur.NORMAL)
canvas.drawRect(left + 4f, top + 4f, right - 4f, bottom - 4f, glowPaint)
}
"block_skin_3" -> { // Retro
// Draw pixelated block with retro effect
blockPaint.color = if (isGhost) Color.argb(30, 255, 90, 95) else Color.parseColor("#FF5A5F")
blockPaint.alpha = if (isGhost) 30 else 255
// Draw main block
canvas.drawRect(left, top, right, bottom, blockPaint)
// Draw pixelated highlights
val highlightPaint = Paint().apply {
color = Color.parseColor("#FF8A8F")
isAntiAlias = false
style = Paint.Style.FILL
}
// Top and left highlights
canvas.drawRect(left, top, right - 2f, top + 2f, highlightPaint)
canvas.drawRect(left, top, left + 2f, bottom - 2f, highlightPaint)
// Draw pixelated shadows
val shadowPaint = Paint().apply {
color = Color.parseColor("#CC4A4F")
isAntiAlias = false
style = Paint.Style.FILL
}
// Bottom and right shadows
canvas.drawRect(left + 2f, bottom - 2f, right, bottom, shadowPaint)
canvas.drawRect(right - 2f, top + 2f, right, bottom - 2f, shadowPaint)
}
"block_skin_4" -> { // Minimalist
// Draw clean, simple block with subtle border
blockPaint.color = if (isGhost) Color.argb(30, 0, 0, 0) else Color.BLACK
blockPaint.alpha = if (isGhost) 30 else 255
blockPaint.style = Paint.Style.FILL
canvas.drawRect(left, top, right, bottom, blockPaint)
// Draw subtle border
val borderPaint = Paint().apply {
color = Color.parseColor("#333333")
style = Paint.Style.STROKE
strokeWidth = 1f
isAntiAlias = true
}
canvas.drawRect(left, top, right, bottom, borderPaint)
}
"block_skin_5" -> { // Galaxy
// Draw cosmic glow effect
blockGlowPaint.color = if (isGhost) Color.argb(30, 102, 252, 241) else Color.parseColor("#66FCF1")
blockGlowPaint.maskFilter = BlurMaskFilter(20f, BlurMaskFilter.Blur.OUTER)
canvas.drawRect(left - 8f, top - 8f, right + 8f, bottom + 8f, blockGlowPaint)
// Draw main block with gradient
val gradient = LinearGradient(
left, top, right, bottom,
Color.parseColor("#66FCF1"),
Color.parseColor("#45B7AF"),
Shader.TileMode.CLAMP
)
blockPaint.shader = gradient
blockPaint.color = if (isGhost) Color.argb(30, 102, 252, 241) else Color.parseColor("#66FCF1")
blockPaint.alpha = if (isGhost) 30 else 255
blockPaint.style = Paint.Style.FILL
canvas.drawRect(left, top, right, bottom, blockPaint)
// Draw star-like sparkles
if (!isGhost) {
val sparklePaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.FILL
isAntiAlias = true
maskFilter = BlurMaskFilter(4f, BlurMaskFilter.Blur.NORMAL)
}
// Add small white dots for sparkle effect
canvas.drawCircle(left + 4f, top + 4f, 1f, sparklePaint)
canvas.drawCircle(right - 4f, bottom - 4f, 1f, sparklePaint)
}
}
}
// Draw pulse effect if animation is active and this is a pulsing line
if (isPulsing && isPulsingLine) {
val pulseBlockPaint = Paint().apply {
color = Color.WHITE
alpha = (255 * pulseAlpha).toInt()
isAntiAlias = true
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(40f * (1f + pulseAlpha), BlurMaskFilter.Blur.OUTER)
}
canvas.drawRect(left - 16f, top - 16f, right + 16f, bottom + 16f, pulseBlockPaint)
}
// Restore canvas state after drawing block effects
canvas.restore()
}
/**
* Check if the given board position is part of the current piece
*/
private fun isPositionInPiece(boardX: Int, boardY: Int, piece: Tetromino): Boolean {
for (y in 0 until piece.getHeight()) {
for (x in 0 until piece.getWidth()) {
if (piece.isBlockAt(x, y)) {
val pieceX = piece.x + x
val pieceY = piece.y + y
if (pieceX == boardX && pieceY == boardY) {
return true
}
}
}
}
return false
}
/**
* Get color for tetromino type
*/
private fun getTetrominoColor(type: TetrominoType): Int {
return when (type) {
TetrominoType.I -> Color.CYAN
TetrominoType.J -> Color.BLUE
TetrominoType.L -> Color.rgb(255, 165, 0) // Orange
TetrominoType.O -> Color.YELLOW
TetrominoType.S -> Color.GREEN
TetrominoType.T -> Color.MAGENTA
TetrominoType.Z -> Color.RED
}
}
// Custom touch event handling
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isRunning || isPaused || gameBoard.isGameOver) {
return true
}
// Ignore touch events during the freeze period after a piece locks
val currentTime = System.currentTimeMillis()
if (currentTime < touchFreezeUntil) {
Log.d(TAG, "Ignoring touch event - freeze active for ${touchFreezeUntil - currentTime}ms more")
return true
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = event.x
startY = event.y
lastTouchX = event.x
lastTouchY = event.y
lastTapTime = currentTime // Set the tap time when touch starts
// Reset direction lock
lockedDirection = null
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.x - lastTouchX
val deltaY = event.y - lastTouchY
// Check if we should lock direction
if (lockedDirection == null) {
val absDeltaX = abs(deltaX)
val absDeltaY = abs(deltaY)
if (absDeltaX > blockSize * directionLockThreshold ||
absDeltaY > blockSize * directionLockThreshold) {
// Lock to the dominant direction
lockedDirection = if (absDeltaX > absDeltaY) {
Direction.HORIZONTAL
} else {
Direction.VERTICAL
}
}
}
// Handle movement based on locked direction
when (lockedDirection) {
Direction.HORIZONTAL -> {
if (abs(deltaX) > blockSize * minMovementThreshold) {
if (deltaX > 0) {
gameBoard.moveRight()
} else {
gameBoard.moveLeft()
}
lastTouchX = event.x
if (currentTime - lastMoveTime >= moveCooldown) {
gameHaptics?.vibrateForPieceMove()
lastMoveTime = currentTime
}
invalidate()
}
}
Direction.VERTICAL -> {
if (deltaY > blockSize * minMovementThreshold) {
gameBoard.softDrop()
lastTouchY = event.y
if (currentTime - lastMoveTime >= moveCooldown) {
gameHaptics?.vibrateForPieceMove()
lastMoveTime = currentTime
}
invalidate()
}
}
null -> {
// No direction lock yet, don't process movement
}
}
}
MotionEvent.ACTION_UP -> {
val deltaX = event.x - startX
val deltaY = event.y - startY
val moveTime = currentTime - lastTapTime
// Handle taps for rotation
if (moveTime < minTapTime * 1.5 &&
abs(deltaY) < maxTapMovement * 1.5 &&
abs(deltaX) < maxTapMovement * 1.5) {
if (currentTime - lastRotationTime >= rotationCooldown) {
gameBoard.rotate()
lastRotationTime = currentTime
gameHaptics?.vibrateForPieceMove()
invalidate()
}
return true
}
// Handle gestures
// Check for hold gesture (swipe up)
if (deltaY < -blockSize * minHoldDistance &&
abs(deltaX) / abs(deltaY) < 0.5f) {
if (currentTime - lastHoldTime >= holdCooldown) {
gameBoard.holdPiece()
lastHoldTime = currentTime
gameHaptics?.vibrateForPieceMove()
invalidate()
}
}
// Check for hard drop (must be faster and longer than soft drop)
else if (deltaY > blockSize * minHardDropDistance &&
abs(deltaX) / abs(deltaY) < 0.5f &&
(deltaY / moveTime) * 1000 > minSwipeVelocity) {
if (currentTime - lastHardDropTime >= hardDropCooldown) {
gameBoard.hardDrop()
lastHardDropTime = currentTime
invalidate()
}
}
// Check for soft drop (slower and shorter than hard drop)
else if (deltaY > blockSize * minMovementThreshold &&
deltaY < blockSize * maxSoftDropDistance &&
(deltaY / moveTime) * 1000 < minSwipeVelocity) {
gameBoard.softDrop()
invalidate()
}
// Reset direction lock
lockedDirection = null
}
}
return true
}
/**
* Get the current score
*/
fun getScore(): Int = gameBoard.score
/**
* Get the current level
*/
fun getLevel(): Int = gameBoard.level
/**
* Get the number of lines cleared
*/
fun getLines(): Int = gameBoard.lines
/**
* Check if the game is over
*/
fun isGameOver(): Boolean = gameBoard.isGameOver
/**
* Get the next piece that will be spawned
*/
fun getNextPiece(): Tetromino? {
return gameBoard.getNextPiece()
}
/**
* Get the game board instance
*/
fun getGameBoard(): GameBoard = gameBoard
/**
* Clean up resources when view is detached
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
handler.removeCallbacks(gameLoopRunnable)
}
/**
* Set the game board for this view
*/
fun setGameBoard(board: GameBoard) {
gameBoard = board
// Reconnect callbacks to the new board
gameBoard.onPieceMove = { onPieceMove?.invoke() }
gameBoard.onPieceLock = { onPieceLock?.invoke() }
gameBoard.onLineClear = { lineCount, clearedLines ->
Log.d(TAG, "Received line clear from GameBoard: $lineCount lines")
try {
onLineClear?.invoke(lineCount)
// Use the lines that were cleared directly
linesToPulse.clear()
linesToPulse.addAll(clearedLines)
Log.d(TAG, "Found ${linesToPulse.size} lines to pulse")
startPulseAnimation(lineCount)
Log.d(TAG, "Forwarded line clear callback")
} catch (e: Exception) {
Log.e(TAG, "Error forwarding line clear callback", e)
}
}
invalidate()
}
/**
* Set the haptics handler for this view
*/
fun setHaptics(haptics: GameHaptics) {
gameHaptics = haptics
}
/**
* Resume the game
*/
fun resume() {
if (!isRunning) {
isRunning = true
}
isPaused = false
// Restart the game loop immediately
handler.removeCallbacks(gameLoopRunnable)
handler.post(gameLoopRunnable)
// Force an update to ensure pieces move immediately
update()
invalidate()
}
/**
* Start the pulse animation for line clear
*/
private fun startPulseAnimation(lineCount: Int) {
Log.d(TAG, "Starting pulse animation for $lineCount lines")
// Cancel any existing animation
pulseAnimator?.cancel()
// Create new animation
pulseAnimator = ValueAnimator.ofFloat(0f, 1f, 0f).apply {
duration = when (lineCount) {
4 -> 2000L // Tetris - longer duration
3 -> 1600L // Triples
2 -> 1200L // Doubles
1 -> 1000L // Singles
else -> 1000L
}
interpolator = LinearInterpolator()
addUpdateListener { animation ->
pulseAlpha = animation.animatedValue as Float
isPulsing = true
invalidate()
Log.d(TAG, "Pulse animation update: alpha = $pulseAlpha")
}
addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
isPulsing = false
pulseAlpha = 0f
linesToPulse.clear()
invalidate()
Log.d(TAG, "Pulse animation ended")
}
})
}
pulseAnimator?.start()
}
2025-03-29 01:59:14 -04:00
/**
* Set the theme color for the game view
*/
fun setThemeColor(color: Int) {
currentThemeColor = color
blockPaint.color = color
ghostBlockPaint.color = color
glowPaint.color = color
blockGlowPaint.color = color
borderGlowPaint.color = color
pulsePaint.color = color
invalidate()
}
/**
* Set the background color for the game view
*/
override fun setBackgroundColor(color: Int) {
super.setBackgroundColor(color)
invalidate()
}
/**
* Start the game over animation
*/
fun startGameOverAnimation() {
Log.d(TAG, "Starting game over animation")
// Check if game over already showing
if (isGameOverAnimating && gameOverAlpha > 0.5f) {
Log.d(TAG, "Game over animation already active - skipping")
return
}
// Cancel any existing animations
pulseAnimator?.cancel()
gameOverAnimator?.cancel()
// Trigger haptic feedback
gameHaptics?.vibrateForGameOver()
// Force immediate visual feedback
isGameOverAnimating = true
gameOverAlpha = 0.3f
invalidate()
// Generate falling blocks based on current board state
generateGameOverBlocks()
// Create new game over animation
gameOverAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 3000L // Increased from 2000L (2 seconds) to 3000L (3 seconds)
interpolator = LinearInterpolator()
addUpdateListener { animation ->
val progress = animation.animatedValue as Float
// Main alpha transition for overlay
gameOverAlpha = when {
progress < 0.2f -> progress * 5f // Quick fade in (first 20% of animation)
else -> 1f // Hold at full opacity
}
// Color transition effect (start after 40% of animation)
gameOverColorTransition = when {
progress < 0.4f -> 0f
progress < 0.8f -> (progress - 0.4f) * 2.5f // Transition during 40%-80%
else -> 1f
}
// Screen shake effect (strongest at beginning, fades out)
gameOverShakeAmount = when {
progress < 0.3f -> progress * 3.33f // Ramp up
progress < 0.6f -> 1f - (progress - 0.3f) * 3.33f // Ramp down
else -> 0f // No shake
}
// Update falling blocks
updateGameOverBlocks()
isGameOverAnimating = true
invalidate()
Log.d(TAG, "Game over animation update: alpha = $gameOverAlpha, progress = $progress")
}
addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
Log.d(TAG, "Game over animation ended - Final alpha: $gameOverFinalAlpha")
isGameOverAnimating = true // Keep true to maintain visibility
gameOverAlpha = gameOverFinalAlpha // Keep at 80% opacity
invalidate()
}
})
}
gameOverAnimator?.start()
}
/**
* Generate falling blocks for the game over animation based on current board state
*/
private fun generateGameOverBlocks() {
// Clear existing blocks
gameOverBlocksY.clear()
gameOverBlocksX.clear()
gameOverBlocksRotation.clear()
gameOverBlocksSpeed.clear()
gameOverBlocksSize.clear()
// Generate 30-40 blocks across the board
val numBlocks = (30 + Math.random() * 10).toInt()
for (i in 0 until numBlocks) {
// Start positions - distribute across the board width but clustered near the top
gameOverBlocksX.add(boardLeft + (Math.random() * gameBoard.width * blockSize).toFloat())
gameOverBlocksY.add((boardTop - blockSize * 2 + Math.random() * height * 0.3f).toFloat())
// Random rotation
gameOverBlocksRotation.add((Math.random() * 360).toFloat())
// Random fall speed (some faster, some slower)
gameOverBlocksSpeed.add((5 + Math.random() * 15).toFloat())
// Slightly varied block sizes
gameOverBlocksSize.add((0.8f + Math.random() * 0.4f).toFloat())
}
}
/**
* Update the position of falling blocks in the game over animation
*/
private fun updateGameOverBlocks() {
for (i in gameOverBlocksY.indices) {
// Update Y position based on speed
gameOverBlocksY[i] += gameOverBlocksSpeed[i]
// Update rotation
gameOverBlocksRotation[i] += gameOverBlocksSpeed[i] * 0.5f
// Accelerate falling
gameOverBlocksSpeed[i] *= 1.03f
}
}
2025-03-31 04:52:11 -04:00
/**
* Check if the game is active (running, not paused, not game over)
*/
fun isActive(): Boolean {
return isRunning && !isPaused && !gameBoard.isGameOver
}
/**
* Move the current piece left (for gamepad/keyboard support)
*/
fun moveLeft() {
if (!isActive()) return
gameBoard.moveLeft()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
/**
* Move the current piece right (for gamepad/keyboard support)
*/
fun moveRight() {
if (!isActive()) return
gameBoard.moveRight()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
/**
* Rotate the current piece (for gamepad/keyboard support)
*/
fun rotate() {
if (!isActive()) return
gameBoard.rotate()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
/**
* Rotate the current piece counterclockwise (for gamepad/keyboard support)
*/
fun rotateCounterClockwise() {
if (!isActive()) return
gameBoard.rotateCounterClockwise()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
2025-03-31 04:52:11 -04:00
/**
* Perform a soft drop (move down faster) (for gamepad/keyboard support)
*/
fun softDrop() {
if (!isActive()) return
gameBoard.softDrop()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
/**
* Perform a hard drop (instant drop) (for gamepad/keyboard support)
*/
fun hardDrop() {
if (!isActive()) return
gameBoard.hardDrop()
// Hard drop haptic feedback is handled by the game board via onPieceLock
invalidate()
}
/**
* Hold the current piece (for gamepad/keyboard support)
*/
fun holdPiece() {
if (!isActive()) return
gameBoard.holdPiece()
gameHaptics?.vibrateForPieceMove()
invalidate()
}
}