【插件】轻量级响应式本地数据库插件使用分享

前言

贴主是做前端开发的,学习cocos之后发现开发方式比较传统,特别是绘制UI的时候。想把数据渲染成对应的界面,需要繁琐的代码去实现。然而我就在想:为啥不能像前端开发一样,用响应式的方式去实现UI呢?

于是呼,我想到了前端开发中用到的全局store。起初想到了 ReduxMobx 等状态管理库。然而,state是硬编码在脚本里的。对于游戏来说,一个关卡可能涉及很多state。且这些state的默认值可能会被频繁修改调试。这样一来,用传统状态管理库就不是很顺手了。我们通常可能是用excel来管理这些初始值,但这样又失去了响应式的特性。

那么,业内有没有既能满足响应式,又能满足方便管理的解决方案呢?答案是肯定的。于是乎,我找到了 Tinybase 这款数据库。下面我将简单介绍一下如何使用。

Tinybase应用

首先需要安装依赖,Tinybase 采用TS语言编写,所以在 creator 中使用非常友好。

安装

关于如何在 creator 中引用外部依赖库,想必大家都知道,这里就不赘述了。使用一下命令安装即可。

npm install tinybase

使用KV

import { createMergeableStore } from 'tinybase/mergeable-store';

@ccclass('TestStore')
export class TestStore extends Component {
    async start() {
        // 创建数据库实例
        const store = createMergeableStore();
        // 向数据库中添加KV
        store.setValue('playerName', '小明');
        store.setValue('playerLevel', 1);
        store.setValue('playerHP', 100);
        // 为KV添加监听器,当 playerName 值改变时,会自动运行回调函数
        const listenerId = store.addValueListener('playerName', (store, valueId, newValue, oldValue) => {
            console.log('playerName 改变:', [oldValue, newValue]);
            find('Canvas/UILayer/NameLabel').getComponent(Label).string = newValue as string;
        })
        // 手动执行一次监听器,为UI初始化数据
        store.callListener(listenerId);
        // ...
    }
}

这样以来,如果玩家修改了 playerName ,我们只需要一行代码即可实现。

// ...
// 通过 setValue 改变值,会自动调用对应的监听器,进而重绘UI
store.setValue('playerName', '小李');
store.setValue('playerLevel', 2);
store.setValue('playerHP', 90);
// ...

上面同时修改三个值的写法很不优雅,所以也可以改一下。

// ...
store.setValues({ playerName: '小李',  playerLevel: 2, playerHP: 90});
// ...

假如我们需要增加一个 playerIsLive 的KV来标识玩家是否存活。我们可以这样写。

// ...
import { createMergeableStore } from 'tinybase/mergeable-store';

@ccclass('TestStore')
export class TestStore extends Component {
    async start() {
        //...
        // 初始状态下,playerHP是100,所以playerIsLive我们设为true
        store.setValue('playerIsLive', true);
        // 在playerHP监听器中去修改playerIsLive的值
        const listenerId = store.addValueListener('playerHP', (store, valueId, newValue, oldValue) => {
            console.log('playerHP 改变:', [oldValue, newValue]);
            if (newValue <= 0) {
                store.setValue('playerIsLive', false);
            } else {
                store.setValue('playerIsLive', true);
            }
            find('Canvas/UILayer/HPLabel').getComponent(Label).string = newValue as string;
        })
    }
}
// ...

虽然我们简单实现了想要的效果,但是实现起来貌似也不简单。下面就带大家一起来简化。

使用表

既然是数据库,当然离不开表啦:blush:

import { createMergeableStore } from 'tinybase/mergeable-store';

