2024年11月

相关:

python编写的扫雷游戏

如何使用计算机程序求解扫雷游戏


image-20241115143032377



本文中实现的《扫雷》游戏的AI解法的项目地址:

https://openi.pcl.ac.cn/devilmaycry812839668/AI_mine_game


该项目的解法效果:

image-20241115125404601



之前介绍了网上的一些解决《扫雷》游戏的一些解法,包括DQN和启发式等AI算法,看着这些的实现个人有些手痒,于是就花了些时间自己用python代码实现了一个启发式方法求解《扫雷》游戏的算法。


求解《扫雷》游戏,很多人给出了很多启发式规则,但是实际上我个人认为就两条或者说三条,其他的那些规则属于在这两条或者说是三条规则基础上衍生的,实际上用这两条或三条规则就足够。

第一条:

如果一个格子揭开后其显示的周围有雷的数量和周边8个格子中未揭开的格子数量相同,那么说明这几个未被揭开的格子都是有雷的;

第二条:

如果一个格子揭开后其显示的周围有雷的数量和周边8个格子中标记的有雷的格子数量相同,那么说明其他几个未被揭开的格子都是无雷的。

第三条:

之所以第三条是独立出来的,因为这一条并不等同于前两条那么基础和必要,或者说没有前两条规则那么肯定不能行,但是没有第三条规则其实很多情况下也是可行的。但是有第三条的话会一定程度上提高我们的胜利比率。(
注意:
扫雷游戏在很多情况下是没有确定的胜利的情况的,也就是说在某种情况下能否胜利是要看概率的,而我们写的算法代码可以看作只是为了去尽可能接近这个概率而已)

第三条规则就是一个格子显示雷的数值在某些情况下是可以通过附近24个格子的数值进行优化的,比如一个格子的坐标为(x, y)那么在其-2,+2的范围下的其他标有数值的格子是可能和其进行化简的。

比如:一个格子坐标为(x, y)数值为3,其周边8个格子标有雷的数量为0,未被揭开的格子有四个,我们假设这4个未揭开的格子的真实有雷和无雷的情况分别用0或1表示,那么我们可以得到下面这个等式:

a+b+c+d=3

而坐标为(x+2, y+2)的一个格子可以得到下面的等式:

a+b+c=2

那么我们可以得到结论,那就是有无雷情况用d表示的格子肯定有雷,因为:

set(a,b,c,d)-set(a,b,c)=d

3-2=1

同理,如果假设(x-2, y-2)的一个格子的数值表示为a+b+c+d+e=3,那么我们可以判断出e这个表示的格子的情况一定为无雷的。

其实,第三条虽然是一种集合运算,但是其模拟的却是一种人类所用的数学推理的方法,在考虑使用这个方法之前曾经考虑过用python的线性代数运算library来实现这种数学推理的计算,但是感觉这么搞有些离谱,总觉得这个问题应该不至于到这个程度,然后盯着游戏画面好久,突然灵感一现,发现这个推理过程虽然看似是一个矩阵运算那种线性代数运算,但是如果只是小范围来看这个其实就是很简单的线性代数,因为每个变量的取值只能为0或1,并且可以完全使用for循环的方式加上set集合运算的方式实现消元化简,从而使用较为简单的编码方式就可以实现这种推理过程,而不需要搞进来一个线性数学的library,不过后来发现GitHub上的其他人实现的也都是大致用这种set结合加for循环的方式实现消元操作,看来这估计是正解。


在这三条的规则基础上我还加入了概率判断,这一点体现在随机进行格子选择时,我们可以根据一个未知格子周边(附近8个)已知格子的显示数值和这8个格子与其周边的另个格子中未知雷的数量计算出这个已知格子对这个目标的未知格子的又雷概率,我们可以取这个未知格子周边已知格子推断出的有雷概率取max,然后再计算出当前一共有多少未知格子和多少雷,然后计算出完全随机选格子的有雷概率,然后再在其中选择概率最小的,这样就能找到最小概率有雷的格子,以此实现最小概率触发雷。


再往下就是使用前面的前两个规则判断刚选的格子(假设此时为触发雷)是否可以判断出周边格子的情况。但是,这里我又加入一个规则,那就是一个格子被揭开显示数值后会影响其周边8个格子中未知格子的推理,导致这8个格子中的未知格子有可能被推理出来,因此我们将每个先揭开的格子或标记的格子其周边的格子保存起来,然后再对这些保存的各种进行判定,看其周边的格子是否可以被推理出来。

需要注意的是,一个格子被揭开显示数值或者被标记有雷后,其周边受影响可能被推断出的格子为附近8个,这时可以根据之前给出的前两条规则进行判断和推理,但是一个格子被揭开显示数值或者被标记有雷后其周边24个格子包括其自身,也就是共25个格子的set集合都有可能被推理化简,也就是之前所谓的for+set实现的消元操作。通过将这些规则结合在一起也就有了本文给出的代码实现。



最终的代码实现:

import numpy as np
import random
from typing import List


def belong_to(h, w, H, W):
    near = []
    for i in range(h-2, h+3):
        for j in range(w-2, w+3):
            if i>=0 and j>=0 and i<H and j<W and (i,j)!=(h,w):
                near.append((i, j))
    return near

def near_by(h, w, H, W):
    near = []
    for i in range(h-1, h+2):
        for j in range(w-1, w+2):
            if i>=0 and j>=0 and i<H and j<W and (i,j)!=(h,w):
                near.append((i,j))
    return near

