티스토리 뷰
앱 공부 할 겸 안드로이드 공식 언어인 코틀린으로 벽돌 깨기 게임을 간단하게 만들어 보았습니다. 복잡한 기능은 구현하지 않고 기본적인 기능만 구현하였으니 가볍게 봐주시길 바랍니다. 전체소스는 Github를 참고해 주세요.
1. 프로젝트 구조
+---java
| \---com
| \---cyb
| \---brickbraker
| | MainActivity.kt
| |
| +---common
| | Constants.kt
| |
| +---components
| | Ball.kt
| | Brick.kt
| | GameView.kt
| | Paddle.kt
| | Stage.kt
| |
| \---ui
| \---theme
| Color.kt
| Theme.kt
| Type.kt
- tree /F /A 명령어를 사용하여 프로젝트 구조를 가져왔습니다. src > main 부분만 수정하였습니다.
2. MainActivity.kt
package com.cyb.brickbraker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.cyb.brickbraker.common.Constants
import com.cyb.brickbraker.components.GameView
import com.cyb.brickbraker.ui.theme.BrickBrakerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Constants.init(this)
setContent {
BrickBrakerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
GameScreen(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@Composable
fun GameScreen(modifier: Modifier = Modifier) {
var selectedStage by remember { mutableStateOf<Int?>(null) }
if (selectedStage != null) {
// 선택된 스테이지에 따라 게임 화면을 보여줍니다.
AndroidView(
factory = { context -> GameView(context, selectedStage!!) },
modifier = modifier.fillMaxSize()
)
} else {
StageSelection { stage -> selectedStage = stage } // 스테이지 선택
}
}
@Composable
fun StageSelection(onStageSelected: (Int) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "스테이지 선택",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(bottom = 32.dp)
)
// 각 스테이지 버튼
for (stage in 1..3) {
StageButton(stage = stage, onClick = { onStageSelected(stage) })
}
}
}
@Composable
fun StageButton(stage: Int, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6200EE)) // 버튼 색상 설정
) {
Text(text = "Stage $stage", fontSize = 20.sp, color = Color.White) // 버튼 텍스트
}
}
@Preview(showBackground = true)
@Composable
fun GameScreenPreview() {
BrickBrakerTheme {
GameScreen()
}
}
- 프로젝트의 진입점으로 초기화면을 구성합니다.
- 스테이지를 표시해 게임을 시작할 수 있습니다.
3. Constants.kt
package com.cyb.brickbraker.common
import android.annotation.SuppressLint
import android.content.Context
@SuppressLint("StaticFieldLeak")
object Constants {
lateinit var context: Context
// 화면 세로 길이
val screenHeight: Int
get() = context.resources.displayMetrics.heightPixels
// 화면 가로 길이
val screenWidth: Int
get() = context.resources.displayMetrics.widthPixels
// 초기화
fun init(context: Context) {
this.context = context
}
}
- 디바이스의 가로, 세로 길이를 전역변수로 관리합니다.
4. Ball.kt
package com.cyb.brickbraker.components
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import com.cyb.brickbraker.common.Constants
/**
* 공
*/
class Ball {
// 공 색상 지정
private val paint = Paint().apply {
color = Color.RED
}
var radius = 20f // 공의 크기
var speed = 10f // 공의 속도
var dx = speed // x 방향 속도
var dy = speed // y 방향 속도
// 공의 초기 위치를 화면 중앙으로 설정
var rect = RectF(
(Constants.screenWidth / 2) - radius,
(Constants.screenHeight / 2) - radius,
(Constants.screenWidth / 2) + radius,
(Constants.screenHeight / 2) + radius
)
fun resetBall(){
radius = 20f
speed = 10f
dx = speed
dy = speed
rect.set(
(Constants.screenWidth / 2) - radius,
(Constants.screenHeight / 2) - radius,
(Constants.screenWidth / 2) + radius,
(Constants.screenHeight / 2) + radius
)
}
// 공 위치 업데이트
fun update() {
rect.offset(dx, dy)
}
// 공 좌우 방향 반전 (x축 속도를 반전)
fun reverseX() {
dx = -dx
}
// 공 상하 방향 반전 (y축 속도를 반전)
fun reverseY() {
dy = -dy
}
// 공 그리기
fun draw(canvas: Canvas) {
update()
rect.set(
rect.left,
rect.top,
rect.left + radius * 2,
rect.top + radius * 2
)
canvas.drawOval(rect, paint)
}
}
- 공의 모양과 위치, 방향을 설정합니다.
5. Brick.kt
package com.cyb.brickbraker.components
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
/**
* 벽돌
*/
class Brick(x: Float, y: Float) {
// 벽돌 색상 지정
private val paint = Paint().apply {
color = Color.BLUE
}
val width = 120f // 벽돌 너비
val height = 50f // 벽돌 높이
// 벽돌 위치 지정
var rect = RectF(x, y, x + width, y + height)
fun draw(canvas: Canvas) {
canvas.drawRect(rect, paint)
}
}
- 벽돌을 객체로 만들어둡니다.
6. Paddle.kt
package com.cyb.brickbraker.components
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import com.cyb.brickbraker.common.Constants
class Paddle() {
// 패들 색상 지정
private val paint = Paint().apply {
color = Color.WHITE
}
private val paddleWidth = 500f // 패들 너비
private val paddleHeight = 40f // 패들 높이
// 패들 위치 설정 (화면 하단 중앙에 위치)
var rect = RectF(
(Constants.screenWidth / 2) - (paddleWidth / 2),
Constants.screenHeight - 150f,
(Constants.screenWidth / 2) + (paddleWidth / 2),
Constants.screenHeight - 150f + paddleHeight
)
fun resetPaddle(){
rect.set(
(Constants.screenWidth / 2) - (paddleWidth / 2),
Constants.screenHeight - 150f,
(Constants.screenWidth / 2) + (paddleWidth / 2),
Constants.screenHeight - 150f + paddleHeight
)
}
// 패들 이동 메서드
fun moveTo(x: Float) {
// 패들의 중심을 x 좌표로 이동
rect.offsetTo(x - paddleWidth / 2, rect.top)
// 패들이 화면 바깥으로 나가지 않도록 제한
if (rect.left < 0) {
rect.offsetTo(0f, rect.top) // 왼쪽 경계
}
if (rect.right > Constants.screenWidth) {
rect.offsetTo(Constants.screenWidth - paddleWidth, rect.top) // 오른쪽 경계
}
}
fun draw(canvas: Canvas) {
canvas.drawRect(rect, paint)
}
}
- 패들의 모양과 초기위치를 지정하고, 패들 이동 메서드를 정의합니다.
7. Stage.kt
package com.cyb.brickbraker.components
import com.cyb.brickbraker.common.Constants
class Stage(val stageNumber: Int) {
// 벽돌 리스트
val bricks = mutableListOf<Brick>()
private var totalRows: Int = 0
private var totalColumns: Int = 0
fun setupBricks() {
when (stageNumber) {
1 -> setupStage1Bricks()
2 -> setupStage2Bricks()
3 -> setupStage3Bricks()
}
}
fun getTotal(): Int {
return totalRows * totalColumns
}
// 스테이지 1 벽돌 배치
private fun setupStage1Bricks() {
totalRows = 5
totalColumns = 8
val brickWidth = 120f
val brickHeight = 50f
val totalBrickWidth = brickWidth * totalColumns + 20f * (totalColumns - 1)
val startX = (Constants.screenWidth - totalBrickWidth) / 2
for (i in 0 until totalRows) {
for (j in 0 until totalColumns) {
val x = startX + j * (brickWidth + 20f)
val y = i * (brickHeight + 10f)
bricks.add(Brick(x, y))
}
}
}
// 스테이지 2 벽돌 배치
private fun setupStage2Bricks() {
totalRows = 6 // 행 수를 늘림
totalColumns = 12 // 열 수를 늘림
val brickWidth = 90f // 벽돌 너비를 줄임
val brickHeight = 40f // 벽돌 높이를 약간 늘림
val totalBrickWidth = brickWidth * totalColumns + 10f * (totalColumns - 1) // 간격 조정
val startX = (Constants.screenWidth - totalBrickWidth) / 2
for (i in 0 until totalRows) {
for (j in 0 until totalColumns) {
// 행의 홀수 및 짝수에 대해 다른 패턴 적용
if (i % 2 == 0) { // 짝수 행에만 벽돌 배치
val x = startX + j * (brickWidth + 10f)
val y = i * (brickHeight + 15f) // 행 사이 간격 조정
bricks.add(Brick(x, y))
}
}
}
}
// 스테이지 3 벽돌 배치
private fun setupStage3Bricks() {
totalRows = 5 // 행 수
val totalColumns = 9 // 열 수
val brickWidth = 100f // 벽돌 너비
val brickHeight = 60f // 벽돌 높이를 증가시킴
val totalBrickWidth = brickWidth * totalColumns + 15f * (totalColumns - 1)
val startX = (Constants.screenWidth - totalBrickWidth) / 2
for (i in 0 until totalRows) {
for (j in 0 until totalColumns) {
// 계단 형태로 배치
if (j >= totalRows - i) { // 계단 형태 유지
val x = startX + j * (brickWidth + 15f) // 간격 조정
val y = i * (brickHeight + 10f) // 높이 증가에 따라 조정
bricks.add(Brick(x, y))
}
}
}
}
}
- 각 스테이지에서 사용할 벽돌들의 배치합니다. 해당 소스에선 3개의 스테이지를 정의해 두었습니다.
8. GameView.kt
package com.cyb.brickbraker.components
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import com.cyb.brickbraker.common.Constants
/**
* 벽돌깨기 메인 뷰
*/
class GameView(context: Context, stageNumber: Int, attrs: AttributeSet? = null, ) : SurfaceView(context, attrs),
SurfaceHolder.Callback {
private val paddle = Paddle() // 패들
private val ball = Ball() // 볼
private val bricks = mutableListOf<Brick>() // 벽돌리스트
private var isRunning = false // 실행중
private var isGameOver = false // 게임종료
private var gameThread: Thread? = null // 쓰레드
private var stage: Stage? = null // 스테이지
private var currentStage = stageNumber // 현재 스테이지
private var remainingBlocks = 0 // 남는 블록 수
private var onStageUpdatedListener: ((Int, Int) -> Unit)? = null
private var onStageClearedListener: (() -> Unit)? = null
private val textPaint = Paint().apply {
color = Color.WHITE // 텍스트 색상
textSize = 50f // 텍스트 크기
style = Paint.Style.FILL // 채우기 스타일
}
init {
holder.addCallback(this)
// 초기 벽돌 설정
setupBricks(currentStage)
}
// 벽돌 설정
private fun setupBricks(curStage: Int) {
bricks.clear() // 이전 스테이지의 벽돌 삭제
stage = Stage(curStage)
stage!!.setupBricks()
bricks.addAll(stage!!.bricks) // 새로 생성된 벽돌 추가
remainingBlocks = stage!!.getTotal()
}
fun nextStage() {
val nextStageNumber = (stage?.stageNumber ?: 1) + 1
if (nextStageNumber <= 3) {
setupBricks(nextStageNumber) // 다음 스테이지로 변경
startGame()
} else {
println("모든 스테이지 클리어!")
}
}
// 게임 진행
private fun gameLoop() {
while (isRunning) {
var canvas: Canvas? = null
try {
canvas = holder.lockCanvas()
if (canvas != null) {
checkCollisions() // 충돌 감지
ball.update() // 공의 위치 업데이트
canvas.drawColor(Color.BLACK) // 배경 색상 설정
// 현재 스테이지 및 남은 블록 수 그리기
val textXPosition = Constants.screenWidth / 2 // 중앙 정렬 X 좌표
val textYPosition = Constants.screenHeight * 2 / 3 // 화면 중앙 쪽 Y 좌표 (2/3 지점)
// 텍스트를 그릴 때, 가운데 정렬을 위해 텍스트의 너비를 고려하여 X 위치 조정
canvas.drawText(
"Stage: $currentStage",
textXPosition - textPaint.measureText("Stage: $currentStage") / 2,
textYPosition.toFloat(),
textPaint
)
canvas.drawText(
"Remaining Blocks: $remainingBlocks",
textXPosition - textPaint.measureText("Remaining Blocks: $remainingBlocks") / 2,
textYPosition + 60f,
textPaint
)
paddle.draw(canvas) // 패들 그리기
ball.draw(canvas) // 공 그리기
bricks.forEach { it.draw(canvas) } // 벽돌 그리기
}
} finally {
if (canvas != null) {
holder.unlockCanvasAndPost(canvas)
}
}
}
}
// 충돌 감지
private fun checkCollisions() {
// 1. 패들 충돌
if (ball.rect.intersect(paddle.rect)) {
val paddleWidth = paddle.rect.width()
val hitPosition = (ball.rect.centerX() - paddle.rect.left) / paddleWidth
// 패들 4등분
when {
hitPosition < 0.25 -> { // 1/4 왼쪽
ball.reverseY() // Y축 반전
ball.dx = -Math.abs(ball.speed) // 왼쪽으로 이동
}
hitPosition < 0.5 -> { // 2/4 중앙 왼쪽
ball.reverseY() // Y축 반전
ball.dx = -ball.speed * 0.5f // 왼쪽으로 이동 (속도 감소)
}
hitPosition < 0.75 -> { // 3/4 중앙 오른쪽
ball.reverseY() // Y축 반전
ball.dx = ball.speed * 0.5f // 오른쪽으로 이동 (속도 감소)
}
else -> { // 4/4 오른쪽
ball.reverseY() // Y축 반전
ball.dx = Math.abs(ball.speed) // 오른쪽으로 이동
}
}
// 공이 패들 위쪽에 고정되도록 위치 조정
ball.rect.bottom = paddle.rect.top // 패들의 위쪽과 일치하도록 설정
// 추가 위치 조정 (패들에서 약간 떨어뜨리기)
ball.rect.offset(0f, -ball.radius) // 공을 패들에서 약간 위로 이동
return // 패들 충돌 처리 후 종료
}
// 2. 벽(좌,우) 충돌
else if (ball.rect.left <= 0 || ball.rect.right >= Constants.screenWidth) {
ball.reverseX() // X축 반전
}
// 3. 벽(위) 충돌
else if (ball.rect.top <= 0) {
ball.reverseY() // Y축 반전
}
// 4. 벽(아래) 충돌
else if (ball.rect.bottom >= Constants.screenHeight) {
gameOver()
} else {
// 5. 벽돌 충돌
val iterator = bricks.iterator()
while (iterator.hasNext()) {
val brick = iterator.next()
if (ball.rect.intersect(brick.rect)) {
ball.reverseY() // 공의 Y축 방향 반전
iterator.remove() // 벽돌 제거
break
}
}
}
remainingBlocks = bricks.size // 현재 남아 있는 블록 수
// 남은 블록 수가 0이 되면 스테이지 클리어 이벤트 호출
if (remainingBlocks == 0) {
onStageClearedListener?.invoke() // 스테이지 클리어 이벤트 호출
nextStage()
}
updateStageInfo(currentStage, remainingBlocks)
}
// 게임 오버 처리
private fun gameOver() {
// 게임 오버 플래그 설정
isGameOver = true
isRunning = false
// UI 스레드에서 게임 오버 버튼 표시
(context as Activity).runOnUiThread {
// 버튼 생성
val restartButton = Button(context).apply {
text = "재시작"
setOnClickListener {
resetGame() // 재시작 메서드 호출
visibility = View.GONE // 버튼 숨기기
startGame()
}
}
// FrameLayout을 사용하여 버튼을 중앙에 위치시키기
val layout = FrameLayout(context).apply {
addView(restartButton)
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
}
// 버튼의 레이아웃 파라미터 설정
restartButton.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.CENTER // 버튼 위치 중앙으로 설정
}
// 레이아웃에 버튼 추가 (여기서는 ViewGroup에 추가해야 함)
(context as Activity).addContentView(
layout,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
}
fun startGame() {
isRunning = true
gameThread = Thread {
gameLoop()
}
gameThread?.start()
}
private fun resetGame() {
// 게임 재시작 시 초기 상태로 되돌림
isGameOver = false
isRunning = true
// 공과 패들 위치 초기화
ball.resetBall()
paddle.resetPaddle()
this.invalidate() // 화면을 다시 그리기
}
fun setOnStageUpdatedListener(listener: (Int, Int) -> Unit) {
onStageUpdatedListener = listener
}
fun setOnStageClearedListener(listener: () -> Unit) {
onStageClearedListener = listener
}
fun updateStageInfo(stage: Int, blocks: Int) {
currentStage = stage
remainingBlocks = blocks
onStageUpdatedListener?.invoke(currentStage, remainingBlocks) // UI에 업데이트 알림
}
// 패들 움직이기
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (!isGameOver) { // 게임이 오버되지 않은 경우에만 패들 이동
event?.let {
paddle.moveTo(it.x) // 터치 이벤트로 패들 이동
}
}
return true
}
// 시작
override fun surfaceCreated(holder: SurfaceHolder) {
isRunning = true
startGame()
}
// 변경
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
// 끝
override fun surfaceDestroyed(holder: SurfaceHolder) {
isRunning = false
}
}
- 메인뷰로 실제 게임에서 동작하는 기능들을 구현한 소스입니다.
- Thread를 통해 게임을 생성하고, 재시작 버튼으로 Thread 를 재생성합니다.
- 패들, 벽돌, 화면에 충돌을 감지하고, 특히 패들에 부딪힐 경우 4 등분하여 속도와 방향을 조절합니다.
앞서 말씀드린 대로 기본적인 기능만 구현이 된 상태입니다. 추후에는 아이템이나 벽돌의 종류를 바꾸는 등의 업데이트를 진행할 예정입니다.
감사합니다.
최근에 올라온 글
- Total
- Today
- Yesterday