@ccclass('TestStore')
export class TestStore extends Component {
    async start() {
        // 创建数据库实例
        const store = createMergeableStore();
        // 向数据库中添加 player 表, player01对应行id,name、level等对应列id
        store.setTable('player',  {
            player01: {name: '小明', level: 1, HP: 100, isLive: true}
        });
        // 为player表的player01行添加监听器,当 player01 行的值改变时,会自动运行回调函数
        const listenerId = store.addRowListener(
            'player',
            'player01',
            (store, tableId, rowId, getCellChange) => {
                console.log('player01改变');
                if (getCellChange(tableId, rowId, 'name')[0]) {
                    find('Canvas/UILayer/NameLabel').getComponent(Label).string = getCellChange(tableId, rowId, 'name')[2] as string;
                }
                const hpChange = getCellChange(tableId, rowId, 'playerHP');
                if (hpChange[0] && hpChange[2] <= 0) {
                    store.setCell(tableId, rowId, 'isLive', false);
                } else if ((hpChange[0] && hpChange[2] > 0) {
                    store.setCell(tableId, rowId, 'isLive', true);
                }
            },
            true
        );
        // 手动执行一次监听器,为UI初始化数据
        store.callListener(listenerId);
        // ...
    }
}

使用插件

插件地址:Cocos Store

虽然使用 Tinybase 可以解决响应式问题,但初始数据全部都写在代码里,管理起来还是不方便。
为此,我写了一个插件,来辅助开发者管理数据。

插件提供了用户界面,可方便的增/删/改/查数据。且修改结果会立即生效,无需反复修改脚本代码重新编译。

演示效果


结语

谢谢大家能够看到这里,希望贴主的分享能够给大家带来一些启发。关于 Tinybase 更多高级用法,可以自行阅读官方文档,文档写得十分详细,API设计得也很明了。

6赞

好!!!!!

1赞

免费的,类型安全,类 EventTarget 接口,可监听任何数据,包括临时变量

4赞

相当于自己实现了响应式的数据监听咯,但是功能略显简单了。例如游戏中有很多关卡、怪物、武器等对象。这些数据都需要提前设计好,而且还需要频繁修改。我看一般都用excel编辑,然后转成json放到项目中加载。这套流程我觉得繁琐了,所以自己弄了个插件。加上Tinybase的设计理念来源于数据库,对复杂数据的查询、索引、回滚、持久化等功能都有涉及。面对大型项目也能满足要求,你这个简单的倒是可以在小项目中用用。

从这些接口设计来看,貌似还是在indexdb上的封装。

indexdb最大的特点就是异步。如果单独用来增删查改。这是个很好地特性。但是跟游戏逻辑结合在一起,那么将会是灾难。你无法保证数据的可靠性。因为你刷新了数据,不是在当前帧改变的。那么设计到当前帧的逻辑操作都会出现大问题。

我的总结就是只能当做玩具玩一下,并不能当成有用的工具。

简单看了一下源码,实际上并不是用indexdb做的。存储方式是用的js对象,也就是放内存里的,跟你自己定义一个对象是一样的。


关于持久化这块,Tinybase支持多种持久化方式。可以看一下他们持久化相关的文档。

所以不存在异步的情况,用户退出游戏前持久一下。等下次进入游戏再加载数据,加载完了数据就是js对象了,后面对数据的操作都是对js对象的操作。

我的脚本监听的是对象,并不需要查询,索引,写入的概念。反而你的方式更麻烦

而我写的只需要修改对象值即可,至于查询? 代码编辑器本身就有对象的类型提示,所以这是极为方便的

至于回滚和持久化,这只不过在初始化和值修改回调内加个本地存储即可

所以我并不认为你的方案有任何优势,当然如果麻烦是一种的话那就算,另外它可以用在任何地方。不局限于大小项目。如有异议,请举例代码

支持一下,粗略看一下不是太方便的样子?而且类型提示应该是没有的吧,用了TS而没有类型还是挺不能接受的,期待继续优化

那就举一个简单的例子。
假如你游戏里有十种怪物,每种怪物有:攻击力、防御力、生命值、武器、装备等属性。
那你的数据结构大致如下:

传统方式

// 怪物列表
const monsters = {
    '怪1': {
        {
            name: '怪1',
            power: 100,
            defense: 100,
            hp: 100
        },
    },
    '怪2': {
            name: '怪2',
            power: 110,
            defense: 110,
            hp: 200
        },
    // ...
}
]
// 武器列表
const arms = {
    '武器1': {
        name: '武器1',
        power: 10,
        effect: '击退敌人'
        desc: '斧头'
    },
    '武器2': {
        name: '武器2',
        power: 7,
        effect: '出血'
        desc: '剑'
    },
}

那么需求是:第一关怪1拿武器1,第二关怪2拿武器1且两只,第三关怪1拿武器2、怪2拿武器2各两只。

const mapLevel = {
  '关卡1':  {
       monsters: [
            {
                  monster: '怪1',
                  arms: '武器1'
            },
       ]
   },
   '关卡2':  {
       monsters: [
            {
                  monster: '怪2',
                  arms: '武器1'
            },
            {
                  monster: '怪2',
                  arms: '武器1'
            },
       ]
   },
   '关卡3':  {
       monsters: [
            {
                  monster: '怪1',
                  arms: '武器2'
            },
            {
                  monster: '怪1',
                  arms: '武器2'
            },
             {
                  monster: '怪2',
                  arms: '武器2'
            },
            {
                  monster: '怪2',
                  arms: '武器2'
            },
       ]
   }
}