def mine_count(h, w, real_state:np.array, H, W):
    count = 0
    for i, j in near_by(h, w, H=H, W=W):
        if real_state[i][j]==1:
            count += 1
    return count


class Env():
    def __init__(self, H, W, N):
        self.H = H
        self.W = W
        self.N = N

        # real state中0表示无雷,1表示有雷
        self.real_state = np.zeros((H, W), dtype=np.int32)
        self.mine = set()
        while len(self.mine)!=N:
            self.mine.add(random.randint(0, H*W-1))
        for x in self.mine:
            # print(x, self.H, self.W)
            # print(self.real_state.shape)
            self.real_state[x//self.W][x%self.W] = 1    
            
        # state_type中0表示无雷,1-8表示有雷, 用此来表示对附近雷的计数
        self.state_type = np.zeros((H, W), dtype=np.int32)  
        for i in range(H):
            for j in range(W):
                self.state_type[i][j] = mine_count(h=i, w=j, H=H, W=W, real_state=self.real_state)

        # obs为-100表示未翻开(未知),0-8表示翻开但无雷,数值大小表示翻开位置周边雷的数量
        # agent的状态记录所用,也可以用来作为打印之用
        self.obs = np.zeros((H, W), dtype=np.int32) -100
    
    def act(self, i, j):
        done = False
        if self.obs[i][j]!=-100:
            print("该位置已经被揭开过,重复翻开,error!!!")
            return ValueError
        if self.real_state[i][j] == 1:
            # game over 触雷
            done = True
            return None, done

        self.obs[i][j] = self.state_type[i][j]
        return self.obs[i][j], done
        
    def pp(self):
        for i in range(self.H):
            for j in range(self.W):
                if self.obs[i][j]>=0:
                    print(self.obs[i][j], end=' ')
                else:
                    print('*', end=' ')
            print()
        
    def input(self):
        while True:
            i, j = input('请输入坐标:').split()
            _, done = self.act(int(i), int(j))
            if done:
                print('game over!!!')
                print(self.real_state)
                print(self.state_type)
                break
            self.pp()
        
# 测试用
# env=Env(5, 5, 5)
# env.input()

def play():
    N = 99  # 雷的数量
    H = 16
    W = 30
    env = Env(H=H, W=W, N=N)  # H=36, W=64, N=100

    known_count_dict = {} # (2,2):3, (3,3):2

    known_set = set()   #  (2, 2)
    unknown_set = set()
    boom_set = set()
    for i in range(H):
        for j in range(W):
            unknown_set.add((i,j))

    new_nodes = []
    new_relation_nodes_set = set()

    while(len(unknown_set)>0):
        probs_list = [] # ((1,1), 0.5, 3), ((2,2), 0.5, 2) # (node, prob, count) # count为node附近的unknown个数
        for node in unknown_set:
            p_list = []
            n_c = 0  # node附近的unknown_node的个数
            for _node in near_by(*node, H, W):
                if _node in unknown_set:
                    n_c += 1
                if _node in known_set:
                    count = known_count_dict[_node]
                    n = 0
                    for _node_node in near_by(*_node, H, W):
                        if _node_node in unknown_set:
                            n += 1
                        if _node_node in boom_set:
                            count -= 1
                    p_list.append(count/n) # 有雷的概率
            p_list.append(N/len(unknown_set))
            probs_list.append((node, max(p_list), n_c))
        m_p = min(probs_list, key=lambda x:x[1])[1]
        probs_list = [x for x in probs_list if x[1]==m_p]
        node = min(probs_list, key=lambda x:x[2])[0]
        
        count, done = env.act(*node)
        if done == True:
            print('游戏失败,触雷,game over!!!')
            print(node)
            raise Exception
        print("成功完成一步!!! \n\n")
        print("remove node:", node)
        unknown_set.remove(node)
        known_set.add(node)
        known_count_dict[node] = count
            
        env.pp()  # 打印当前游戏环境的显示

        new_nodes.append(node)
        new_relation_nodes_set.add(node)
        
        
        while new_nodes or new_relation_nodes_set:
            # debug
            # print(new_nodes)
            # print(new_relation_nodes_set)
            while new_nodes:
                node = new_nodes.pop()
                k = 0
                b = 0
                count = known_count_dict[node]
                tmp_unk = set()
                for _node in near_by(*node, H, W):
                    if _node in known_set:
                        new_relation_nodes_set.add(_node)
                        # k += 1
                        continue
                    if _node in boom_set:
                        new_relation_nodes_set.add(_node)
                        b += 1
                        continue
                    tmp_unk.add(_node) # 对unknown节点进行判断
                count -= b
                if count==len(tmp_unk):
                    # 全是雷
                    for _node in tmp_unk:
                        print("remove node:", _node)
                        unknown_set.remove(_node)
                        boom_set.add(_node)
                        new_relation_nodes_set.add(_node)
                        N -= 1
                if count==0 and len(tmp_unk) > 0:
                    # 全都不是雷
                    for _node in tmp_unk:
                        c, done = env.act(*_node)
                        if done:
                            print("程序判断出错,把雷误触发了!!!")
                            raise Exception

                        print("remove node:", _node)
                        unknown_set.remove(_node)
                        known_set.add(_node)
                        known_count_dict[_node] = c
                        new_nodes.append(_node)
                        new_relation_nodes_set.add(_node)
                
            while new_relation_nodes_set:
                node = new_relation_nodes_set.pop()
                tmp_set = set()
                for i in range(-2, 3):
                    for j in range(-2, 3):
                        if node[0]+i>=0 and node[0]+i<H and node[1]+j>=0 and node[1]+j<W:
                            if (node[0]+i, node[1]+j) in known_set:
                                if known_count_dict[(node[0]+i, node[1]+j)]==0:
                                    continue
                                tmp_set.add((node[0]+i, node[1]+j))
                if len(tmp_set)==0:
                    continue

                relations = []
                for node in tmp_set:  # node 为 known set
                    tmp_tmp_set = set()
                    c = known_count_dict[node]
                    for _node in near_by(*node, H, W):
                        if _node in boom_set:
                            c -= 1
                            continue
                        if _node in unknown_set:
                            tmp_tmp_set.add(_node)
                            continue
                    if len(tmp_tmp_set)==0:
                        continue
                    relations.append([tmp_tmp_set, c, node])

                if len(relations)<2:
                    continue
                for i in range(0, len(relations)):
                    for j in range(1, len(relations)):
                        if relations[i][0].issuperset(relations[j][0]):
                            relations[i][0] -= relations[j][0] 
                            relations[i][1] -= relations[j][1]
                            
                        if relations[i][1]==len(relations[i][0]) and relations[i][1]>0:
                            # 全是雷
                            for _node in relations[i][0]:
                                if _node in boom_set:
                                    continue
                                print("remove node:", _node)
                                unknown_set.remove(_node)
                                boom_set.add(_node)
                                new_relation_nodes_set.add(relations[i][2])
                                N -= 1
                        if relations[i][1]==0 and len(relations[i][0]):
                            # 全都不是雷
                            for _node in relations[i][0]:
                                if _node in known_set:
                                    continue
                                c, done = env.act(*_node)
                                if done:
                                    print("程序判断出错,把雷误触发了!!!")
                                    raise Exception
                                print("remove node:", _node)
                                unknown_set.remove(_node)
                                known_set.add(_node)
                                known_count_dict[_node] = c
                                new_nodes.append(_node)
                                new_relation_nodes_set.add(_node)

                        if relations[j][0].issuperset(relations[i][0]):
                            relations[j][0] -= relations[i][0] 
                            relations[j][1] -= relations[i][1]
        
                        if relations[j][1]==len(relations[j][0]) and relations[j][1]>0:
                            # 全是雷
                            for _node in relations[j][0]:
                                if _node in boom_set:
                                    continue
                                print("remove node:", _node)
                                unknown_set.remove(_node)
                                boom_set.add(_node)
                                new_relation_nodes_set.add(relations[j][2])
                                N -= 1
                        if relations[j][1]==0 and len(relations[j][0]):
                            # 全都不是雷
                            for _node in relations[j][0]:
                                if _node in known_set:
                                    continue
                                c, done = env.act(*_node)
                                if done:
                                    print("程序判断出错,把雷误触发了!!!")
                                    raise Exception
                                print("remove node:", _node)
                                unknown_set.remove(_node)
                                known_set.add(_node)
                                known_count_dict[_node] = c
                                new_nodes.append(_node)
                                new_relation_nodes_set.add(_node)


    print('游戏胜利,game over!!!')
    return True

sss = []
for xyz in range(30000):
    try:
        sss.append(play())
        print('第 %d 次游戏成功'%xyz)
    except Exception:
        print('第 %d 次游戏失败!!!'%xyz)
        continue
print("成功次数: ", sum(sss))
print("成功比例: ", sum(sss)/30000)



个人github博客地址:
https://devilmaycry812839668.github.io/

今天的博客来自 JuiceFS 云服务用户 Jerry,他们通过使用 JuiceFS snapshot 功能,创新性地实现了数据的版本控制。Jerry,是一家位于北美的科技公司,利用人工智能和机器学习技术,简化用户购买汽车和家庭保险的比较及购买流程。

在软件开发领域,严格的测试和受控发布已经成为几十年来的标准做法。但如果我们能将这些原则应用到数据库和数据仓库中会怎样?想象一下,能够为数据基础设施定义一套带有测试用例的标准,自动应用于每个新的"发布",以确保客户始终看到准确和一致的数据。这将会极大改善数据质量。

01 挑战:为什么端到端测试在数据管理中并不常见

这个想法看似直观,但端到端测试在数据管理中并不常见,因为它需要数据库或数据仓库具备克隆或快照的功能,而大多数数据系统都不提供这一功能。

现代数据仓库本质上是随时间变化的有组织的可变存储,我们通过数据管道对其进行操作。数据通常在生成后立即对最终客户可见,没有"发布"的概念。当没有这个发布概念,对数据仓库进行端到端测试就没有多大意义。因为无法确保测试所看到的内容就是客户将看到的内容,这些数据在不断因为数据管线的修改而变化。

所以问题的核心,就是要在实现一种数据发布的机制,这种机制能够把某一个时刻数据仓库的状态提取成一个“快照“,并且控制这个”快照“对最终用户的可见性。这样,这个快照就成为一个”发布工件“,我们控制它什么条件、什么时间最终可以让用户看见。

02 现有方法及其局限性

一些团队在数据仓库之上开发了版本控制系统。他们不直接修改最终用户查询的表,而是为变更创建新版本的表,并使用原子交换操作来"发布"表。虽然这种方法在某种程度上有效,但它带来了重大挑战:

  • 高效实施"创建和交换"模式并不容易;
  • 确保涉及多个表的一致性(例如,验证订单表中的每一行在价格表中都有对应行)需要将多个表的变更"打包"成一个"事务",这也具有挑战性,不仅仅实现这种模式是困难的,这种模式也要求数据管线被相对严格的编排。

03 解决方案:由 JuiceFS 支持的 ClickHouse 数据库克隆

我们开发了一个系统,利用 JuiceFS snapshot 功能将 ClickHouse 数据库"克隆"为副本。这种方法在我们早前的文章 "低成本读写分离:Jerry构建主从ClickHouse架构" 中有详细介绍。

它的工作原理如下:

  • 我们在 JuiceFS 上运行 ClickHouse 数据库,JuiceFS 是一个由对象存储服务(OSS)支持的 POSIX 兼容共享文件系统。
  • JuiceFS 提供了一个实现 git 分支语义的"快照"功能。
  • 使用简单的命令如
    juicefs snapshot src_dir des_dir
    ,我们可以创建 src_dir 在那一刻的克隆。

这种方法使我们能够轻松地从运行中的实例复制/克隆 ClickHouse 实例,创建一个可以被视为"发布工件"的冻结快照。

04 使用数据库克隆实施端到端测试

有了这种机制,我们可以对 ClickHouse 副本运行端到端测试,并根据测试结果控制其可见性。

现在可以使用常见的单元测试框架(我们使用 pytest )开发、组织和迭代数据端到端测试。这种方法使我们能够将数据可用性和可靠性的基础设施和业务标准编码成数据测试。

一个典型的测试是表大小测试,它有助于防止由意外或临时表损坏导致的数据问题。还可以定义业务标准,以保护数据报告和分析免受数据管道中可能导致数据错误的意外更改的影响。例如,用户可以在一列或一组列上强制唯一性以避免重复——这在计算营销成本时是一个关键因素。

在 Jerry,这种架构在近几个季度中发挥了至关重要的作用,有效防止了几乎所有可能暴露给最终客户的 P0 级数据问题。

这种方法不仅限于 ClickHouse。如果在 JuiceFS 之上运行任何类型的数据湖或湖仓,采纳本文描述的发布机制可能会更容易。

05 结论

通过将现代软件开发实践引入数据管理世界,我们可以显著提高数据质量、可靠性和一致性。数据库克隆和端到端测试的结合为确保客户始终看到正确的数据提供了强大的工具集,就像他们期望在经过充分测试的软件发布中看到正确的功能一样。

下图展示了我们的数据库发布和端到端测试过程的工作流程。

这个架构的诞生标志着我们在缩小软件开发与数据管理之间的差距方面迈出了重要一步,为数据领域的创新和质量保障开辟了全新的可能性。

希望这篇内容能够对你有一些帮助,如果有其他疑问欢迎加入
JuiceFS 社区
与大家共同交流。

Overview

输出形式:早物化与晚物化(OLAP一般都是晚物化)

代价分析:一般用IO次数计算(最终结果可能落盘,也可能不落盘,所以我们只计算输出结果之前的IO次数)。

Join
左边称为外表(Outer Table),右边称为内表(Inner Join),外表一般是小表。

Nested Loop Join

Naïve

前提:缓冲区大小为3,一个外表输入,一个内表输入,一个输出。

基本思想:双重循环,对每一个元组(Tuple)进行配对,读取S表m次。

Cost:
\(M+(m*N)\)

image
image

Block

前提:缓冲区大小为3,一个外表输入,一个内表输入,一个输出。

基本思想:双重循环,对每一个块(Block,同页Page)内进行配对,所以读取S表M次。

Cost:
\(M+(M*N)\)

image-20241115131544624

如果缓冲区容量为B,即可以容纳B个块(页),B-2个块用于外表输入,一个块用于内表输入,一个块用于输出。

Cost:
\(M+(⌈M/(B-2)⌉*N)\)

Index

前提:缓冲区大小为3,一个外表输入,一个内表输入,一个输出。

基本思想:如果外部表有索引,那么内层循环无需遍历,查询索引即可。

Cost:
\(M+(m*C)\)

image-20241115132426075

Sort-Merge Join

基本思想:排序后的序列更容易找到匹配项。

分为两个步骤:

  1. 排序:用任意排序方式,将R和S排序。
  2. 合并:移动两个指针寻找匹配项,过程中可能需要回退指针。

这两个步骤和上一节提到的
外部归并排序
思想相同,但不是同一个东西。

SortCost(R):
\(2M*(1 + ⌈ log_{B-1} ⌈M / B⌉ ⌉)\)

SortCost(S):
\(2N*(1 + ⌈ log_{B-1} ⌈N / B⌉ ⌉)\)

MergeCost:
\(M+N\)

Total Cost:Sort + Merge

当R中存的是相同元素,且S中也是时,指针需要一直回退,Sort-Merge Join退化为Nest Loop Join。

image-20241115140700767

image-20241115141209698

Hash Join

Simple Hash Join

基本思想:匹配项会被映射到同一个哈希桶。

分为两步骤:

  1. 构建哈希表:对R表采用哈希函数
    \(h_1\)
    进行哈希,得到哈希表,包含不同的哈希桶(可以采用不同的哈希表,但是
    链式哈希
    最符合需求)。
  2. 探测:把S表元组用哈希函数
    \(h_1\)
    进行哈希,得到对应的哈希桶位置,然后在哈希桶中寻找匹配项。

image-20241115142931397

优化措施:布隆过滤器。

创建哈希表时顺带构建布隆过滤器,探测阶段先走布隆过滤器再走哈希桶。

image
image

存在的问题i:该算法需要保证哈希表能存在内存中,如果哈希表太大导致无法存到内存中,需要不断地换入换出,影响效率。但不幸的是,大部分情况下,我们都不能保证内存能完全存下哈希表。

Partition Hash Join

基本思想:把两个表分别用同一个哈希函数哈希,相同哈希桶之间进行配对,如果哈希桶都存不下,就再哈希一次,直到能存下为止。

image-20241115144711876

读取对应的哈希桶到内存中配对即可。

Partition Cost:
\(2(M+N)\)
【读取数据+哈希桶落盘(哈希空间复杂度为
\(O(n)\)
)】

Probe Cost:
\(M+N\)

Total Cost:
\(3(M+N)\)

总结

Algorithm IO Cost Example
Naïve Nested Loop Join M + (m * N) 1.3 hours
Block Nested Loop Join M + (⌈M / (B-2)⌉ * N) 0.55 seconds
Index Nested Loop Join M + (m * C) Variable
Sort-Merge Join M + N + sort cost 0.75 seconds
Hash Join 3 * (M + N) 0.45 seconds

结论:选择Partition Hash Join,出现下述情况时使用Sort-Merge Join:

  • 数据偏斜严重:Hash Join退化为Sort-Merge Join

  • 数据本身需要被排序:此时Sort-Merge Join只需要额外付出
    \(M+N\)
    即可实现Join

一般数据库中,Hash Join和Sort-Merge Join都会实现。

前言

有些时候,我们可能对输出的某些字段要做特殊的处理在输出到前端,比如:身份证号,电话等信息,在前端展示的时候我们需要进行脱敏处理,这时候通过自定义注解就非常的有用了。在Jackson中要自定义注解,我们可以通过
@JacksonAnnotationsInside
注解来实现,如下示例:

一、自定义注解

importcom.fasterxml.jackson.annotation.JacksonAnnotationsInside;importcom.fasterxml.jackson.databind.annotation.JsonSerialize;importjava.lang.annotation.ElementType;importjava.lang.annotation.Retention;importjava.lang.annotation.RetentionPolicy;importjava.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using
= SensitiveSerializer.class)public @interfaceSensitive {//加密开始位置 int start()default 0;//加密结束位置 int end() default 0;//加密掩码 String mask() default "*";
}

