抽卡游戏设计
首先介绍游戏规则。集图鉴,每个图鉴54张碎卡,以6x9网格排列,类似于拼图或欢乐斗地主😂。按一定的稀有度抽卡,集齐全部54张卡片则解锁这张图片,以后可用于更换音乐播放器背景图。当用户集图鉴快饱和的时候,可以很方便扩展新图鉴。
稀有度
kt
enum class CardRarity(
val probability: Double,
val displayName: String
) {
L(0.001, "传说"),
U(0.003, "极品"),
SSR(0.004, "超超稀有"),
SR(0.006, "超级稀有"),
R(0.008, "稀有"),
P(0.012, "完美"),
B(0.015, "精品"),
E(0.018, "卓越"),
F(0.020, "精美"),
N1(0.022, "一级普通"),
N2(0.024, "二级普通"),
N3(0.025, "三级普通"),
N4(0.028, "四级普通"),
N5(0.032, "五级普通"),
N6(0.035, "六级普通")
}
表示我没有抄袭《游戏王》。
大致效果


不要怀疑,我就是开挂。不过为了游戏乐趣,请不要把自己编译的破解包给普通用户。
积分获取与消耗
首先我们得有积分才能开始游戏,要求记录积分来源和所有积分明细。
kt
package site.doramusic.app.score
enum class PointsSource(@JvmField val desc: String) {
/** 听歌时间(根据计时) */
LISTEN_MUSIC("听歌时长积分"),
/** 每日登录奖励 */
DAILY_LOGIN("每日登录奖励"),
/** 完成任务 */
TASK("任务奖励"),
/** 活动/运营事件 */
EVENT("活动奖励"),
/** 抽奖(一般是负分) */
GACHA("抽奖消耗"),
/** 兑换道具(一般是负分) */
EXCHANGE("兑换消耗");
}
然后写一个积分管理器,所有地方都通过它来操作积分。
kt
package site.doramusic.app.score
import dora.db.dao.DaoFactory
import dora.db.dao.OrmDao
object PointsManager {
private var pointDao: OrmDao<UserPoints> = DaoFactory.getDao(UserPoints::class.java)
private var recordDao: OrmDao<PointsRecord> = DaoFactory.getDao(PointsRecord::class.java)
@JvmOverloads
fun addPoints(type: String, points: Int, extra: String? = null) {
// 插入记录表
val record = PointsRecord(
type = type,
points = points,
extra = extra
)
recordDao.insert(record)
// 获取总积分记录,如果没有则插入一条初始值0
var userPoints = pointDao.selectOne()
if (userPoints == null) {
val initialPoints = UserPoints(totalPoints = 0)
pointDao.insert(initialPoints)
userPoints = initialPoints
}
// 更新总积分
val newPoints = userPoints.totalPoints + points
pointDao.update(userPoints.copy(totalPoints = newPoints))
}
fun getTotalPoints(): Int {
return pointDao.selectOne()?.totalPoints ?: 0
}
fun getRecords(): List<PointsRecord> {
return recordDao.selectAll()
}
}
我们设计为每听歌1分钟奖励10积分。那么在哪里调用呢?
kt
package site.doramusic.app.util;
import android.os.Handler;
import android.os.Message;
import java.util.Timer;
import java.util.TimerTask;
import site.doramusic.app.score.PointsManager;
import site.doramusic.app.score.PointsSource;
/**
* 音乐播放定时器。
* 功能:
* 1. 定时刷新播放进度
* 2. 统计有效播放时长
* 3. 每分钟自动奖励积分
*/
public class MusicTimer {
/**
* 刷新进度条事件。
*/
public static final int REFRESH_PROGRESS_EVENT = 0x100;
/**
* 定时器刷新间隔(0.5秒)。
*/
private static final int INTERVAL_TIME = 500;
/**
* 每多少毫秒奖励一次积分(1分钟)。
*/
private static final long REWARD_INTERVAL = 60_000L;
/**
* 每次奖励积分数量。
*/
private static final int REWARD_POINTS = 10;
private final Handler[] mHandler;
private final Timer mTimer;
private TimerTask mTimerTask;
private final int what;
/**
* 是否已启动。
*/
private boolean mTimerStart = false;
/**
* 已累计播放时长。
*/
private long playDuration = 0L;
/**
* 是否正在播放。
*/
private boolean isPlaying = false;
public MusicTimer(Handler... handler) {
this.mHandler = handler;
this.what = REFRESH_PROGRESS_EVENT;
this.mTimer = new Timer();
}
/**
* 启动定时器。
*/
public void startTimer() {
if (mHandler == null || mTimerStart) {
return;
}
mTimerTask = new MusicTimerTask();
mTimer.schedule(mTimerTask, INTERVAL_TIME, INTERVAL_TIME);
mTimerStart = true;
}
/**
* 停止定时器。
*/
public void stopTimer() {
if (!mTimerStart) {
return;
}
mTimerStart = false;
if (mTimerTask != null) {
mTimerTask.cancel();
mTimerTask = null;
}
}
/**
* 获取累计播放时长(毫秒)。
*/
public long getPlayDuration() {
return playDuration;
}
/**
* 重置播放时长。
*/
public void resetPlayDuration() {
playDuration = 0L;
}
class MusicTimerTask extends TimerTask {
@Override
public void run() {
playDuration += INTERVAL_TIME;
// 每分钟奖励一次积分
if (playDuration >= REWARD_INTERVAL) {
playDuration = 0L;
PointsManager.INSTANCE.addPoints(
PointsSource.LISTEN_MUSIC.desc,
REWARD_POINTS
);
}
// 刷新UI
if (mHandler != null) {
for (Handler handler : mHandler) {
Message msg = handler.obtainMessage(what);
msg.sendToTarget();
}
}
}
}
}
通过研究朵拉音乐的源码发现,在播放刷新播放进度时有一个很好的切入点用于奖励积分。消耗积分也是调用积分管理器的addPoints,只不过是给一个负数。
kt
PointsManager.addPoints(PointsSource.GACHA.desc, -100)
积分管理器会自动将所有积分获取和消耗的明细,保存在积分记录表。
抽卡逻辑
kt
package site.doramusic.app.score
import dora.db.constraint.Id
import dora.db.migration.OrmMigration
import dora.db.table.Column
import dora.db.table.OrmTable
data class GalleryCard(
@Id
val id: Long = OrmTable.ID_UNASSIGNED,
@Column("number")
val number: Int = 0,
@Column("gallery_id")
val galleryId: String = "",
@Column("is_drawn")
var isDrawn: Boolean = false,
@Column("probability")
val probability: Double = 0.0,
override val isUpgradeRecreated: Boolean = false,
override val migrations: Array<OrmMigration>? = arrayOf()
) : OrmTable
class Gallery(val id: String, private val cards: List<GalleryCard>) {
/**
* 正常抽卡:抽出的卡标记为已抽出。
*/
fun drawCard(): GalleryCard? {
val availableCards = cards.filter { !it.isDrawn }
if (availableCards.isEmpty()) return null
val totalProb = availableCards.sumOf { it.probability }
val rand = Math.random() * totalProb
var cumulative = 0.0
for (card in availableCards) {
cumulative += card.probability
if (rand <= cumulative) {
card.isDrawn = true
return card
}
}
return null
}
/**
* 独立概率抽卡:抽出来的卡不改变原卡的概率和状态。
*/
fun drawCardIgnoreDrawn(): GalleryCard? {
if (cards.isEmpty()) return null
val totalProb = cards.sumOf { it.probability }
val rand = Math.random() * totalProb
var cumulative = 0.0
for (card in cards) {
cumulative += card.probability
if (rand <= cumulative) {
// 这里不修改 card.isDrawn
return card.copy() // 返回一个副本,不影响原始状态
}
}
return null
}
}
图鉴列表
kt
package site.doramusic.app.score
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.GridLayoutManager
import com.alibaba.android.arouter.facade.annotation.Route
import dora.BaseActivity
import dora.db.builder.WhereBuilder
import dora.db.dao.DaoFactory
import dora.util.StatusBarUtils
import site.doramusic.app.R
import site.doramusic.app.conf.ARoutePath
import site.doramusic.app.conf.AppConfig
import site.doramusic.app.databinding.ActivityGalleryListBinding
import site.doramusic.app.util.ThemeSelector
@Route(path = ARoutePath.ACTIVITY_GALLERY_LIST)
class GalleryListActivity : BaseActivity<ActivityGalleryListBinding>() {
private lateinit var adapter: CardPackAdapter
override fun getLayoutId(): Int = R.layout.activity_gallery_list
override fun onSetStatusBar() {
StatusBarUtils.setTransparencyStatusBar(this)
}
override fun initData(savedInstanceState: Bundle?, binding: ActivityGalleryListBinding) {
binding.statusbarGalleryList.layoutParams = LinearLayout
.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, StatusBarUtils.getStatusBarHeight())
ThemeSelector.applyViewTheme(binding.statusbarGalleryList)
ThemeSelector.applyViewTheme(binding.titlebarGalleryList)
binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
adapter = CardPackAdapter(getCardPacks()) { pack ->
val intent = Intent(this, DrawCardActivity::class.java)
intent.putExtra(DrawCardActivity.GALLERY_ID, pack.id)
intent.putExtra(DrawCardActivity.GALLERY_NAME, pack.name)
startActivityForResult(intent, AppConfig.REQUEST_CODE_DRAW_CARD)
}
binding.recyclerView.adapter = adapter
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == AppConfig.REQUEST_CODE_DRAW_CARD) {
// 抽卡返回后刷新每个卡牌包的已抽数量
adapter.setList(getCardPacks())
}
}
/** 获取卡包列表及已抽数量 */
private fun getCardPacks(): List<CardPack> {
// 新加的卡包排在最前面
val galleryIds = listOf(
AppConfig.GALLERY_RAIN_FOREST to "雨林",
// AppConfig.GALLERY_DESERT to "沙漠",
// AppConfig.GALLERY_CITY to "都市",
// AppConfig.GALLERY_COUNTRYSIDE to "田园",
// AppConfig.GALLERY_PLATEAU to "高原",
// AppConfig.GALLERY_BEACH to "海滩",
// AppConfig.GALLERY_GLACIER to "冰川",
// AppConfig.GALLERY_MOUNTAIN to "山地",
// AppConfig.GALLERY_UNDERSEA to "海底",
// AppConfig.GALLERY_HIGHWAY to "公路"
).reversed()
return galleryIds.map { (id, name) ->
CardPack(
id = id,
name = name,
drawableRes = when (id) {
AppConfig.GALLERY_RAIN_FOREST -> R.drawable.bg_rain_forest
// AppConfig.GALLERY_DESERT -> R.drawable.bg_desert
// AppConfig.GALLERY_CITY -> R.drawable.bg_city
// AppConfig.GALLERY_COUNTRYSIDE -> R.drawable.bg_countryside
// AppConfig.GALLERY_PLATEAU -> R.drawable.bg_plateau
// AppConfig.GALLERY_BEACH -> R.drawable.bg_beach
// AppConfig.GALLERY_GLACIER -> R.drawable.bg_glacier
// AppConfig.GALLERY_MOUNTAIN -> R.drawable.bg_mountain
// AppConfig.GALLERY_UNDERSEA -> R.drawable.bg_undersea
// AppConfig.GALLERY_HIGHWAY -> R.drawable.bg_highway
else -> R.drawable.bg_rain_forest
},
ownNum = DaoFactory.getDao(GalleryCard::class.java).count(
WhereBuilder.create().addWhereEqualTo("gallery_id", id)
.andWhereEqualTo("is_drawn", true)
).toInt()
)
}
}
}
将图鉴列表的数据反过来,就可以将新加的图鉴排在最前面了。以后只要更新了apk的版本,就能看到新图鉴,然后旧图鉴的数据也会保留。
抽卡界面
kt
package site.doramusic.app.score
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.GridLayoutManager
import com.alibaba.android.arouter.facade.annotation.Route
import dora.BaseActivity
import dora.db.builder.WhereBuilder
import dora.db.dao.DaoFactory
import dora.util.IntentUtils
import dora.util.SPUtils
import dora.util.StatusBarUtils
import site.doramusic.app.R
import site.doramusic.app.conf.ARoutePath
import site.doramusic.app.conf.AppConfig
import site.doramusic.app.databinding.ActivityDrawCardBinding
import site.doramusic.app.util.ThemeSelector
@Route(path = ARoutePath.ACTIVITY_DRAW_CARD)
class DrawCardActivity : BaseActivity<ActivityDrawCardBinding>() {
private lateinit var galleryId: String
private lateinit var galleryName: String
override fun getLayoutId(): Int = R.layout.activity_draw_card
override fun onGetExtras(action: String?, bundle: Bundle?, intent: Intent) {
super.onGetExtras(action, bundle, intent)
galleryId = IntentUtils.getStringExtra(intent, GALLERY_ID)
galleryName = IntentUtils.getStringExtra(intent, GALLERY_NAME)
}
override fun onSetStatusBar() {
StatusBarUtils.setTransparencyStatusBar(this)
}
override fun initData(savedInstanceState: Bundle?, binding: ActivityDrawCardBinding) {
binding.statusbarDrawCard.layoutParams = LinearLayout
.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, StatusBarUtils.getStatusBarHeight())
ThemeSelector.applyViewTheme(binding.statusbarDrawCard)
ThemeSelector.applyViewTheme(binding.titlebarDrawCard)
binding.tvGalleryName.text = "图鉴名称:$galleryName"
binding.tvMyPoints.text = "我的积分:${PointsManager.getTotalPoints()}"
val gallery = Gallery(
galleryId,
listOf(
// L(Legendary)传说
GalleryCard(number = 0, galleryId = galleryId, probability = CardRarity.L.probability),
// U(Ultimate)极品
GalleryCard(number = 1, galleryId = galleryId, probability = CardRarity.U.probability),
// SSR(Super Super Rare)超超稀有
GalleryCard(number = 2, galleryId = galleryId, probability = CardRarity.SSR.probability),
GalleryCard(number = 3, galleryId = galleryId, probability = CardRarity.SSR.probability),
GalleryCard(number = 4, galleryId = galleryId, probability = CardRarity.SSR.probability),
GalleryCard(number = 5, galleryId = galleryId, probability = CardRarity.SSR.probability),
// SR(Super Rare)超级稀有
GalleryCard(number = 6, galleryId = galleryId, probability = CardRarity.SR.probability),
GalleryCard(number = 7, galleryId = galleryId, probability = CardRarity.SR.probability),
GalleryCard(number = 8, galleryId = galleryId, probability = CardRarity.SR.probability),
GalleryCard(number = 9, galleryId = galleryId, probability = CardRarity.SR.probability),
// R(Rare) 稀有
GalleryCard(number = 10, galleryId = galleryId, probability = CardRarity.R.probability),
GalleryCard(number = 11, galleryId = galleryId, probability = CardRarity.R.probability),
GalleryCard(number = 12, galleryId = galleryId, probability = CardRarity.R.probability),
GalleryCard(number = 13, galleryId = galleryId, probability = CardRarity.R.probability),
// P(Perfect)完美
GalleryCard(number = 14, galleryId = galleryId, probability = CardRarity.P.probability),
GalleryCard(number = 15, galleryId = galleryId, probability = CardRarity.P.probability),
GalleryCard(number = 16, galleryId = galleryId, probability = CardRarity.P.probability),
GalleryCard(number = 17, galleryId = galleryId, probability = CardRarity.P.probability),
// B(Boutique)精品
GalleryCard(number = 18, galleryId = galleryId, probability = CardRarity.B.probability),
GalleryCard(number = 19, galleryId = galleryId, probability = CardRarity.B.probability),
GalleryCard(number = 20, galleryId = galleryId, probability = CardRarity.B.probability),
GalleryCard(number = 21, galleryId = galleryId, probability = CardRarity.B.probability),
// E(Exceptional)卓越
GalleryCard(number = 22, galleryId = galleryId, probability = CardRarity.E.probability),
GalleryCard(number = 23, galleryId = galleryId, probability = CardRarity.E.probability),
GalleryCard(number = 24, galleryId = galleryId, probability = CardRarity.E.probability),
GalleryCard(number = 25, galleryId = galleryId, probability = CardRarity.E.probability),
// F(Fine)精美
GalleryCard(number = 26, galleryId = galleryId, probability = CardRarity.F.probability),
GalleryCard(number = 27, galleryId = galleryId, probability = CardRarity.F.probability),
GalleryCard(number = 28, galleryId = galleryId, probability = CardRarity.F.probability),
GalleryCard(number = 29, galleryId = galleryId, probability = CardRarity.F.probability),
// N1(Normal) 一级普通
GalleryCard(number = 30, galleryId = galleryId, probability = CardRarity.N1.probability),
GalleryCard(number = 31, galleryId = galleryId, probability = CardRarity.N1.probability),
GalleryCard(number = 32, galleryId = galleryId, probability = CardRarity.N1.probability),
GalleryCard(number = 33, galleryId = galleryId, probability = CardRarity.N1.probability),
// N2(Normal) 二级普通
GalleryCard(number = 34, galleryId = galleryId, probability = CardRarity.N2.probability),
GalleryCard(number = 35, galleryId = galleryId, probability = CardRarity.N2.probability),
GalleryCard(number = 36, galleryId = galleryId, probability = CardRarity.N2.probability),
GalleryCard(number = 37, galleryId = galleryId, probability = CardRarity.N2.probability),
// N3(Normal) 三级普通
GalleryCard(number = 38, galleryId = galleryId, probability = CardRarity.N3.probability),
GalleryCard(number = 39, galleryId = galleryId, probability = CardRarity.N3.probability),
GalleryCard(number = 40, galleryId = galleryId, probability = CardRarity.N3.probability),
GalleryCard(number = 41, galleryId = galleryId, probability = CardRarity.N3.probability),
// N4(Normal) 四级普通
GalleryCard(number = 42, galleryId = galleryId, probability = CardRarity.N4.probability),
GalleryCard(number = 43, galleryId = galleryId, probability = CardRarity.N4.probability),
GalleryCard(number = 44, galleryId = galleryId, probability = CardRarity.N4.probability),
GalleryCard(number = 45, galleryId = galleryId, probability = CardRarity.N4.probability),
// N5(Normal) 五级普通
GalleryCard(number = 46, galleryId = galleryId, probability = CardRarity.N5.probability),
GalleryCard(number = 47, galleryId = galleryId, probability = CardRarity.N5.probability),
GalleryCard(number = 48, galleryId = galleryId, probability = CardRarity.N5.probability),
GalleryCard(number = 49, galleryId = galleryId, probability = CardRarity.N5.probability),
// N6(Normal) 六级普通
GalleryCard(number = 50, galleryId = galleryId, probability = CardRarity.N6.probability),
GalleryCard(number = 51, galleryId = galleryId, probability = CardRarity.N6.probability),
GalleryCard(number = 52, galleryId = galleryId, probability = CardRarity.N6.probability),
GalleryCard(number = 53, galleryId = galleryId, probability = CardRarity.N6.probability)
)
)
val galleryCards = (53 downTo 0).map { number ->
// 查询数据库,是否已经拥有
val ownCard = DaoFactory.getDao(GalleryCard::class.java)
.selectOne(
WhereBuilder.create()
.addWhereEqualTo("number", number)
.andWhereEqualTo("gallery_id", galleryId)
)
GalleryCard(
number = number,
galleryId = galleryId,
isDrawn = ownCard != null, // 如果数据库有,则标记已抽
probability = 0.0
)
}.toMutableList()
val adapter = GalleryCardAdapter(
cards = galleryCards,
getCardImage = { number -> getBackImage(galleryId, number) }, // 这里 getBackImage 返回正面资源
onCardDraw = { card, pokerView ->
}
)
binding.recyclerView.layoutManager = GridLayoutManager(this, 6)
binding.recyclerView.adapter = adapter
binding.btnDrawCard.setOnClickListener {
if (SPUtils.readBoolean(this@DrawCardActivity, galleryId)) {
showShortToast("太棒了,你的图鉴已集齐!")
return@setOnClickListener
}
val totalPoints = PointsManager.getTotalPoints()
// 先扣除积分
if (totalPoints >= 100) {
PointsManager.addPoints(PointsSource.GACHA.desc, -100)
binding.tvMyPoints.text = "我的积分:${PointsManager.getTotalPoints()}"
} else {
showShortToast("积分不足!")
return@setOnClickListener
}
val card = gallery.drawCardIgnoreDrawn() // 随机抽一张
if (card != null) {
card.isDrawn = true
val index = galleryCards.indexOfFirst { it.number == card.number }
if (index != -1) {
val holder = binding.recyclerView.findViewHolderForAdapterPosition(index)
if (holder is GalleryCardAdapter.CardViewHolder) {
val pokerView = holder.pokerView
val isDrawn = galleryCards[index].isDrawn
if (isDrawn) {
// 如果已经翻出过,先翻回去再翻出
pokerView.reset() // 重置到背面
pokerView.setBackImage(getBackImage(galleryId, card.number)) // 设置正面图片
pokerView.flipCard() // 翻出正面
} else {
// 直接翻出
pokerView.setBackImage(getBackImage(galleryId, card.number))
pokerView.reset()
pokerView.flipCard()
}
}
// 标记已抽
galleryCards[index].isDrawn = true
// 保存到数据库
val ownCard = DaoFactory.getDao(GalleryCard::class.java)
.selectOne(
WhereBuilder.create()
.addWhereEqualTo("number", card.number)
.andWhereEqualTo("gallery_id", galleryId)
)
if (ownCard == null) {
DaoFactory.getDao(GalleryCard::class.java).insert(card)
val num = DaoFactory.getDao(GalleryCard::class.java).count(
WhereBuilder.create()
.addWhereEqualTo("gallery_id", galleryId)
.andWhereEqualTo("is_drawn", true)
)
if (num >= 54 && !SPUtils.readBoolean(this@DrawCardActivity, galleryId)) {
SPUtils.writeBoolean(this@DrawCardActivity, galleryId, true)
showLongToast("恭喜,卡片已集齐!")
}
}
adapter.notifyItemChanged(index)
}
} else {
showShortToast("已有该卡了")
}
}
}
private fun getBackImage(galleryId: String, number: Int): Int {
fun prefixFor(galleryId: String): String = when (galleryId) {
AppConfig.GALLERY_RAIN_FOREST -> "rain_forest"
AppConfig.GALLERY_DESERT -> "desert"
AppConfig.GALLERY_CITY -> "city"
AppConfig.GALLERY_COUNTRYSIDE -> "countryside"
AppConfig.GALLERY_PLATEAU -> "plateau"
AppConfig.GALLERY_BEACH -> "beach"
AppConfig.GALLERY_GLACIER -> "glacier"
AppConfig.GALLERY_MOUNTAIN -> "mountain"
AppConfig.GALLERY_UNDERSEA -> "undersea"
AppConfig.GALLERY_HIGHWAY -> "highway"
else -> "unnamed"
}
val prefix = prefixFor(galleryId)
return R.drawable::class.java.getField("${prefix}_${54 - number}").getInt(null)
}
companion object {
const val GALLERY_ID = "gallery_id"
const val GALLERY_NAME = "gallery_name"
}
}
翻卡控件
先写一个适配器将卡片以6x9网格排列。
kt
package site.doramusic.app.score
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import dora.widget.DoraPokerView
import site.doramusic.app.R
class GalleryCardAdapter(
private val cards: MutableList<GalleryCard>,
private val getCardImage: (Int) -> Int, // 根据 card.number 获取正面图片
private val onCardDraw: (GalleryCard, DoraPokerView) -> Unit
) : RecyclerView.Adapter<GalleryCardAdapter.CardViewHolder>() {
// 记录已经翻开的卡片编号
private val flippedCards = mutableSetOf<Int>()
inner class CardViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val pokerView: DoraPokerView = itemView.findViewById(R.id.pokerView)
fun bind(card: GalleryCard) {
// 如果已翻开,则显示正面
if (card.isDrawn || flippedCards.contains(card.number)) {
pokerView.setFrontImage(getCardImage(card.number))
pokerView.setBackImage(getCardImage(card.number))
} else {
// 背面显示
pokerView.setFrontImage(getCardImage(card.number))
pokerView.setBackImage(R.drawable.card_back)
}
pokerView.reset()
pokerView.setOnClickListener {
// 翻回背面再翻出正面
pokerView.reset()
flippedCards.add(card.number)
onCardDraw(card, pokerView)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_gallery_card, parent, false)
return CardViewHolder(view)
}
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
holder.bind(cards[position])
}
override fun getItemCount(): Int = cards.size
/**
* 通过编号更新某张卡。
*/
fun updateCard(cardNumber: Int, updatedCard: GalleryCard) {
if (cardNumber in 0 until cards.size) {
cards[cardNumber] = updatedCard
notifyItemChanged(cardNumber)
}
}
/**
* 重置所有翻开的卡状态。
*/
fun resetFlipped() {
flippedCards.clear()
notifyDataSetChanged()
}
}
然后实现翻卡控件,开源项目 github.com/dora4/dview... 。
kt
package dora.widget
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import dora.widget.pokerview.R
class DoraPokerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var frontView: ImageView
private var backView: ImageView
private var isFront = true
// 声音
private val soundPool: SoundPool
private val flipSoundId: Int
init {
// 设置相机距离防止透视失真
cameraDistance = 8000 * resources.displayMetrics.density
// 创建两张 ImageView
frontView = AppCompatImageView(context)
backView = AppCompatImageView(context)
frontView.scaleType = ImageView.ScaleType.FIT_XY
backView.scaleType = ImageView.ScaleType.FIT_XY
backView.visibility = View.GONE
addView(frontView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
addView(backView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
// 从 XML 读取属性
attrs?.let {
val ta = context.obtainStyledAttributes(it, R.styleable.DoraPokerView)
if (ta.hasValue(R.styleable.DoraPokerView_dview_pv_frontSrc))
frontView.setImageResource(ta.getResourceId(R.styleable.DoraPokerView_dview_pv_frontSrc, 0))
if (ta.hasValue(R.styleable.DoraPokerView_dview_pv_backSrc))
backView.setImageResource(ta.getResourceId(R.styleable.DoraPokerView_dview_pv_backSrc, 0))
ta.recycle()
}
// 初始化音效
val audioAttrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
soundPool = SoundPool.Builder()
.setAudioAttributes(audioAttrs)
.setMaxStreams(2)
.build()
flipSoundId = soundPool.load(context, R.raw.flip, 1)
}
/** 翻牌动画 */
fun flipCard() {
val visible = if (isFront) frontView else backView
val hidden = if (isFront) backView else frontView
playFlipSound()
val animOut = ObjectAnimator.ofFloat(visible, "rotationY", 0f, 90f).apply {
duration = 200
}
val animIn = ObjectAnimator.ofFloat(hidden, "rotationY", -90f, 0f).apply {
duration = 200
}
animOut.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
visible.visibility = View.GONE
hidden.visibility = View.VISIBLE
animIn.start()
isFront = !isFront
}
})
animOut.start()
}
/** 点击翻牌:如果正面已显示,先翻回再翻出正面 */
fun flipCardWithReset() {
if (isFrontShowing()) {
flipCard()
postDelayed({
flipCard()
}, 250)
} else {
flipCard()
}
}
/** 设置正面图片 */
fun setFrontImage(resId: Int) {
frontView.setImageResource(resId)
}
/** 设置背面图片 */
fun setBackImage(resId: Int) {
backView.setImageResource(resId)
}
/** 当前是否正面显示 */
fun isFrontShowing(): Boolean = isFront
/**
* 重置到背面。
* @param animate 是否播放翻转动画,默认 false
*/
fun reset(animate: Boolean = false) {
if (!isFront) return // 已经是背面
if (animate) {
flipCard()
} else {
frontView.visibility = View.GONE
backView.visibility = View.VISIBLE
frontView.rotationY = 0f
backView.rotationY = 0f
isFront = false
}
}
private fun playFlipSound() {
soundPool.play(flipSoundId, 1f, 1f, 1, 0, 1f)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
soundPool.release()
}
}
使用SoundPool比MediaPlayer更适合播放音效,因为音效时长一般比较短。这个控件比较简单,主要使用的是属性动画的知识。
写在最后
最后来支持一下朵拉音乐, github.com/dora4/DoraM... 。