这里写的伪代码,假设mapLevel是响应式的,并且怪物、武器对应的预制体也都做好了。

@ccclass('GameManager')
export class GameManager extends Component {
    public level = '关卡1';
    start() {
        this.init()
    }
    init() {
        mapLevel[this.level].monsters.map(monster => {
            // 具体Monster、Arms类里面会用传进去的参数来初始化,去查对应的怪物、武器数值。
            // Monster、Arms内部根据传入的数据对象来绑定数据,实现响应式。
            const monsterObj = new Monster(monster.monster);
            const armsObj = new Arms(monster.arms);
            monsterObj.setArms(armsObj);
            return monsterObj;
        })
    }
}

假设某个怪物被攻击了,血量减少50,代码实现如下。

// 这里大致写一下伪代码,具体需要根据上下文环境去处理。
// 假设harm方法里面实现了减少hp的代码,并实现了数据绑定。
mapLevel[this.level].monsters[0].harm(50);

再假随着游戏时长增加,每过一分钟,所有怪物攻击力实时增加1点。

// 遍历monsters,并且自增power属性
Object.keys(monsters).map(key => {
    monsters[key].power += 1;
})
// 为monster对象设置新的power值
mapLevel[this.level].monsters.map(monster => {
    const power = monsters[monster.getName()].power;
    monster.setPower(power);
})

下面来看一下数据怎么做

数据库方式

// 初始化数据库
store
.setTable('monsters', {
    '怪1': { name: '怪1', power: 100, defense: 100, hp: 100 },
    '怪2': { name: '怪2', power: 110, defense: 110, hp: 200 },
})
.setTable('arms', {
    '武器1': { name: '武器1', power: 10, effect: '击退敌人', desc: '斧头' },
    '武器2': { name: '武器2', power: 7, effect: '出血', desc: '剑' },
})
.setTable('mapLevel', {
    '1': { monsterId: '怪1', armsId: '武器1', inLevel: '关卡1'},
    '2': { monsterId: '怪2', armsId: '武器1', inLevel: '关卡2'},
    '3': { monsterId: '怪2', armsId: '武器1', inLevel: '关卡2'},
    '4': { monsterId: '怪1', armsId: '武器2', inLevel: '关卡3'},
    '5': { monsterId: '怪1', armsId: '武器2', inLevel: '关卡3'},
    '6': { monsterId: '怪2', armsId: '武器2', inLevel: '关卡3'},
    '7': { monsterId: '怪2', armsId: '武器2', inLevel: '关卡3'},
})
// 创建并定义查询
const queries = createQueries(store);
queries.setQueryDefinition('关卡1', 'mapLevel', ({where, select, join}) => {
    select('arms', 'name').as('armsName');
    select('arms', 'power').as('armsPower');
    where('inLevel', '关卡1');
    join('monsters', 'monsterId');
    join('arms', 'armsId');
});

// 遍历查询结果并建立一个零时表,方便后面数据操作
queries.forEachResultRow('关卡1', (rowId) => {
    store.addRow('mapTemp', {[rowId]: queries.getResultRow('关卡1', rowId)})
});

// 给mapTemp表绑定数据
const listenerId = queries.addTableListener(
    'mapTemp',
    (queries, tableId, getCellChange) => {
        // 实现数据绑定
    },
);

// 为查询结果绑定数据
const listenerId = queries.addResultTableListener(
    '关卡1',
    (queries, tableId, getCellChange) => {
        // 当查询结果改变时,更新mapTemp表
        // ...
    },
);

假设某个怪物被攻击了,血量减少50,代码实现如下。

const hp = store.getCell('mapTemp', '1', 'hp');
store.setCell('mapTemp', '1', 'hp', hp - 50);

再假随着游戏时长增加,每过一分钟,所有怪物攻击力实时增加1点。

// 遍历monsters表,并且自增power属性
// 因为有监听器存在,所以查询结果、以及mapTemp表都会自动更新,且游戏展上也是实时更新。
store.forEachRow('monsters', (rowId, forEachCell) => {
  console.log(rowId);
  const power = store.getCell('monsters', rowId, 'power');
  store.setCell('monsters', rowId, 'power', power + 1);
});

假如玩家在关卡中退出了游戏,下次进入游戏想恢复之前的关卡进度的话。只需要把store持久化一下,下次进入游戏自动加载,这样就可以实现恢复关卡的效果。

// 创建本地持久化,并自动监听数据变化,变化后立即保存
const persister = createLocalPersister(store, 'store1');
await persister.startAutoSave();
// ...
// 在游戏加载阶段,执行load方法,把持久化的数据加载进来
// 之前写的monsters、arms等表的初始化代码稍加修改,注意不要覆盖掉持久化的数据就行了
persister.load();

