手把手带你实现一个Android抽卡集图鉴功能

抽卡游戏设计

首先介绍游戏规则。集图鉴,每个图鉴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()
    }
}

使用SoundPoolMediaPlayer更适合播放音效,因为音效时长一般比较短。这个控件比较简单,主要使用的是属性动画的知识。

写在最后

最后来支持一下朵拉音乐, github.com/dora4/DoraM...

相关推荐
海雅达手持终端PDA1 小时前
海雅达Model 10X—高通6490工业三防平板,生产制造仓储管理应用
android·物联网·能源·制造·信息与通信·交通物流·平板
liu_sir_2 小时前
安卓设置界面-关于手机修改为关于设备
android·大数据·elasticsearch
new_bie_B2 小时前
Android16 应用安装流程源码分析
android
帅次2 小时前
LazyColumn 懒加载、items 与 key
android·flutter·kotlin·android studio·webview
zhangphil2 小时前
Android显示系统RenderThread绘制HARDWARE/普通格式Bitmap与GPU与CPU处理机制
android
美狐美颜SDK开放平台2 小时前
什么是美颜SDK?高并发场景下的企业级美颜SDK如何开发?
android·人工智能·ios·美颜sdk·第三方美颜sdk·视频美颜sdk
YF02113 小时前
Protobuf与 gRPC 的关系:从理论到 Android + Go 实战通信全解析
android·后端·grpc
YF02113 小时前
Android 卡顿性能优化专项治理:从 ANR 根源到系统性重构实践
android·app
蒙奇·D·路飞-3 小时前
Kotlin安卓app版本自动升级设计实现
android