二、自定义序列化处理器SensitiveSerializer

importcom.fasterxml.jackson.core.JsonGenerator;importcom.fasterxml.jackson.databind.BeanProperty;importcom.fasterxml.jackson.databind.JsonMappingException;importcom.fasterxml.jackson.databind.JsonSerializer;importcom.fasterxml.jackson.databind.SerializerProvider;importcom.fasterxml.jackson.databind.ser.ContextualSerializer;importorg.springframework.util.StringUtils;importjava.io.IOException;importjava.util.Collections;/***@authorsongwp
* @date 2024-11-15
* @desc 自定义序列化器,用于对敏感字段进行脱敏处理
*/ public class SensitiveSerializer extends JsonSerializer<String> implementsContextualSerializer {privateSensitive sensitive;

@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throwsIOException {
String val
=value;if (sensitive != null &&StringUtils.hasLength(val)) {
String m
=sensitive.mask();int start =sensitive.start();int end =sensitive.end();int totalLength =value.length();if (totalLength <= 2) {
val
= totalLength == 1 ? value + m : value.substring(0, 1) +m;
}
else if (totalLength <= 6) {
val
= value.substring(0, 1) + String.join("", Collections.nCopies(totalLength - 2, m)) + value.substring(totalLength - 1);
}
else{int prefixLength = Math.min(start, totalLength - 1);int suffixLength = Math.min(end, totalLength - 1);if (prefixLength >totalLength) {
prefixLength
= totalLength / 2;
}
if (suffixLength >totalLength) {
suffixLength
= totalLength / 2;
}
int maskLength = Math.max(0, totalLength - (prefixLength +suffixLength));if (maskLength == 0) {
prefixLength
-= 2;
suffixLength
-= 2;
maskLength
= Math.max(2, totalLength - (prefixLength +suffixLength));
}
prefixLength
= Math.min(prefixLength, totalLength - 1);
suffixLength
= Math.min(suffixLength, totalLength - 1);
maskLength
= totalLength - prefixLength -suffixLength;
val
= value.substring(0, prefixLength) + String.join("", Collections.nCopies(maskLength, m)) + value.substring(totalLength -suffixLength);
}
}
gen.writeString(val);
}

@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throwsJsonMappingException {
sensitive
= property.getAnnotation(Sensitive.class);return this;
}
}

