本文介绍如何使用Jetpack Compose打造一个经典版的俄罗斯方块游戏。

玩过上面这种游戏机的朋友应该会对本文内容感到亲切,废话不多说,先看东西:

1. 为什么Compose适合做游戏?


通常一个游戏程序的执行流程如下所示:

简单说就是一个不断等待输入、渲染界面的过程。

这种模型非常符合当下前端的开发思想:数据驱动UI。 因此基于各种前端框架的小游戏层出不穷。相比之下,用客户端开发同类应用成本则会高出不少。

如今有了Compose,客户端终于在开发范式上追上了前端的步伐,像前端那样开发小游戏成为可能。

2. 基于MVI的游戏架构


MVIModel-View-Intent,它受前端框架的启发,提倡一种单向数据流的设计思想,非常适合在Compose项目中实现逻辑部分,可以彻底贯彻数据驱动UI的核心思想。

之前的文章里曾对MVI架构做过简单介绍,后续也计划对MVI与MVVM等其他架构做一个对比介绍。本文只聚焦MVI在俄罗斯方块游戏中的具体使用。

项目结构如下:

  • View层:基于Compose打造,所有UI元素都由代码实现
  • Model层ViewModel维护State的变化,游戏逻辑交由reduce处理
  • V-M通信:通过StateFlow驱动Compose刷新,用户事件由Action分发至ViewModel

ViewModel的核心代码如下:

class GameViewModel : ViewModel() {private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState())//Provide State to observed by UI layerval viewState = _viewState.asStateFlow()//dispatch Action from UI layerfun dispatch(action: Action) {viewModelScope.launch {_viewState.value = reduce(viewState.value, action)}}//update viewState according to the Actionprivate fun reduce(state: ViewState, action: Action): ViewState =when(action) { //handleAction.Reset -> { //略...state.copy(...)}Action.Move -> {//略...state.copy(...)}//略...}
}

接下来我们看一下View层和Model层的具体实现。

3. View Layer:基于Compose实现


作为一个单页面的游戏没有页面跳转,界面由以下几部分构成:

  • GameBody:绘制按键、处理用户输入
  • GameScreen
    • BrickMatrix:绘制方块矩阵背景、下落中的方块
    • Scoreboard:显示游戏得分、时钟等信息

接下来重点介绍一下方块区域(BrickMatrix)以及游戏机身(GameBody)的绘制

3.1 方块绘制(BrickMatrix)

方块区域由12 * 24 的小方块组成的矩阵构成。为了模拟液晶屏的显示效果,需要分别绘制浅色的矩阵以及深色的砖块(下落中的以及底部的),所有元素均基于Compose的Canvas绘制。

关于Canvas的基本使用,我之前的文章中有介绍:https://blog.csdn.net/vitaviva/article/details/115257165

绘制背景矩阵

首先绘制每个砖块单元的图形,形状为正方形:

drawBrick:

Canvas中绘制图形需要借助DrawScope,为了便于使用,我们定义drawBrickDrawScope的扩展函数