实现起来还是比较简单的。但是传统的面线对象的方式做的话,就比较繁琐了。需要自己把所有的数据汇总并转成JSON字符串,然后存到本地缓存。下次启动游戏后,又得把序列号的数据恢复成一个个对象实例。如果还想做到数据实时保存的效果,几乎不可能。

再来看一个数据管理的场景。假如mapLevel变了,现在要加x、y两个属性来标定monster的初始坐标。如果是传统方式,我们需要改脚本代码,一条条的加,且非常不直观容易改错。下面是插件演示效果,比起改代码还是方便不少。
录屏2024-10-31 18.05.11 (1)_0

个人看法

有时候用数据库的方式,代码看上去挺复杂的,其实对于游戏开发的场景并不友好。所以后面有时间可以考虑封装一套api,简化一下代码。但是底层数据驱动的思想还是不变的。因为我个人认为,游戏开发特别是单机游戏,数据都是存本地的,且量还不小。如果没有一个好的管理方案,对编码也是一种考验。

当然,我也不是什么大佬。刚接触游戏开发,项目做多了可能也会总结出更好的处理方式。

1赞

有类型提示的哦,这个项目源码就是TS写的。


提示里面都有例子,都可以不用看他们文档了,挺方便。

indexeDB 的设计者都不知道咋想的, 接口弄成异步,但更恶心实际底层是同步, 我之前弄一个项目想把webp动图解释成多张imagebitmap后存到indexedb作持久化缓存, 谁知一存, 特么直接画面卡住了。最终只能弄一个worker 去做储存才行, 明明底层就是同步, 接口却弄成异步,真坑

那确实坑,为啥不直接用localStorage

localStorage 有容量限制, 一般10mb左右就是极限, 并且只能储string
但indexeDB能大容量储存, 起步至少250MB, 一般H5原生对像如ImageData, ImageBitmap,ArrayBuffer,Blob, 以及Object 能直接存到IndexDB里, 另外localStorage在worker里不能调用, 但IndexeDB可以

1赞

好吧,学到了。不过按常理来看,这种媒体数据并不适合放数据库里存。数据库还是擅长存结构型的数据,如果需要用到图片这种资源数据,数据库直接存获取链接就行了。这种数据加载起来比较费时,所以indexeDB设计成异步也能理解。

playerHP如果写错了,会有类型检查吗,看上去应该是没有

我看你好多地方都是强转,看上去数据类型都丢了

面向类型数据编程更方便团队协作吧,这个有点面向json数据结构编程。不过这个存储和读取看起来是真方便,如果能拿来跟model数据进行运行时序列化反序列化转换,应该很有用

示例代码主要是让大家大概看一下效果,所以没有很严谨。

关于第一个问题,playerHP是列id,本身就是字符串类型。就跟你取一个对象的属性用obj[‘someKey’]的写法类似,你如果传非string类型,ts类型检测也会报错的。

关于第二个问题,getCellChange返回的是CellChange类型的,实际上是一个数组,第一个元素表示该值是否变化,类型是boolean。第二个元素表示oldValue,第三个元素表示newValue。这两个都是Cell类型,而Cell有是string | number | boolean | nulll类型。所以我这里用了强转没问题。

如果还有问题,可以自己写个demo项目体验一下。

我的意思是比如写成了 PlayerHP

支持持久化的,tinybase内部就是通过JSON.stringify做的序列化跟反序列化。我计划在项目中用起来。做的第一个游戏就是用对象去存,修改起来十分不方便,还无法做到响应式。

团队协作感觉也能用,这个数据库支持数据合并的。例如你编辑了一套数据,你同事也编辑了一套。两套不一样可以调用merge方法进行合并,合并后基于字段修改时间取的并集。

1. 你的代码没有证明你的方式更好,反而偏见式举例,例如

到了你这儿就变成

2. 对象持久化并不难也不繁琐,而且支持websocket, http, localStorage任何能拿到数据的方式

举例

// 加载
let xxx = ?(根据存储方式决定);
// webscoket 存储
await xxx("接口名", xxx);
// http 存储
await post("接口名", xxx);
// localStorage 存储
localStorage.set("键", JSON.stringify(xxx))

3. 数据汇总并转成JSON字符串是完全不需要的,因为只要父级对象分离即可

class xxx {
    data = {
        storage: {
            ...需要存储的数据
        }
    }
}

4. 对象并不只支持手写,反而支持各种方式,例如 bin二进制,随机数据,excel表格,等等…

请问离开了你的插件,它还有什么方式支持外部写入数据?