三、在输出的Java Bean中使用上面的注解

importcom.fasterxml.jackson.databind.annotation.JsonSerialize;importcom.fasterxml.jackson.databind.ser.std.ToStringSerializer;importcom.songwp.config.Sensitive;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importjava.io.Serializable;/***@authorsongwp
*
@version1.0
* @date 2024-11-15
* @description: user domain
*/@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implementsSerializable {
@JsonSerialize(using
= ToStringSerializer.class)privateLong id;
@Sensitive(start
= 2, end = 4)privateString name;
@Sensitive(start
= 6, end = 4)privateString idCard;
@Sensitive(start
= 4, end = 3)privateString phone;
}

四、在前端展示结果如下:

敏感数据得到了脱敏处理。

二游GAMELauncher启动器

1.前言

  • 许多二次元手游(原神,鸣潮,少女前线)的PC端启动器都是使用Qt做的,正好最近正在玩鸣潮,心血来潮,便仿鸣潮启动器,从头写一个。先下载一个官方版的PC启动器,找到图标,背景图等素材,然后对着界面写代码就行。

  • 效果如下

    在这里插入图片描述

    在这里插入图片描述

2. 划分模块

  • 游戏启动器大致可以分为六部分


    • 主体窗口

    • 顶部标题栏

    • 公告栏

    • 轮播图

    • 游戏下载模块

    • 设置对话框

  • 模块划分后,要做的事就很清晰了,对每一个模块,都新建一个带ui(方便布局)的类,然后根据各模块功能分别实现,最后组装在一起就行。