private fun DrawScope.drawBrick(brickSize: Float,//每一个方块的sizeoffset: Offset,//在矩阵中的偏移位置color: Color//砖块颜色
) {//根据Offset计算实际位置val actualLocation = Offset(offset.x * brickSize,offset.y * brickSize)val outerSize = brickSize * 0.8fval outerOffset = (brickSize - outerSize) / 2//绘制外部矩形边框drawRect(color,topLeft = actualLocation + Offset(outerOffset, outerOffset),size = Size(outerSize, outerSize),style = Stroke(outerSize / 10))val innerSize = brickSize * 0.5fval innerOffset = (brickSize - innerSize) / 2//绘制内部矩形方块drawRect(color,actualLocation + Offset(innerOffset, innerOffset),size = Size(innerSize, innerSize))}

drawMatrix:

搞定砖块单元,绘制矩阵如下

private fun DrawScope.drawMatrix(brickSize: Float,matrix: Pair<Int, Int> //横向、纵向的数量: 12 * 24
) {(0 until matrix.first).forEach { x ->(0 until matrix.second).forEach { y ->//遍历调用drawBrickdrawBrick(brickSize,Offset(x.toFloat(), y.toFloat()),BrickMatrix)}}
}

绘制下落的砖块

一个个砖块单元根据摆放位置的不同,组成不同形状(Shape)的下落砖块。

用相对top-left的Offset定义每个方块的摆放位置,每种Shape无非是一组Offset的列表。

Shape:

我们如下定义所有Shape的常量:

val SpiritType = listOf(listOf(Offset(1, -1), Offset(1, 0), Offset(0, 0), Offset(0, 1)),//ZlistOf(Offset(0, -1), Offset(0, 0), Offset(1, 0), Offset(1, 1)),//SlistOf(Offset(0, -1), Offset(0, 0), Offset(0, 1), Offset(0, 2)),//IlistOf(Offset(0, 1), Offset(0, 0), Offset(0, -1), Offset(1, 0)),//TlistOf(Offset(1, 0), Offset(0, 0), Offset(1, -1), Offset(0, -1)),//OlistOf(Offset(0, -1), Offset(1, -1), Offset(1, 0), Offset(1, 1)),//LlistOf(Offset(1, -1), Offset(0, -1), Offset(0, 0), Offset(0, 1))//J
)

Spirit:

由Shape和Offset便可以决定下落砖块在Matrix中的具体位置。定义Spirit代表下落砖块:

data class Spirit(val shape: List<Offset> = emptyList(),val offset: Offset = Offset(0, 0),
) {val location: List<Offset> = shape.map { it + offset }
}

drawSpirit

最后调用drawBrick,绘制下落砖块

fun DrawScope.drawSpirit(spirit: Spirit, brickSize: Float, matrix: Pair<Int, Int>) {clipRect(0f, 0f,matrix.first * brickSize,matrix.second * brickSize) {spirit.location.forEach {drawBrick(brickSize,Offset(it.x, it.y),BrickSpirit)}}
}

3.2 游戏机身(GameBody)

GameBody的核心是按钮的绘制以及事件处理

绘制Button

button的绘制很简单, 通过RoundedCornerShape实现圆形、通过Modifier添加阴影增加立体感

GameButton:

@Composable
fun GameButton(modifier: Modifier = Modifier,size: Dp,content: @Composable (Modifier) -> Unit
) {val backgroundShape = RoundedCornerShape(size / 2)Box(modifier = modifier.shadow(5.dp, shape = backgroundShape).size(size = size).clip(backgroundShape).background(brush = Brush.verticalGradient(colors = listOf(Purple200,Purple500),startY = 0f,endY = 80f))) {content(Modifier.align(Alignment.Center))}
}

添加事件

当按下方向键不放时希望方块能持续移动。Modifier.clickable()只能设置单击事件,不满足使用需求,需要让button能处理连发事件。

Modifier.pointerIneropFilter:拦截MotionEvent:

通常需要通过处理MotionEvent实现类似需求,Compose中提供了处理MotionEvent的方法:

Modifier.pointerIneropFilter { //it:MotionEvent //可以获取当前MotionEventwhen(it.action) {ACTION_DOWN -> {...}...}
}

Modifier.indication:设置click背景色

拦截MotionEvent后,默认的button按下时背景色变化的逻辑失效。此时可以借助Modifier.indication进行弥补,indication允许我们根据当前按钮的交互状态改变显示状态:

Modifier.indication(interactionSource = interactionSource, //观察交互状态indication = rememberRipple() //设置Ripple风格的显示效果).pointerInteropFilter {when(it.action) {ACTION_DOWN -> {val interaction = PressInteraction.Press(Offset(50f, 50f))interactionSource.emit(interaction) //通知交互状态的改变、改变显示状态}...}
}

ReceiveChannel发送连发事件:

添加了Modifier.pointerIneropFilter和Modifier.indication的完整代码如下:

@Composable
fun GameButton(modifier: Modifier = Modifier,size: Dp,onClick: () -> Unit = {},content: @Composable (Modifier) -> Unit
) {val backgroundShape = RoundedCornerShape(size / 2)lateinit var ticker: ReceiveChannel<Unit> //定时器val coroutineScope = rememberCoroutineScope()val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }val interactionSource = MutableInteractionSource()Box(modifier = modifier.shadow(5.dp, shape = backgroundShape).size(size = size).clip(backgroundShape).background(brush = Brush.verticalGradient(colors = listOf(Purple200,Purple500),startY = 0f,endY = 80f)).indication(interactionSource = interactionSource, indication = rememberRipple()).pointerInteropFilter {when (it.action) {ACTION_DOWN -> {coroutineScope.launch {// Remove any old interactions if we didn't fire stop / cancel properlypressedInteraction.value?.let { oldValue ->val interaction = PressInteraction.Cancel(oldValue)interactionSource.emit(interaction)pressedInteraction.value = null}val interaction = PressInteraction.Press(Offset(50f, 50f))interactionSource.emit(interaction)pressedInteraction.value = interaction}ticker = ticker(initialDelayMillis = 300, delayMillis = 60)coroutineScope.launch {//ticker发送连发事件ticker.receiveAsFlow().collect { onClick() }}}//略...}true}) {content(Modifier.align(Alignment.Center))}
}

使用ticker()创建连发事件源的ReceiveChannel

组装Button、发送Action

最后在GameBody中对各Button进行布局,并在OnClick中向ViewModel发送Action。

例如,四个方向键的布局:

Box(modifier = Modifier.fillMaxHeight().weight(1f)
) {GameButton(Modifier.align(Alignment.TopCenter),onClick = { clickable.onMove(Direction.Up) },size = DirectionButtonSize) {ButtonText(it, stringResource(id = R.string.button_up))}GameButton(Modifier.align(Alignment.CenterStart),onClick = { clickable.onMove(Direction.Left) },size = DirectionButtonSize) {ButtonText(it, stringResource(id = R.string.button_left))}GameButton(Modifier.align(Alignment.CenterEnd),onClick = { clickable.onMove(Direction.Right) },size = DirectionButtonSize) {ButtonText(it, stringResource(id = R.string.button_right))}GameButton(Modifier.align(Alignment.BottomCenter),onClick = { clickable.onMove(Direction.Down) },size = DirectionButtonSize) {ButtonText(it, stringResource(id = R.string.button_down))}}

Clicable:分发事件

clickable负责事件分发:

data class Clickable constructor(val onMove: (Direction) -> Unit,//移动val onRotate: () -> Unit,//旋转val onRestart: () -> Unit,//开始、重置游戏val onPause: () -> Unit,//暂停、恢复游戏val onMute: () -> Unit//打开、关闭游戏音乐
)

3.3 订阅游戏状态(ViewState)

GameScreen订阅viewModel的数据实现UI的刷新。ViewState是唯一的数据源,遵循Single Source Of Truth的要求。

Compose中使用ViewModel

添加viewmodel-compose的支持,方便在Composable中访问ViewModle

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03"
@Composable
fun GameScreen(modifier: Modifier = Modifier) {val viewModel = viewModel<GameViewModel>() //获取ViewModelval viewState by viewModel.viewState.collectAsState() //订阅StateBox() {Canvas(modifier = Modifier.fillMaxSize()) {val brickSize = min(size.width / viewState.matrix.first,size.height / viewState.matrix.second)//仅负责绘制UI,没有任何逻辑处理drawMatrix(brickSize, viewState.matrix)drawBricks(viewState.bricks, brickSize, viewState.matrix)drawSpirit(viewState.spirit, brickSize, viewState.matrix)}//略...}}

瞧! 有了MVI的加持,Compose无需再染指任何逻辑,仅负责drawXXX即可,逻辑全部交由ViewModel处理。

接下来,我们移步ViewModel的实现。

4. Model Layer :基于ViewModel实现


MVI中的Model层一般负责数据请求以及State的更新。俄罗斯方块中没有数据请求场景,只处理本地state更新即可。

4.1 ViewState

遵循SSOT原则,所有影响UI刷新的数据都定义在ViewState

data class ViewState(val bricks: List<Brick> = emptyList(), //底部落地成盒的砖块val spirit: Spirit = Empty, // 下落中的砖块val spiritReserve: List<Spirit> = emptyList(), //后补t砖块(Next)val matrix: Pair<Int, Int> = MatrixWidth to MatrixHeight,//矩阵尺寸val gameStatus: GameStatus = GameStatus.Onboard,//游戏状态val score: Int = 0, //得分val line: Int = 0, //下了多少行val level: Int = 0,//当前级别(难度)val isMute: Boolean = false,//是否静音)
enum class GameStatus {Onboard, //游戏欢迎页Running, //游戏进行中LineClearing,// 消行动画中Paused,//游戏暂停ScreenClearing, //清屏动画中GameOver//游戏结束
}

如上,甚至连消行、清屏这类逻辑也统一由ViewModel负责,Compose无脑反应State即可。

4.2 Action

用户的输入通过Action通知到ViewModel,目前支持以下几种Action:

sealed class Action {data class Move(val direction: Direction) : Action() //点击方向键object Reset : Action() //点击startobject Pause : Action() //点击pauseobject Resume : Action() //点击resumeobject Rotate : Action() //点击rotateobject Drop : Action() //点击↑,直接掉落object GameTick : Action() //砖块下落通知object Mute : Action()//点击mute
}

4.3 reduce

ViewModel接收到Action后,分发到reduce、更新ViewState。

GameTicker:砖块下落Action

以最核心的GameTicker为例,其他所有Action都是用户触发的,唯有GameTicker是自动触发,用来保证砖块按一定速度下降。

基本流程如上图所示,根据Spirit在当前Matrix中的状态更新ViewStae:

  • 没有触达底部:

    • Spirit在y轴前进一步
  • 触达底部,但没有成功消行:
    • 更新Next Spirit
    • 更新下沉到底部的bricks(吸收Spirit的brick)
  • 成功消行:
    • 更新Next Spirit
    • 更新GameState到LineClearing
  • 屏幕溢出:
    • 更新GameState到GameOver
fun reduce(state: ViewState, action: Action) {when(action) {Action.GameTick -> run {// 没有触达底部,y轴偏移+1val spirit = state.spirit.moveBy(Direction.Down.toOffset())if (spirit.isValidInMatrix(state.bricks, state.matrix)) {emit(state.copy(spirit = spirit))}// GameOverif (!state.spirit.isValidInMatrix(state.bricks, state.matrix)) {//砖块超出屏幕上界,游戏结束}// 更新底部Bricks,// updateBricks: 底部Bricks的状态信息// clearedLine:消行信息val (updatedBricks, clearedLines) = updateBricks(state.bricks,state.spirit,matrix = state.matrix)//updatedBricks返回的底部Bricks的信息由三个List<Brick>组成val (noClear, clearing, cleared) = updatedBricksif (clearedLines != 0) {// 成功消行// 执行消行动画,见后文} else {//没有消行emit(newState.copy(bricks = noClear,spirit = state.spiritNext))}}//end of run}
}
  • isValidInMatrix()判断Spirit相对于Matrix是否已经出界,出界以为游戏结束。
  • Spirit触达底部时,updatedBricks负责更新底部Bricks数据,即将Spirit的bricks吸收添加到底部Bricks中。

Brick的定义很简单,就是在Matrix中的Offset

data class Brick(val location: Offset = Offset(0, 0))

updatedBricks返回三个List<Brick>,分别记录消行动画过程中Bricks的中间状态

  • noClear: 未消行的bricks
  • clearing: 消行中的bricks(相当于消除的空行设置为Invisiable
  • cleared: 消行后的bricks(相当于消除的空行设置为Gone
noClear clearing cleared

消行动画:

基于返回的List<Brick>,通过更新state,实现消行动画

launch {//animate the clearing linesrepeat(5) {emit(//间隔100ms,交替显示noClear/clearingstate.copy(gameStatus = GameStatus.LineClearing,spirit = Empty,bricks = if (it % 2 == 0) noClear else clearing)delay(100)}//delay emit new stateemit(//动画结束,bricks更新到clearedstate.copy(spirit = state.spiritNext,bricks = cleared,gameStatus = GameStatus.Running))
}

5. 活用@Preview


文章最后再聊一聊@Preview

由于AndroidStudio的XML预览功能很鸡肋,很多Android开发者都习惯于通过实机运行查看UI。Compose的@Preview的预览效果可以做到与真机无异,实现所见所即得开发。因此,建议为所有有调试需要的Composable配备@Preview,将大大提高你的UI开发效率。

由于@Preview不能接受业务参数,Composable的接口定义需要秉承对Preview友好的原则,尽量为其添加可预览的默认值

借助@Preview,我们可以方便地进行局部UI的预览,并且可以组合各个@Preview的Composalbe来预览全貌。UI开发如同装配车间那样实现流水化作业:

除了基本预览以外@Preview还提供了例如互动预览、实机预览等更多使用功能。此外,通过右击还可以将预览UI直接保存为.png。 本游戏的AppIcon就是通过这种方式创建生成的。

6. 最后


篇幅有限,本文只能以小见大地介绍游戏的基本实现过程,其他更多功能的实现欢迎查阅源码了解。

整个游戏中,包括动画在内的所有的UI刷新全是由State驱动完成的,借助于Compose Compiler强大的编译期优化,即使再频繁的Recomposition(重组)也没有任何性能问题,运行起来十分流畅。

用Compose做游戏都如此流畅,更何况普通的UI?在性能层面已无需担心,未来随着功能层面的不断完善,Compose的时代或许真的要来了,自Android诞生就存在的android.view.View体系也将迎来它的谢幕。。Game Over

项目地址

https://github.com/vitaviva/compose-tetris

用Jetpack Compose做一个俄罗斯方块游戏机相关推荐

  1. 安卓开发: Jetpack compose + kotlin 实现 俄罗斯方块游戏

    文章目录 前言 俄罗斯方块开发文档 1.摘要 2.开发工具选取 2.1.Compose 的自身优点 2.2.数据驱动界面 3.设计需求 3.1.功能需求 3.1.1.基本游戏功能 3.1.2.拓展功能 ...

  2. 羡慕大劳星空顶?不如跟我一起使用 Jetpack compose 绘制一个星空背景(带流星动画)

    前言 背景 作为一个自诩的电影爱好者,经常会在半夜看电影,看完后就会顺道去豆瓣标记一下看过,再看看别人对这个电影的理解. 某日深夜,看完电影后,顺手打开了豆瓣的 书影音记录 这个功能,起初并没有注意到 ...

  3. 跟我一起使用 compose 做一个跨平台的黑白棋游戏(1)整体实现思路

    前言 为什么写这系列文章 虽然 compose 正式版已经出来很久了,也有很多大佬写了很多教程文章和实例 demo ,但是对于 compose 其实我也还是一知半解的. 特别是对于 compose 的 ...

  4. 使用 Jetpack Compose 实现一个计算器APP

    前言 在上一篇文章中,我们说到打算使用 compose 实现一个计算器 APP,最开始打算做一个经典的 LCD 基础计算器,后来觉得好像没啥特色,最终决定还是改成仿微软计算器. 不过,微软计算器的功能 ...

  5. 跟我一起使用 compose 做一个跨平台的黑白棋游戏(4)移植到compose-jb实现跨平台

    前言 在上一篇文章中,我们已经实现了游戏的所有界面和逻辑代码,并且在 Android 上已经可以正常运行. 这篇文章我们将讲解如何将其从使用 jetpack compose 修改为使用 compose ...

  6. 跟我一起使用 compose 做一个跨平台的黑白棋游戏(2)界面布局

    前言 在上一篇文章中,我们讲解了实现这个游戏的总体思路,这篇文章我们将讲解如何实现游戏界面. 本文将涉及到 compose 的自定义绘制与触摸处理,这些内容都可以在我往期的文章中找到对应的教程,如果对 ...

  7. javascript+html做一个俄罗斯方块的小游戏

    本来写了上篇的,可是写得不太好,而且方块的左右移动那里还有bug,索性把上篇删了,只用一篇来说. 效果图 不要用IE浏览器打开,不然会变成...... <!DOCTYPE html> &l ...

  8. 学不动了,尝试用Android Jetpack Compose重写微信经典飞机大战游戏

    前段时间看了TechMerger大佬写的<一气呵成:用Compose完美复刻Flappy Bird!>,甚是有趣,按耐不住那躁动的心,笔者决定跟随大佬的脚步通过写游戏的方式学习Jetpac ...

  9. Android 我的第一个Jetpack Compose应用

    目录 1. 背景 2. Jetpack Compose 是什么? 3. 开始前的准备 4. 创建DEMO 4.1 New Project 4.2 项目配置 5. 项目整体变化 5.1 布局部分 5.2 ...

最新文章

  1. 类型的设计--类型和成员基础(一)
  2. PyMySQL 的decode坑
  3. 多路I/O转接服务器——epoll
  4. 增加mysql的最大连接数
  5. Springboot配置不当
  6. jquery 样式获取设置值_[JQuery] jQuery选择器ID、CLASS、标签获取对象值、属性、设置css样式...
  7. 【c语言】指针数组和数组指针-解释和用法
  8. mysql workbench查询快捷_mysql workbench快捷键
  9. Linux find命令详解 【转】
  10. 老毛桃安装WIN7原版系统
  11. Windows下给WSL子系统(Kali)换源,使用binwalk,outguess等工具
  12. nginx 四种策略
  13. 下载编译LineageOS代码
  14. GB28181协议之录像回放
  15. wps表格l制作甘特图_如何在表格中制作甘特图(横道图)?
  16. linux wav 转mp3,linux下wav转换为mp3
  17. dx逆向建模步骤_产品温度的逆向建模的系统和方法与流程
  18. virtualxposed使用教程_VirtualXposed框架
  19. 怎样做风险评估?风险评估有哪些具体实施流程?
  20. java笔试多么_世界多么精彩!

热门文章

  1. 5天学会HTML及HTML5
  2. 人工智能,深度学习,机器学习平台,界面UI
  3. 品甜蜜“醇时代”,享幸福“鑫品质”豫鑫糖醇盛装亮相2023生物发酵展
  4. unity2D:音效Audio音效管理SoundManager
  5. Error-prone
  6. 2022树莓派安装网心云docker镜像
  7. Python项目实战抓取-某漫画app逆向
  8. App逆向 | 某漫画app签名加密逻辑分析
  9. 无法访问此网站无法找到DNS地址DNS_PROBE_STARTED怎么办?
  10. 社区新零售另一个风口:社区拼团震撼来袭