3. 主体窗口

  • 主体窗口是一个无边框窗口,然后有动态的背景图,有logo,版本号,版本标题。


    • Qt中设置无边框窗口很简单,只要一行代码即可

        this->setWindowFlags(Qt::FramelessWindowHint | windowFlags());
      

      这会导致窗口原本的移动事件和缩放事件无效,移动事件我们留在标题栏部分实现,缩放事件我们则不需要。

    • 动态背景图,其实是通过定时切换图片实现,这时我们很容易想到使用定时器实现,到时间就就加载下一张图片。

      这样做会有一个问题,加载图片是需要时间的,这样做界面会有卡顿感。我们可以先把所有图片加载到内存,然后就不需要加载了,可以解决卡顿感。

      但是这又会导致另一个问题,图片很多,全部加载会占用很多内存空间,不够优雅。这里就需要用到线程,我们可以使用线程加载图片,然后通过信号把加载好的图片发送给主窗口就行绘制,这样既不卡顿也不占用很多内存。

    • 新建一个加载图片的类(LoadImage),继承QThread,实现run方法。

         while (!stop)
         {
              if (!imgNameList.empty())
              {
                  QPixmap pix = QPixmap(imgNameList[curIndex]);
                  curIndex = (curIndex + 1) % imgNameList.size();
                  emit sendPixmap(pix);
              }
              QThread::msleep(fps);
          }
      

      我们可以设定帧数,让背景图实现指定帧率刷新。

    • 这样我们就实现了背景图的切换

      在这里插入图片描述

    • 然后我们把那些logo,版本号,标题,根据对应位置绘制上去就行了。logo 和 slogan都是图片来的。

       //绘制logo 和 slogan
          p.drawImage(0,this->height()-slogan.height(),this->slogan);
          p.drawImage(50,120,this->logo
         //绘制版本号
          QPen pen;
          pen.setWidth(1);
          pen.setColor(Qt::white);
          p.setPen(pen);
          QFont font("Arial", 12, QFont::Bold);
          p.setFont(font);
          p.drawText(10,this->height()-10,versionNumber);
      
    • 这样就得到了主体视觉图

      在这里插入图片描述

  • 主体窗口还需要接收来自标题栏的移动,最小化,关闭的信号。

        connect(ui->topBar,&TopBar::miniumWindow,[this]()
        {
            this->showMinimized();
        });
        connect(ui->topBar,&TopBar::closeWindow,[this]()
        {
            this->close();
        });
        connect(ui->topBar,&TopBar::moveWindow,[this](QPoint pos)
        {
            this->move(pos+this->pos());
        });
    

4. 顶部标题栏

  • 标题栏用一个QWidget,然后把背景颜色设置成
    rgba(0,0,0,80)
    就可以实现透明的样式。其它的控件就是很常规的,里面有些按钮是有渐变的背景色和底部有白线,我们可以用一个QWidget加一个QPushButton作为一个组件实现,使用QWidget控件方便绘制白线。还有鼠标悬浮时显示的类似气泡的对话框,这个对话框需要自己实现。


    • 气泡框可以通过把QWidget设置为无边框和透明窗口,然后里面绘制一个圆角矩形,然后再画一个三角形箭头即可。

      setWindowFlags(Qt::FramelessWindowHint);
      setAttribute(Qt::WA_TranslucentBackground);
      
      void BubbleWidget::paintEvent(QPaintEvent*event)
      {
      
          QPainter painter(this);
          painter.setRenderHint(QPainter::Antialiasing);
      
          // 设置背景颜色和边框
          painter.setBrush(Qt::white);
          painter.setPen(QPen(Qt::gray, 1));
      
          // 创建圆角矩形路径
          QPainterPath path;
          QRectF rect = this->rect().adjusted(1, 10, -1,1); // 为箭头留出空间
      
          path.addRoundedRect(rect, 6, 6);
          painter.drawPath(path);
          path.clear();
          // 添加三角形箭头
          int arrowWidth = 15;
          int arrowHeight = 6;
          QVector<QPointF>points =
          {
              QPointF(rect.center().x() - arrowWidth / 2, rect.top()),
              QPointF(rect.center().x() + arrowWidth / 2, rect.top()),
              QPointF(rect.center().x(), rect.top() - arrowHeight)
          };
          path.addPolygon(QPolygonF(points));
      
          // 绘制路径
          painter.drawPath(path);
      
    • 然后我们可以根据需要往这个气泡框设置不同的Layout,就可以实现不同的布局效果了。

      在这里插入图片描述

    • 把其他控件放上就可以得到下面的标题栏,我们在这个类里面把移动,关闭,最小化的信号发给父窗口即可。
      在这里插入图片描述

    • 然后得到一个带标题栏的窗口。
      在这里插入图片描述

5. 公告栏

  • 公告栏可以用QFrame和QStackedWidget组合实现,每条公告需要自定义一个QWiget来表示,处理好气泡框提示以及绘制左则的竖线。剩下就是对样式的设置,需要慢慢调一下。

    在这里插入图片描述

6. 轮播图

  • 轮播图使用QWiget和两个QPushButton实现,按钮固定在中间的左右两则,鼠标进入轮播图时显示。QWiget负责绘制轮播图片,图片切换是带一定动画效果的,不能直接切换图片。


    • 轮播图上使用缓出动画效果,使得切换图片时更平滑。我们可以使用Qt的属性动画QPropertyAnimation,让图片位置属性
      offset
      按缓和曲线进行变动,然后根据属性变化绘制当前图片和下一张图片即可。

        animation = new QPropertyAnimation(this, "offset");
        animation->setStartValue(0.0);
        animation->setEndValue(1.0);
        animation->setDuration(400);
        animation->setEasingCurve(QEasingCurve::OutCubic);   //缓出效果
      
        void Carousel::paintEvent(QPaintEvent*e)
        {
             QPainter p(this);
          if(!left)
          {
      
              p.drawImage(QRect(-width() * offset, 0, width(), height()), imgArr.at(curIndex).scaled(width(),height(),Qt::KeepAspectRatio,Qt::SmoothTransformation));
              // 绘制下一张图片
              p.drawImage(QRect(width() * (1 - offset), 0, width(), height()), imgArr.at(nextIndex).scaled(width(),height(),Qt::KeepAspectRatio,Qt::SmoothTransformation));
          }
          else
          {
              p.drawImage(QRect(width() * offset, 0, width(), height()), imgArr.at(curIndex).scaled(width(),height(),Qt::KeepAspectRatio,Qt::SmoothTransformation));
              // 绘制下一张图片
              p.drawImage(QRect(-width() * (1 - offset), 0, width(), height()), imgArr.at(nextIndex).scaled(width(),height(),Qt::KeepAspectRatio,Qt::SmoothTransformation));
      
          }
        }
      
    • 效果如下

      在这里插入图片描述

  • 图片进行缩放时要使用
    Qt::SmoothTransformation
    ,不然图片会很模糊。

7. 下载模块

  • 这个模块就比较简单,使用QWidget+QStackedWidget,实现下载界面和进入游戏界面的切换。


    • 有一些细节注意,QLineEdit要实现有个图标在最右侧可以使用
      addAction函数
      ,添加一个图标。当QLineEdit的文字内容过长时,要让光标位于最开始位置,可以设置

      setCursorPosition(0)
      。还需要把QLineEdit自动获取鼠标焦点功能禁用,设置
      setFocusPolicy(Qt::NoFocus)

    • 在这里插入图片描述

8. 设置

  • 设置界面比较麻烦,里面的QCheckBox和QRadioButton的效果无法通过QSS实现,需要重写,里面的漏斗形图形需要比较多步骤去绘制。


    • 重写QCheckBox

       void paintEvent(QPaintEvent *event) override
          {
              QCheckBox::paintEvent(event);
              QPainter painter(this);
      
      
              // 绘制复选框
              QStyleOptionButton opt;
              initStyleOption(&opt);
      
              QRect checkBoxRect = style()->subElementRect(QStyle::SE_CheckBoxIndicator, &opt, this);
              painter.setRenderHint(QPainter::Antialiasing);
      
              if (isChecked())
              {
                  // 绘制选中时的圆角背景
                  painter.setBrush(QColor("#BB9F5E"));  // 设置选中时的背景颜色
                  painter.setPen(Qt::NoPen);  // 去除边框
              }
              else
              {
                  // 绘制未选中时的圆角边框
                  painter.setBrush(Qt::NoBrush);  // 不填充背景
                  painter.setPen(QPen(QColor("#8C8C8C"), 2));  // 使用灰色边框,线宽为2
              }
      
              // 绘制圆角矩形,圆角半径为3
              painter.drawRoundedRect(checkBoxRect.adjusted(1, 1, -1, -1), 3, 3);
      
              // 如果复选框被选中,绘制白色的勾
              if (isChecked())
              {
                  painter.setPen(QPen(Qt::white, 2));  // 设置勾的颜色为白色,线宽为2
      
                  // 使用 QPainterPath 绘制勾的形状
                  QPainterPath checkMarkPath;
                  checkMarkPath.moveTo(checkBoxRect.left() + checkBoxRect.width() * 0.3, checkBoxRect.center().y());
                  checkMarkPath.lineTo(checkBoxRect.center().x()-2, checkBoxRect.bottom() - checkBoxRect.height() * 0.3);
                  checkMarkPath.lineTo(checkBoxRect.right() - checkBoxRect.width() * 0.3, checkBoxRect.top() + checkBoxRect.height() * 0.35);
                  painter.drawPath(checkMarkPath);  // 绘制勾
              }
      
          }
      
  • 重写QRadioButton

      void paintEvent(QPaintEvent* event) override
        {
    
              QPainter painter(this);
              painter.setRenderHint(QPainter::Antialiasing);
    
              QStyleOptionButton option;
              initStyleOption(&option);
              painter.save();
      
      
      
              // 获取单选框的矩形区域
              QRect radioButtonRect = style()->subElementRect(QStyle::SE_RadioButtonIndicator, &option, this);
      
              // 增大单选框的尺寸
              int enlargedSize = 24;  // 自定义单选框的大小(增大后的大小)
              radioButtonRect.setWidth(enlargedSize);
              radioButtonRect.setHeight(enlargedSize);
      
      
      
              painter.setBrush(Qt::NoBrush);  // 不填充背景
              painter.setPen(QPen(QColor("#8C8C8C"), 2));  // 使用灰色边框,线宽为2
      
              // 绘制增大的圆形的单选框
              QRect circleRect = radioButtonRect.adjusted(2, 2, -2, -2); // 调整绘制圆形的位置
              painter.drawEllipse(circleRect);
      
              // 如果当前单选框被选中,则填充中心
              if (isChecked()) 
              {   
      
                  painter.setPen(QPen(QColor("#BB9F5E"), 2));
                  painter.drawEllipse(circleRect);
      
      
                  painter.setBrush(QColor("#BB9F5E"));   // 设置选中时的填充颜色为白色
                  painter.drawEllipse(circleRect.adjusted(5,5, -5, -5));  // 绘制小圆圈,表示选中
              }
      
              // 绘制文本,确保文本位置对齐
              QRect textRect = option.rect;
      
              // 将文本左移,使其与增大的单选框右边对齐
              textRect.setLeft(radioButtonRect.right() + 5);  // 将文本移到单选框右侧
      
              // 使文本垂直居中
              textRect.moveTop(radioButtonRect.top() + (radioButtonRect.height() - textRect.height()) / 2);
              painter.restore();
      
              // 使用默认的文本颜色(由样式表和控件状态决定)
              style()->drawItemText(&painter, textRect, Qt::AlignVCenter, option.palette, isEnabled(), option.text);
          }
    
  • 绘制设置框的线条和图形。

    void Setting::paintEvent(QPaintEvent*event)
    {   
          QPainter p(this);
          QPen pen;
          QPainterPath path;
          pen.setColor(QColor("#CFCFCF"));//CFCFCF
          pen.setWidth(2);
          p.setPen(pen);
      
          //画顶部线条
          int x = ui->labelSetting->pos().x();
          int y = ui->labelSetting->pos().y()+ui->labelSetting->height()+10;
          p.drawLine(x,y,x+this->width()-40,y);
      
      
      
      
          //画圆弧
          int aw = 20;
          int endx = x+this->width()-35;
          int endy = y;
      
          int cx = endx-aw;
          int cy = endy-aw;
      
          int tx = cx-aw;
          int ty = cy-aw;
      
          //int bx = cx+aw;
          int by = cy+aw;
      
      
          int startAngle = 270;
          int spanAngle = 80;
          double rr = aw;
      
          int cx1 = tx+aw;
          int cy1 = ty+aw;
          double ex1 = cx1 + rr * cos((startAngle + spanAngle) * 3.14 / 180);
          double ey1 = cy1 - rr * sin((startAngle + spanAngle) * 3.14 / 180);
      
          startAngle = 90;
          spanAngle  = -80;
          int cx2 = tx+aw;
          int cy2 = by+aw;
          double ex2 = cx2 + rr * cos((startAngle + spanAngle) * 3.14 / 180);
          double ey2 = cy2 - rr * sin((startAngle + spanAngle) * 3.14 / 180);
      
      
      
          p.setBrush(QColor("#333333"));
          p.setPen(Qt::white);
          path.moveTo(cx,by);
          path.lineTo(ex1,ey1);
          path.lineTo(ex2,ey2);
          path.lineTo(cx,by);
          p.drawPath(path);
      
      
      
          path.clear();
          path.moveTo(cx,by);
          QRect r (tx,ty,aw*2,aw*2);  //x,y,width,height
          QRect r2(tx,by,aw*2,aw*2);
      
          pen.setWidth(1);
          p.setPen(pen);
          p.setRenderHint(QPainter::Antialiasing);
          path.arcTo(r,270,80);
          path.moveTo(cx,by);
          path.arcTo(r2,90,-80);
          p.fillPath(path,Qt::white);
      
      
      
          //画顶部线条
          pen.setColor(QColor("#464646"));
          pen.setWidth(2);
          p.setPen(pen);
          p.drawLine(x+this->width()-35,0,x+this->width()-35,this->height());
      
      
          //画右边线条
          int s1x = ui->btnCancel->pos().x();
          int s1y = ui->btnCancel->pos().y()-20;
          int s2x = ui->btnOk->pos().x()+ui->btnOk->width();
      
          pen.setColor(QColor("#CBCBCB"));
          pen.setWidthF(1.5);
          p.setPen(pen);
          p.drawLine(s1x,s1y,s2x,s1y);
      
      
          //画右下角三角形
          QPoint p1(x+this->width()-35,this->height()-10);
          QPoint p2(x+this->width()-35,this->height());
          QPoint p3(x+this->width()-45,this->height());
      
      
          QPolygon cons;
          cons<<p1<<p2<<p3;
          p.setPen(Qt::black);
          p.drawPolygon(cons);
      }
      
    
  • 最终效果图就是这样。

    在这里插入图片描述

9. 其它

  • 除了界面之外,我们编写一下各控件对应事件就可以了,比如打开链接,跳转到网站。
  • 完整的源码在放在github里面了:
    GameLauncher
  • 有玩鸣潮的可以加个好友:
    ID:100073367