├── blibli ├── __init__.py ├── proto.py └── ws.py ├── bliv ├── __init__.py ├── blivedm │ ├── __init__.py │ ├── handlers.py │ ├── models.py │ └── client.py └── sample.py ├── common ├── __init__.py ├── config.py ├── color.py ├── xiuxian_state.py ├── five_element.py └── image_source.py ├── game ├── __init__.py ├── operate_log.py ├── hero_ranking_list.py ├── card.py ├── test │ └── test_player_command.py ├── player_commond.py ├── play.py ├── land.py ├── control.py └── hero.py ├── img ├── bg.jpg ├── alpha.png ├── earth.png ├── fire.png ├── metal.png ├── water.png └── wood.png ├── requirements.txt ├── README.md ├── main.py ├── .gitignore └── LICENSE.txt /blibli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bliv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/bg.jpg -------------------------------------------------------------------------------- /img/alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/alpha.png -------------------------------------------------------------------------------- /img/earth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/earth.png -------------------------------------------------------------------------------- /img/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/fire.png -------------------------------------------------------------------------------- /img/metal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/metal.png -------------------------------------------------------------------------------- /img/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/water.png -------------------------------------------------------------------------------- /img/wood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/img/wood.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freedom-xiao007/xiuXianInvest/HEAD/requirements.txt -------------------------------------------------------------------------------- /common/config.py: -------------------------------------------------------------------------------- 1 | WIN_WIDTH = 1900 2 | WIN_HEIGHT = 900 3 | UNIT_LENGTH = 100 4 | LEFT_WIDTH = 250 5 | RIGHT_WIDTH = 250 6 | -------------------------------------------------------------------------------- /bliv/blivedm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .models import * 3 | from .handlers import * 4 | from .client import * 5 | -------------------------------------------------------------------------------- /common/color.py: -------------------------------------------------------------------------------- 1 | WHITE = (255, 255, 255) 2 | BLACK = (0, 0, 0) 3 | BLUE1 = (51, 102, 204, 0) 4 | GREEN1 = (0, 102, 0) 5 | RED1 = (255, 51, 61) -------------------------------------------------------------------------------- /common/xiuxian_state.py: -------------------------------------------------------------------------------- 1 | State = [ 2 | {"name": "炼气期", "level": 9, "exp": 30}, 3 | {"name": "筑基期", "level": 9, "exp": 50}, 4 | {"name": "金丹期", "level": 9, "exp": 70}, 5 | {"name": "元婴期", "level": 9, "exp": 90}, 6 | {"name": "化神期", "level": 9, "exp": 110}, 7 | {"name": "合道期", "level": 9, "exp": 150}, 8 | {"name": "地仙期", "level": 9, "exp": 200}, 9 | {"name": "天仙期", "level": 9, "exp": 1000}, 10 | {"name": "金仙期", "level": 9, "exp": 2000}, 11 | {"name": "太乙金仙期", "level": 9, "exp": 3000}, 12 | {"name": "大罗金仙期", "level": 9, "exp": 4000}, 13 | {"name": "准圣", "level": 9, "exp": 5000}, 14 | {"name": "圣人", "level": 9, "exp": 10000}, 15 | {"name": "天道", "level": 9, "exp": 0}, 16 | ] -------------------------------------------------------------------------------- /common/five_element.py: -------------------------------------------------------------------------------- 1 | import random 2 | from enum import Enum 3 | 4 | 5 | ALPHA = 255 6 | 7 | 8 | class FiveElementType(Enum): 9 | """ 10 | 五行:金木水火土 11 | 集群对应的颜色 12 | """ 13 | METAL = {"color": (255, 255, 0, ALPHA), "type": "金"} 14 | WOOD = {"color": (0, 51, 0, ALPHA), "type": "木"} 15 | WATER = {"color": (0, 51, 204, ALPHA), "type": "水"} 16 | FIRE = {"color": (204, 0, 0, ALPHA), "type": "火"} 17 | EARTH = {"color": (102, 51, 0, ALPHA), "type": "土"} 18 | 19 | 20 | Five_element_types = [ 21 | FiveElementType.METAL, 22 | FiveElementType.WOOD, 23 | FiveElementType.WATER, 24 | FiveElementType.FIRE, 25 | FiveElementType.EARTH, 26 | ] 27 | 28 | 29 | def get_random_five_element(): 30 | return Five_element_types[random.randint(0, 4)] 31 | -------------------------------------------------------------------------------- /game/operate_log.py: -------------------------------------------------------------------------------- 1 | import pygame.sprite 2 | from common import color 3 | from game import player_commond 4 | 5 | 6 | class OperateLog(pygame.sprite.Sprite): 7 | def __init__(self, big_font, small_font): 8 | pygame.sprite.Sprite.__init__(self) 9 | img = pygame.image.load(r".\img\alpha.png").convert_alpha() 10 | self.image = pygame.transform.scale(img, (250, 420)) 11 | self.rect = self.image.get_rect() 12 | self.rect.x = 1655 13 | self.rect.y = 450 14 | self.big_font = big_font 15 | self.small_font = small_font 16 | self.update() 17 | 18 | def update(self): 19 | title = self.big_font.render("玩家交互与操作日志", True, color.RED1) 20 | self.image.blit(title, [10, 0]) 21 | logs = player_commond.player_instance.get_log_cache() 22 | for i in range(len(logs)): 23 | t = self.small_font.render(logs[i], True, color.BLACK) 24 | self.image.blit(t, [10, 27 * (i + 1)]) 25 | -------------------------------------------------------------------------------- /common/image_source.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | 3 | from common import five_element 4 | 5 | 6 | class GameImageSource(object): 7 | _instance = None 8 | 9 | def __new__(cls, *args, **kw): 10 | if cls._instance is None: 11 | cls._instance = object.__new__(cls) 12 | return cls._instance 13 | 14 | def __init__(self): 15 | self.game_images_of_file_element = {} 16 | 17 | def load(self): 18 | metal = pygame.image.load(r".\img\metal.png").convert_alpha() 19 | wood = pygame.image.load(r".\img\wood.png").convert_alpha() 20 | water = pygame.image.load(r".\img\water.png").convert_alpha() 21 | fire = pygame.image.load(r".\img\fire.png").convert_alpha() 22 | earth = pygame.image.load(r".\img\earth.png").convert_alpha() 23 | self.game_images_of_file_element = { 24 | five_element.FiveElementType.METAL: metal, 25 | five_element.FiveElementType.WOOD: wood, 26 | five_element.FiveElementType.WATER: water, 27 | five_element.FiveElementType.FIRE: fire, 28 | five_element.FiveElementType.EARTH: earth, 29 | } 30 | 31 | def get_five_element_image(self, type: five_element.FiveElementType): 32 | self.load() 33 | return self.game_images_of_file_element[type] 34 | 35 | def get_all_alpha_img(self): 36 | return pygame.image.load(r".\img\alpha.png").convert_alpha() -------------------------------------------------------------------------------- /game/hero_ranking_list.py: -------------------------------------------------------------------------------- 1 | import pygame.sprite 2 | from game import hero 3 | from common import xiuxian_state 4 | from common import color 5 | 6 | 7 | class HeroRankingList(pygame.sprite.Sprite): 8 | def __init__(self, big_font, small_font): 9 | pygame.sprite.Sprite.__init__(self) 10 | img = pygame.image.load(r".\img\alpha.png").convert_alpha() 11 | self.image = pygame.transform.scale(img, (250, 420)) 12 | self.rect = self.image.get_rect() 13 | self.rect.x = 0 14 | self.rect.y = 0 15 | self.big_font = big_font 16 | self.small_font = small_font 17 | self.update() 18 | 19 | def update(self): 20 | self.image.fill(color.BLUE1) 21 | self.title = self.big_font.render("当前游戏修士排行榜", True, color.RED1) 22 | self.image.blit(self.title, [10, 0]) 23 | 24 | rank_info = get_rank_info() 25 | for i in range(len(rank_info)): 26 | t = self.small_font.render(str(i + 1) + " " + rank_info[i], True, color.BLACK) 27 | self.image.blit(t, [10, 27 * (i + 1)]) 28 | 29 | 30 | def get_rank_info(): 31 | rank = {} 32 | for item in hero.Heroes: 33 | if not item.alive: 34 | continue 35 | key = "编号:%d %s %s%d层 血量:%d" % (item.index, item.name, xiuxian_state.State[item.state]["name"], item.level, item.bleed) 36 | rank[key] = item.state * 100 + item.level 37 | 38 | info = [] 39 | top = 15 40 | for value in sorted(rank.items(), key=lambda kv: (kv[1], kv[0]), reverse=True): 41 | if top < 0: 42 | break 43 | top = top - 1 44 | info.append(value[0]) 45 | return info 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 洪荒修仙投资 2 | *** 3 | ## 运行说明 4 | 首先安装依赖:pip install -r requirements.txt 5 | 6 | 后面用pycharm,python版本用3.7以上即可 7 | 8 | 游戏逻辑部分是基于pygame写的,获取直播间的弹幕不是直接用的B站的官方接口,为了快速做一个demo,用的一个大佬的:[xfgryujk/blivedm](https://github.com/xfgryujk/blivedm) 9 | 10 | ## 工程说明 11 | 在B战看到弹幕游戏时的即兴之作,写完发现没啥意思,忘记整理完整上传工程了,导致后面老哥们说图片找不到,运行失败了,道具部分逻辑好像也没有保存过来....... 12 | 13 | 由于后面重装清理过电脑,导致没有办法恢复完整工程了,只能临时用相同的图片凑合着,让工程能正常运行 14 | 15 | 整个工程涉及的图片下面几张: 16 | 17 | - bg.jpg:这个是背景图片,开始用的是很多白云的天空图片 18 | - alpha.png:这个是一个完全透明(是透明,不是白色,用ps做的)的图片,没有任何内容 19 | - metal/wood/water/fire/earth.png:也是自己用ps画的,就是金木水火土的图片,金条,木头,一滴水,火焰,土块,重画太麻烦了,就算了 20 | 21 | 上面的图片目前工程是暂时用一张相同的用着,各位可以找到自己对应的图片,换上就行了(金木水火土的象征图片,背景图片找自己喜欢的一张,透明图可以直接用ps新建然后啥内容也没有,保存即可) 22 | 23 | 整个工程是个试验品,写的也比较乱了,仅做参考 24 | 25 | ## 参考链接 26 | ### 资源 27 | - [字节图标库](https://iconpark.oceanengine.com/official) 28 | - [image](https://pixabay.com/zh/images/search/) 29 | - [颜色代码表](http://www.360doc.com/content/12/0229/16/605353_190576827.shtml) 30 | 31 | ### 其他 32 | - [Python 修改 pip 源为国内源](https://zhuanlan.zhihu.com/p/109939711) 33 | - [第084讲: Pygame:基本图形绘制 | 学习记录(小甲鱼零基础入门学习Python)](https://blog.csdn.net/qq_38970783/article/details/89242624) 34 | - [Python枚举类定义和使用(详解版)](http://c.biancheng.net/view/2305.html) 35 | - [pygame 透明颜色 颜色透明度问题color alpha数值 绘制透明图形 - 探索字符串](https://string.quest/read/12966909) 36 | - [Python:如何在Pygame中使用中文](https://blog.csdn.net/wangzirui32/article/details/116100759) 37 | - [Youtirsin/pygame-samples](https://github.com/Youtirsin/pygame-samples) 38 | - [Pygame(十七)定时器](jianshu.com/p/b1017b1c10b8) 39 | - [Pygame remove a single sprite from a group](https://stackoverflow.com/questions/40632424/pygame-remove-a-single-sprite-from-a-group) -------------------------------------------------------------------------------- /game/card.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import pygame.sprite 4 | from common import color 5 | 6 | 7 | class CardType(Enum): 8 | """ 9 | 游戏卡片 10 | """ 11 | MOVE = {"need": 10, "des": "让人物移动到随机格子", "name": "移动卡"} 12 | PRACTICE = {"need": 10, "des": "修炼速度翻倍", "name": "修炼卡"} 13 | MASK = {"need": 10, "des": "屏蔽后续其他玩家卡牌影响", "name": "屏蔽卡"} 14 | DEMON = {"need": 10, "des": "制定格子上的修炼速度随机降低", "name": "心魔卡"} 15 | STOP = {"need": 10, "des": "角色下阶段不移动", "name": "静止卡"} 16 | BUJUK = {"need": 10, "des": "战败时无损伤败走", "name": "护身卡"} 17 | WUXING = {"need": 10, "des": "随机改变角色五行属性", "name": "五行卡"} 18 | 19 | 20 | cards = [ 21 | CardType.MOVE, 22 | CardType.PRACTICE, 23 | CardType.MASK, 24 | CardType.DEMON, 25 | CardType.STOP, 26 | CardType.BUJUK, 27 | CardType.WUXING, 28 | ] 29 | 30 | 31 | class Card(pygame.sprite.Sprite): 32 | def __init__(self, big_font, small_font): 33 | pygame.sprite.Sprite.__init__(self) 34 | img = pygame.image.load(r".\img\alpha.png").convert_alpha() 35 | self.image = pygame.transform.scale(img, (250, 400)) 36 | self.rect = self.image.get_rect() 37 | self.rect.x = 0 38 | self.rect.y = 450 39 | self.big_font = big_font 40 | self.small_font = small_font 41 | 42 | def update(self): 43 | self.show_play_tourist() 44 | 45 | def show_play_tourist(self): 46 | title = self.big_font.render("游戏卡牌列表", True, color.RED1) 47 | self.image.blit(title, [10, 0]) 48 | 49 | info = [ 50 | "提示:括号内为所需金币", 51 | "移动卡(10):指定人物移动到特定格子进行停留", 52 | "修炼卡(10): 修炼速度翻倍", 53 | "屏蔽卡(10):屏蔽后续其他玩家卡牌影响", 54 | "心魔卡(10):制定格子上的修炼速度随机降低", 55 | "静止卡(10):角色下阶段不移动", 56 | "护身卡(10):战败时无损伤败走:", 57 | ] 58 | for i in range(len(info)): 59 | t = self.small_font.render(info[i], True, color.BLACK) 60 | self.image.blit(t, [10, 27 * (i + 1)]) -------------------------------------------------------------------------------- /blibli/proto.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import zlib 3 | 4 | 5 | class Proto: 6 | def __init__(self): 7 | self.packetLen = 0 8 | self.headerLen = 16 9 | self.ver = 0 10 | self.op = 0 11 | self.seq = 0 12 | self.body = '' 13 | self.maxBody = 2048 14 | 15 | def pack(self): 16 | self.packetLen = len(self.body) + self.headerLen 17 | buf = struct.pack('>i', self.packetLen) 18 | buf += struct.pack('>h', self.headerLen) 19 | buf += struct.pack('>h', self.ver) 20 | buf += struct.pack('>i', self.op) 21 | buf += struct.pack('>i', self.seq) 22 | buf += self.body.encode() 23 | return buf 24 | 25 | def unpack(self, buf): 26 | if len(buf) < self.headerLen: 27 | print("包头不够") 28 | return 29 | self.packetLen = struct.unpack('>i', buf[0:4])[0] 30 | self.headerLen = struct.unpack('>h', buf[4:6])[0] 31 | self.ver = struct.unpack('>h', buf[6:8])[0] 32 | self.op = struct.unpack('>i', buf[8:12])[0] 33 | self.seq = struct.unpack('>i', buf[12:16])[0] 34 | if self.packetLen < 0 or self.packetLen > self.maxBody: 35 | print("包体长不对", "self.packetLen:", self.packetLen, 36 | " self.maxBody:", self.maxBody) 37 | return 38 | if self.headerLen != self.headerLen: 39 | print("包头长度不对") 40 | return 41 | bodyLen = self.packetLen - self.headerLen 42 | self.body = buf[16:self.packetLen] 43 | if bodyLen <= 0: 44 | return 45 | if self.ver == 0: 46 | # 这里做回调 47 | print("====> callback:", self.body.decode('utf-8')) 48 | elif self.ver == 2: 49 | # 解压 50 | self.body = zlib.decompress(self.body) 51 | bodyLen = len(self.body) 52 | offset = 0 53 | while offset < bodyLen: 54 | cmdSize = struct.unpack('>i', self.body[offset:offset+4])[0] 55 | if offset + cmdSize > bodyLen: 56 | return 57 | newProto = Proto() 58 | newProto.unpack(self.body[offset: offset+cmdSize]) 59 | offset += cmdSize 60 | else: 61 | return 62 | -------------------------------------------------------------------------------- /game/test/test_player_command.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from game import player_commond 3 | from game import hero 4 | from game import card 5 | 6 | 7 | class TestPlayer(unittest.TestCase): 8 | def setUp(self) -> None: 9 | self.play = player_commond.Player() 10 | 11 | def test_parse_invest(self): 12 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t2"), "0萧0 投资了 %s" % hero.hero_names[2]) 13 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t20"), "0萧0 投资了 %s" % hero.hero_names[20]) 14 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t125"), "0萧0 投资了 %s" % hero.hero_names[125]) 15 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t0"), "0萧0 投资了 %s" % hero.hero_names[0]) 16 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t-1"), None) 17 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:t200"), "0萧0 指令错误,角色编号不存在") 18 | 19 | def test_parse_card(self): 20 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.0"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[0].value["name"])) 21 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.1"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[1].value["name"])) 22 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.2"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[2].value["name"])) 23 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.3"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[3].value["name"])) 24 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.4"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[4].value["name"])) 25 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.5"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[5].value["name"])) 26 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.6"), "0萧0 对 %s 使用 %s" % (hero.hero_names[2], card.cards[6].value["name"])) 27 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.10"), None) 28 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j2.-1"), None) 29 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j-1.0.23"), None) 30 | self.assertEqual(self.play.exe_command("[11953515] 0萧0:j200.0"), "0萧0 指令错误") 31 | -------------------------------------------------------------------------------- /game/player_commond.py: -------------------------------------------------------------------------------- 1 | import re 2 | from game import hero 3 | from game import card 4 | 5 | class Player: 6 | def __init__(self): 7 | self.invest_reg = re.compile('.*? (?P.*?):(?Pt)(?P\d+)$') 8 | self.card_hero_reg = re.compile('.*? (?P.*?):(?Pj)(?P\d+)\.(?P[0123456])$') 9 | self.command_queue = [] 10 | self.log_cache = [] 11 | 12 | def exe_command(self, command): 13 | res = self.invest(command) 14 | if res is not None: 15 | return res 16 | 17 | res = self.hero_card(command) 18 | if res is not None: 19 | return res 20 | 21 | def invest(self, command): 22 | match = self.invest_reg.match(command) 23 | if match is None: 24 | return None 25 | 26 | res = match.groupdict() 27 | name = res["name"] 28 | hero_no = int(res["hero_no"]) 29 | if hero_no < 0 or hero_no >= len(hero.hero_names): 30 | return "%s 指令错误,角色编号不存在" % res["name"] 31 | has, invest_hero = hero.has_invest(name) 32 | if has: 33 | return invest_hero 34 | hero.add_hero_rel_player(hero_no, name) 35 | log = "%s 投资了 %s" % (res["name"], hero.hero_names[hero_no]) 36 | if len(self.log_cache) >= 15: 37 | self.log_cache.pop() 38 | self.log_cache.append(log) 39 | else: 40 | self.log_cache.append(log) 41 | return log 42 | 43 | def hero_card(self, command): 44 | match = self.card_hero_reg.match(command) 45 | if match is None: 46 | return None 47 | res = match.groupdict() 48 | print(res) 49 | name = res["name"] 50 | hero_no = int(res["hero_no"]) 51 | card_no = int(res["card_no"]) 52 | if hero_no < 0 or hero_no >= len(hero.hero_names): 53 | return "%s 指令错误" % name 54 | log = "%s 对 %s 使用 %s" % (name, hero.hero_names[hero_no], card.cards[card_no].value["name"]) 55 | if len(self.log_cache) >= 15: 56 | self.log_cache.pop() 57 | self.log_cache.append(log) 58 | else: 59 | self.log_cache.append(log) 60 | return log 61 | 62 | def get_log_cache(self): 63 | return self.log_cache 64 | 65 | 66 | player_instance = Player() 67 | -------------------------------------------------------------------------------- /game/play.py: -------------------------------------------------------------------------------- 1 | import pygame.sprite 2 | from game import hero 3 | from common import xiuxian_state 4 | from common import color 5 | 6 | 7 | class PlayInfo(pygame.sprite.Sprite): 8 | def __init__(self, big_font, small_font): 9 | pygame.sprite.Sprite.__init__(self) 10 | img = pygame.image.load(r".\img\alpha.png").convert_alpha() 11 | self.image = pygame.transform.scale(img, (250, 420)) 12 | self.rect = self.image.get_rect() 13 | self.rect.x = 1655 14 | self.rect.y = 0 15 | self.big_font = big_font 16 | self.small_font = small_font 17 | self.show_player_rank = False 18 | 19 | def update(self): 20 | self.image.fill((255, 255, 255, 0)) 21 | if self.show_player_rank: 22 | self.show_rank() 23 | self.show_player_rank = False 24 | else: 25 | self.show_play_tourist() 26 | self.show_player_rank = True 27 | 28 | def show_play_tourist(self): 29 | title = self.big_font.render("游戏入门教程", True, color.RED1) 30 | self.image.blit(title, [10, 0]) 31 | 32 | info = [ 33 | "洪荒不断轮回,选择中意的角色", 34 | "角色存活到最后,获得奖励", 35 | "金币用于购买卡牌,能干扰游戏世界", 36 | "修炼点用于提升文件修仙等级", 37 | "游戏角色死亡后,可重新投资", 38 | "", 39 | "指令说明:", 40 | "选择中意投资角色:t + 角色编号", 41 | "如:t12 (投资12号东皇太一)", 42 | "对角色使用卡牌:j + 角色编号 + . + 卡牌编号", 43 | "如:j12.2 对东皇太一使用修炼卡", 44 | "对角色使用卡牌:d + 区域编号 + . + 卡牌编号", 45 | "如:d23.3 对区域23使用地形卡", 46 | "查询当前投资的角色(结果在右下角窗口):s" 47 | ] 48 | for i in range(len(info)): 49 | t = self.small_font.render(info[i], True, color.BLACK) 50 | self.image.blit(t, [10, 27 * (i + 1)]) 51 | 52 | def show_rank(self): 53 | title = self.big_font.render("当前游戏修士排行榜", True, color.RED1) 54 | self.image.blit(title, [10, 0]) 55 | 56 | rank_info = get_rank_info() 57 | for i in range(len(rank_info)): 58 | t = self.small_font.render(str(i + 1) + " " + rank_info[i], True, color.BLACK) 59 | self.image.blit(t, [10, 27 * (i + 1)]) 60 | 61 | 62 | def get_rank_info(): 63 | rank = {} 64 | for item in hero.Heroes: 65 | if not item.alive: 66 | continue 67 | key = "%d %s%d层" % (item.index, xiuxian_state.State[item.state]["name"], item.level) 68 | rank[key] = "%d:%d" % (item.state, item.level) 69 | 70 | info = [] 71 | top = 15 72 | for value in sorted(rank.items(), key=lambda kv: (kv[1], kv[0]), reverse=True): 73 | if top < 0: 74 | break 75 | top = top - 1 76 | info.append(value[0]) 77 | return info 78 | -------------------------------------------------------------------------------- /game/land.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pygame 4 | 5 | import common.config 6 | from common.five_element import FiveElementType 7 | from common import five_element 8 | from common import config 9 | from common import color 10 | 11 | LAND_LENGTH = 100 12 | INTERVAL = 5 13 | 14 | 15 | class Land(pygame.sprite.Sprite): 16 | def __init__(self, x: int, y: int, five_element: FiveElementType, index: int, img, big_font, small_font): 17 | self.x = x + INTERVAL 18 | self.y = y + INTERVAL 19 | self.img = img 20 | pygame.sprite.Sprite.__init__(self) 21 | self.radius = 10 22 | self.image = pygame.transform.scale(self.img, (100, 100)) 23 | self.rect = self.image.get_rect() 24 | self.rect.x = self.x 25 | self.rect.y = self.y 26 | self.length = LAND_LENGTH - INTERVAL 27 | self.five_element = five_element 28 | self.index = index 29 | self.level = 1 30 | self.exp = random.randint(1, 10) * self.level 31 | self.big_font = big_font 32 | self.small_font = small_font 33 | self.update() 34 | 35 | def update(self): 36 | self.image.fill((255, 255, 255, 0)) 37 | self.image = pygame.transform.scale(self.img, (100, 100)) 38 | pygame.draw.rect(self.image, (255, 255, 255, 125), (0, 0, 100, 100), 1) 39 | 40 | number = self.big_font.render(str(self.index), True, color.BLACK) 41 | text_pos = number.get_rect(center=(15, 15)) 42 | self.image.blit(number, text_pos) 43 | 44 | five_element = self.big_font.render(self.five_element.value["type"], True, color.BLACK) 45 | type_pos = five_element.get_rect(center=(15, 80)) 46 | self.image.blit(five_element, type_pos) 47 | 48 | exp = self.small_font.render("exp:" + str(self.exp), True, color.BLACK) 49 | exp_pos = five_element.get_rect(center=(60, 20)) 50 | self.image.blit(exp, exp_pos) 51 | 52 | 53 | def create_lands(images, big_font, small_font): 54 | x_count = int((common.config.WIN_WIDTH - config.LEFT_WIDTH - config.RIGHT_WIDTH) / LAND_LENGTH) 55 | y_count = int(common.config.WIN_HEIGHT / LAND_LENGTH) 56 | nodes = [] 57 | group = pygame.sprite.Group() 58 | for i in range(x_count): 59 | for j in range(y_count): 60 | x = i * LAND_LENGTH + config.LEFT_WIDTH 61 | y = j * LAND_LENGTH 62 | element = five_element.get_random_five_element() 63 | land = Land(x, y, element, i * y_count + j, images.get_five_element_image(element), big_font, small_font) 64 | nodes.append(land) 65 | group.add(land) 66 | return nodes, group 67 | 68 | 69 | def upgrade(): 70 | for item in Lands: 71 | item.level = item.level + 1 72 | item.exp = random.randint(1, 100) * item.level 73 | item.update() 74 | 75 | 76 | def reset(): 77 | for item in Lands: 78 | item.level = 1 79 | item.exp = random.randint(0, 10) 80 | 81 | 82 | Lands, Land_group = None, None 83 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # This is a sample Python script. 2 | 3 | # Press Shift+F10 to execute it or replace it with your code. 4 | # Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings. 5 | import time 6 | from multiprocessing import Process 7 | 8 | import pika 9 | import pygame 10 | 11 | import game.control 12 | from game import hero 13 | from common import image_source 14 | from common import config 15 | from game import land 16 | import asyncio 17 | from bliv import sample 18 | import threading 19 | from game import player_commond 20 | 21 | 22 | def print_hi(name): 23 | # Use a breakpoint in the code line below to debug your script. 24 | print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint. 25 | 26 | 27 | def start_game(): 28 | pygame.init() 29 | fps = 20 30 | win_size = (config.WIN_WIDTH, config.WIN_HEIGHT) 31 | screen = pygame.display.set_mode(win_size) 32 | pygame.display.set_caption("修仙投资") 33 | 34 | font = pygame.font.SysFont("SimHei", 20) 35 | samil_font = pygame.font.SysFont("SimHei", 10) 36 | 37 | images = image_source.GameImageSource() 38 | images.load() 39 | land.Lands, land.Land_group = land.create_lands(images, font, samil_font) 40 | hero.Heroes, hero.Hero_groups = hero.create_heroes(images) 41 | 42 | game.control.start() 43 | 44 | 45 | def start_listen(): 46 | connection = pika.BlockingConnection(pika.ConnectionParameters( 47 | host='127.0.0.1', port=5672)) 48 | channel = connection.channel() 49 | channel.queue_declare(queue='test') 50 | channel.basic_consume(queue="test", on_message_callback=callback, auto_ack=True) 51 | print(' [*] Waiting for messages. To exit press CTRL+C') 52 | channel.start_consuming() 53 | 54 | 55 | class myThread (threading.Thread): 56 | def __init__(self, threadID, name, delay): 57 | threading.Thread.__init__(self) 58 | self.threadID = threadID 59 | self.name = name 60 | self.delay = delay 61 | def run(self): 62 | if self.threadID == 1: 63 | start_game() 64 | if self.threadID == 2: 65 | start_listen() 66 | print ("开始线程:" + self.name) 67 | print_time(self.name, self.delay, 5) 68 | print ("退出线程:" + self.name) 69 | 70 | 71 | exitFlag = 0 72 | 73 | 74 | def print_time(threadName, delay, counter): 75 | while counter: 76 | if exitFlag: 77 | threadName.exit() 78 | time.sleep(delay) 79 | print ("%s: %s" % (threadName, time.ctime(time.time()))) 80 | counter -= 1 81 | 82 | 83 | def callback(ch, method, properties, body): 84 | '''回调函数,处理从rabbitmq中取出的消息''' 85 | msg = body.decode() 86 | print(" [x] Received %r" % msg) 87 | player = player_commond.player_instance 88 | player.exe_command(msg) 89 | 90 | 91 | # Press the green button in the gutter to run the script. 92 | if __name__ == '__main__': 93 | # game = Process(target=start_game) 94 | # game.start() 95 | # 96 | # bliv = Process(target=start_listen) 97 | # bliv .start() 98 | # 99 | # game.join() 100 | # bliv.join() 101 | 102 | thread1 = myThread(1, "Thread-1", 1) 103 | thread2 = myThread(2, "Thread-2", 2) 104 | thread1.start() 105 | thread2.start() 106 | thread1.join() 107 | thread2.join() 108 | 109 | while True: 110 | time.sleep(60 * 60 * 24) 111 | 112 | 113 | # See PyCharm help at https://www.jetbrains.com/help/pycharm/ 114 | -------------------------------------------------------------------------------- /bliv/sample.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import random 4 | 5 | import pika 6 | 7 | from bliv import blivedm 8 | from game import player_commond 9 | 10 | # 直播间ID的取值看直播间URL 11 | TEST_ROOM_IDS = [ 12 | 11953515, 13 | ] 14 | 15 | 16 | async def main(): 17 | await run_single_client() 18 | # await run_multi_client() 19 | 20 | 21 | async def run_single_client(): 22 | """ 23 | 演示监听一个直播间 24 | """ 25 | room_id = random.choice(TEST_ROOM_IDS) 26 | # 如果SSL验证失败就把ssl设为False,B站真的有过忘续证书的情况 27 | client = blivedm.BLiveClient(room_id, ssl=True) 28 | handler = MyHandler() 29 | client.add_handler(handler) 30 | 31 | client.start() 32 | try: 33 | # 演示5秒后停止 34 | # await asyncio.sleep(60) 35 | # client.stop() 36 | 37 | await client.join() 38 | finally: 39 | await client.stop_and_close() 40 | 41 | 42 | async def run_multi_client(): 43 | """ 44 | 演示同时监听多个直播间 45 | """ 46 | clients = [blivedm.BLiveClient(room_id) for room_id in TEST_ROOM_IDS] 47 | handler = MyHandler() 48 | for client in clients: 49 | client.add_handler(handler) 50 | client.start() 51 | 52 | try: 53 | await asyncio.gather(*( 54 | client.join() for client in clients 55 | )) 56 | finally: 57 | await asyncio.gather(*( 58 | client.stop_and_close() for client in clients 59 | )) 60 | 61 | 62 | class MyHandler(blivedm.BaseHandler): 63 | # # 演示如何添加自定义回调 64 | # _CMD_CALLBACK_DICT = blivedm.BaseHandler._CMD_CALLBACK_DICT.copy() 65 | # 66 | # # 入场消息回调 67 | # async def __interact_word_callback(self, client: blivedm.BLiveClient, command: dict): 68 | # print(f"[{client.room_id}] INTERACT_WORD: self_type={type(self).__name__}, room_id={client.room_id}," 69 | # f" uname={command['data']['uname']}") 70 | # _CMD_CALLBACK_DICT['INTERACT_WORD'] = __interact_word_callback # noqa 71 | def __init__(self): 72 | self.connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost')) 73 | self.channel = self.connection.channel() 74 | self.channel.queue_declare(queue='test') # 声明队列以向其发送消息消息 75 | 76 | async def _on_heartbeat(self, client: blivedm.BLiveClient, message: blivedm.HeartbeatMessage): 77 | print(f'[{client.room_id}] 当前人气值:{message.popularity}') 78 | 79 | async def _on_danmaku(self, client: blivedm.BLiveClient, message: blivedm.DanmakuMessage): 80 | command = f'[{client.room_id}] {message.uname}:{message.msg}' 81 | # self.channel.basic_publish(exchange='', routing_key='test', body=command) 82 | self.channel.basic_publish(exchange='', routing_key='test', body="%d %s:%s" % (client.room_id, message.uname, message.msg)) 83 | print(command) 84 | # res = player_commond.player_instance.exe_command(command) 85 | # print(res) 86 | 87 | async def _on_gift(self, client: blivedm.BLiveClient, message: blivedm.GiftMessage): 88 | print(f'[{client.room_id}] {message.uname} 赠送{message.gift_name}x{message.num}' 89 | f' ({message.coin_type}瓜子x{message.total_coin})') 90 | 91 | async def _on_buy_guard(self, client: blivedm.BLiveClient, message: blivedm.GuardBuyMessage): 92 | print(f'[{client.room_id}] {message.username} 购买{message.gift_name}') 93 | 94 | async def _on_super_chat(self, client: blivedm.BLiveClient, message: blivedm.SuperChatMessage): 95 | print(f'[{client.room_id}] 醒目留言 ¥{message.price} {message.uname}:{message.message}') 96 | 97 | 98 | if __name__ == '__main__': 99 | asyncio.get_event_loop().run_until_complete(main()) 100 | 101 | -------------------------------------------------------------------------------- /blibli/ws.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | 5 | import websockets 6 | import requests 7 | import time 8 | import hashlib 9 | import hmac 10 | import random 11 | from hashlib import sha256 12 | import proto 13 | 14 | 15 | class BiliClient: 16 | def __init__(self, roomId, key, secret, host='live-open.biliapi.com'): 17 | self.roomId = roomId 18 | self.key = key 19 | self.secret = secret 20 | self.host = host 21 | pass 22 | 23 | # 事件循环 24 | def run(self): 25 | loop = asyncio.get_event_loop() 26 | websocket = loop.run_until_complete(self.connect()) 27 | tasks = [ 28 | asyncio.ensure_future(self.recvLoop(websocket)), 29 | asyncio.ensure_future(self.heartBeat(websocket)), 30 | ] 31 | loop.run_until_complete(asyncio.gather(*tasks)) 32 | 33 | # http的签名 34 | def sign(self, params): 35 | key = self.key 36 | secret = self.secret 37 | md5 = hashlib.md5() 38 | md5.update(params.encode()) 39 | ts = time.time() 40 | nonce = random.randint(1, 100000) + time.time() 41 | md5data = md5.hexdigest() 42 | headerMap = { 43 | "x-bili-timestamp": str(int(ts)), 44 | "x-bili-signature-method": "HMAC-SHA256", 45 | "x-bili-signature-nonce": str(nonce), 46 | "x-bili-accesskeyid": key, 47 | "x-bili-signature-version": "1.0", 48 | "x-bili-content-md5": md5data, 49 | } 50 | 51 | headerList = sorted(headerMap) 52 | headerStr = '' 53 | 54 | for key in headerList: 55 | headerStr = headerStr + key + ":" + str(headerMap[key]) + "\n" 56 | headerStr = headerStr.rstrip("\n") 57 | 58 | appsecret = secret.encode() 59 | data = headerStr.encode() 60 | signature = hmac.new(appsecret, data, digestmod=sha256).hexdigest() 61 | headerMap["Authorization"] = signature 62 | headerMap["Content-Type"] = "application/json" 63 | headerMap["Accept"] = "application/json" 64 | return headerMap 65 | 66 | # 获取长链信息 67 | def websocketInfoReq(self, postUrl, params): 68 | headerMap = self.sign(params) 69 | r = requests.post(url=postUrl, headers=headerMap, data=params, verify=False) 70 | data = json.loads(r.content) 71 | print(data) 72 | return "ws://" + data['data']['host'][0] + ":" + str(data['data']['ws_port'][0]) + "/sub", data['data'][ 73 | 'auth_body'] 74 | 75 | # 长链的auth包 76 | async def auth(self, websocket, authBody): 77 | req = proto.Proto() 78 | req.body = authBody 79 | req.op = 7 80 | await websocket.send(req.pack()) 81 | buf = await websocket.recv() 82 | resp = proto.Proto() 83 | resp.unpack(buf) 84 | respBody = json.loads(resp.body) 85 | if respBody["code"] != 0: 86 | print("auth 失败") 87 | else: 88 | print("auth 成功") 89 | 90 | # 长链的心跳包 91 | async def heartBeat(self, websocket): 92 | while True: 93 | await asyncio.ensure_future(asyncio.sleep(20)) 94 | req = proto.Proto() 95 | req.op = 2 96 | await websocket.send(req.pack()) 97 | print("[BiliClient] send heartBeat success") 98 | 99 | # 长链的接受循环 100 | async def recvLoop(self, websocket): 101 | print("[BiliClient] run recv...") 102 | while True: 103 | recvBuf = await websocket.recv() 104 | resp = proto.Proto() 105 | resp.unpack(recvBuf) 106 | 107 | async def connect(self): 108 | postUrl = "https://%s/v1/common/websocketInfo" % self.host 109 | params = '{"room_id":%s}' % self.roomId 110 | addr, authBody = self.websocketInfoReq(postUrl, params) 111 | print(addr, authBody) 112 | websocket = await websockets.connect(addr) 113 | await self.auth(websocket, authBody) 114 | return websocket 115 | 116 | 117 | if __name__ == '__main__': 118 | try: 119 | env_dist = os.environ 120 | cli = BiliClient( 121 | roomId=env_dist.get("BLIV_RID"), 122 | key=env_dist.get("BLIV_KEY"), 123 | secret=env_dist.get("BLIV_SECRET"), 124 | host="live-open.biliapi.com") 125 | cli.run() 126 | except Exception as e: 127 | print("err", e) 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Example user template template 75 | ### Example user template 76 | 77 | # IntelliJ project files 78 | .idea 79 | *.iml 80 | out 81 | gen 82 | ### Python template 83 | # Byte-compiled / optimized / DLL files 84 | __pycache__/ 85 | *.py[cod] 86 | *$py.class 87 | 88 | # C extensions 89 | *.so 90 | 91 | # Distribution / packaging 92 | .Python 93 | build/ 94 | develop-eggs/ 95 | dist/ 96 | downloads/ 97 | eggs/ 98 | .eggs/ 99 | lib/ 100 | lib64/ 101 | parts/ 102 | sdist/ 103 | var/ 104 | wheels/ 105 | share/python-wheels/ 106 | *.egg-info/ 107 | .installed.cfg 108 | *.egg 109 | MANIFEST 110 | 111 | # PyInstaller 112 | # Usually these files are written by a python script from a template 113 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 114 | *.manifest 115 | *.spec 116 | 117 | # Installer logs 118 | pip-log.txt 119 | pip-delete-this-directory.txt 120 | 121 | # Unit test / coverage reports 122 | htmlcov/ 123 | .tox/ 124 | .nox/ 125 | .coverage 126 | .coverage.* 127 | .cache 128 | nosetests.xml 129 | coverage.xml 130 | *.cover 131 | *.py,cover 132 | .hypothesis/ 133 | .pytest_cache/ 134 | cover/ 135 | 136 | # Translations 137 | *.mo 138 | *.pot 139 | 140 | # Django stuff: 141 | *.log 142 | local_settings.py 143 | db.sqlite3 144 | db.sqlite3-journal 145 | 146 | # Flask stuff: 147 | instance/ 148 | .webassets-cache 149 | 150 | # Scrapy stuff: 151 | .scrapy 152 | 153 | # Sphinx documentation 154 | docs/_build/ 155 | 156 | # PyBuilder 157 | .pybuilder/ 158 | target/ 159 | 160 | # Jupyter Notebook 161 | .ipynb_checkpoints 162 | 163 | # IPython 164 | profile_default/ 165 | ipython_config.py 166 | 167 | # pyenv 168 | # For a library or package, you might want to ignore these files since the code is 169 | # intended to run in multiple environments; otherwise, check them in: 170 | # .python-version 171 | 172 | # pipenv 173 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 174 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 175 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 176 | # install all needed dependencies. 177 | #Pipfile.lock 178 | 179 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 180 | __pypackages__/ 181 | 182 | # Celery stuff 183 | celerybeat-schedule 184 | celerybeat.pid 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | .dmypy.json 211 | dmypy.json 212 | 213 | # Pyre type checker 214 | .pyre/ 215 | 216 | # pytype static type analyzer 217 | .pytype/ 218 | 219 | # Cython debug symbols 220 | cython_debug/ 221 | 222 | -------------------------------------------------------------------------------- /bliv/blivedm/handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | from typing import * 4 | 5 | from . import client as client_ 6 | from . import models 7 | 8 | __all__ = ( 9 | 'HandlerInterface', 10 | 'BaseHandler', 11 | ) 12 | 13 | logger = logging.getLogger('blivedm') 14 | 15 | # 常见可忽略的cmd 16 | IGNORED_CMDS = ( 17 | 'COMBO_SEND', 18 | 'ENTRY_EFFECT', 19 | 'HOT_RANK_CHANGED', 20 | 'HOT_RANK_CHANGED_V2', 21 | 'INTERACT_WORD', 22 | 'LIVE', 23 | 'LIVE_INTERACTIVE_GAME', 24 | 'NOTICE_MSG', 25 | 'ONLINE_RANK_COUNT', 26 | 'ONLINE_RANK_TOP3', 27 | 'ONLINE_RANK_V2', 28 | 'PK_BATTLE_END', 29 | 'PK_BATTLE_FINAL_PROCESS', 30 | 'PK_BATTLE_PROCESS', 31 | 'PK_BATTLE_PROCESS_NEW', 32 | 'PK_BATTLE_SETTLE', 33 | 'PK_BATTLE_SETTLE_USER', 34 | 'PK_BATTLE_SETTLE_V2', 35 | 'PREPARING', 36 | 'ROOM_REAL_TIME_MESSAGE_UPDATE', 37 | 'STOP_LIVE_ROOM_LIST', 38 | 'SUPER_CHAT_MESSAGE_JPN', 39 | 'WIDGET_BANNER', 40 | ) 41 | 42 | # 已打日志的未知cmd 43 | logged_unknown_cmds = set() 44 | 45 | 46 | class HandlerInterface: 47 | """ 48 | 直播消息处理器接口 49 | """ 50 | 51 | async def handle(self, client: client_.BLiveClient, command: dict): 52 | raise NotImplementedError 53 | 54 | 55 | class BaseHandler(HandlerInterface): 56 | """ 57 | 一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器 58 | """ 59 | 60 | def __heartbeat_callback(self, client: client_.BLiveClient, command: dict): 61 | return self._on_heartbeat(client, models.HeartbeatMessage.from_command(command['data'])) 62 | 63 | def __danmu_msg_callback(self, client: client_.BLiveClient, command: dict): 64 | return self._on_danmaku(client, models.DanmakuMessage.from_command(command['info'])) 65 | 66 | def __send_gift_callback(self, client: client_.BLiveClient, command: dict): 67 | return self._on_gift(client, models.GiftMessage.from_command(command['data'])) 68 | 69 | def __guard_buy_callback(self, client: client_.BLiveClient, command: dict): 70 | return self._on_buy_guard(client, models.GuardBuyMessage.from_command(command['data'])) 71 | 72 | def __super_chat_message_callback(self, client: client_.BLiveClient, command: dict): 73 | return self._on_super_chat(client, models.SuperChatMessage.from_command(command['data'])) 74 | 75 | def __super_chat_message_delete_callback(self, client: client_.BLiveClient, command: dict): 76 | return self._on_super_chat_delete(client, models.SuperChatDeleteMessage.from_command(command['data'])) 77 | 78 | # cmd -> 处理回调 79 | _CMD_CALLBACK_DICT: Dict[ 80 | str, 81 | Optional[Callable[ 82 | ['BaseHandler', client_.BLiveClient, dict], 83 | Awaitable 84 | ]] 85 | ] = { 86 | # 收到心跳包,这是blivedm自造的消息,原本的心跳包格式不一样 87 | '_HEARTBEAT': __heartbeat_callback, 88 | # 收到弹幕 89 | # go-common\app\service\live\live-dm\service\v1\send.go 90 | 'DANMU_MSG': __danmu_msg_callback, 91 | # 有人送礼 92 | 'SEND_GIFT': __send_gift_callback, 93 | # 有人上舰 94 | 'GUARD_BUY': __guard_buy_callback, 95 | # 醒目留言 96 | 'SUPER_CHAT_MESSAGE': __super_chat_message_callback, 97 | # 删除醒目留言 98 | 'SUPER_CHAT_MESSAGE_DELETE': __super_chat_message_delete_callback, 99 | } 100 | # 忽略其他常见cmd 101 | for cmd in IGNORED_CMDS: 102 | _CMD_CALLBACK_DICT[cmd] = None 103 | del cmd 104 | 105 | async def handle(self, client: client_.BLiveClient, command: dict): 106 | cmd = command.get('cmd', '') 107 | pos = cmd.find(':') # 2019-5-29 B站弹幕升级新增了参数 108 | if pos != -1: 109 | cmd = cmd[:pos] 110 | 111 | if cmd not in self._CMD_CALLBACK_DICT: 112 | # 只有第一次遇到未知cmd时打日志 113 | if cmd not in logged_unknown_cmds: 114 | logger.warning('room=%d unknown cmd=%s, command=%s', client.room_id, cmd, command) 115 | logged_unknown_cmds.add(cmd) 116 | return 117 | 118 | callback = self._CMD_CALLBACK_DICT[cmd] 119 | if callback is not None: 120 | await callback(self, client, command) 121 | 122 | async def _on_heartbeat(self, client: client_.BLiveClient, message: models.HeartbeatMessage): 123 | """ 124 | 收到心跳包(人气值) 125 | """ 126 | 127 | async def _on_danmaku(self, client: client_.BLiveClient, message: models.DanmakuMessage): 128 | """ 129 | 收到弹幕 130 | """ 131 | 132 | async def _on_gift(self, client: client_.BLiveClient, message: models.GiftMessage): 133 | """ 134 | 收到礼物 135 | """ 136 | 137 | async def _on_buy_guard(self, client: client_.BLiveClient, message: models.GuardBuyMessage): 138 | """ 139 | 有人上舰 140 | """ 141 | 142 | async def _on_super_chat(self, client: client_.BLiveClient, message: models.SuperChatMessage): 143 | """ 144 | 醒目留言 145 | """ 146 | 147 | async def _on_super_chat_delete(self, client: client_.BLiveClient, message: models.SuperChatDeleteMessage): 148 | """ 149 | 删除醒目留言 150 | """ 151 | -------------------------------------------------------------------------------- /game/control.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | import pygame 5 | 6 | import game.land 7 | from game import hero 8 | from common import config 9 | from game import hero_ranking_list 10 | from game import play 11 | from game import card 12 | from game import operate_log 13 | from common import xiuxian_state 14 | from common import image_source 15 | 16 | 17 | end_game_stamp = None 18 | game_pre_stamp = None 19 | 20 | 21 | def start(): 22 | fps = 20 23 | win_size = (config.WIN_WIDTH, config.WIN_HEIGHT) 24 | screen = pygame.display.set_mode(win_size) 25 | pygame.display.set_caption("修仙投资") 26 | 27 | background = pygame.image.load(r".\img\bg.jpg") 28 | 29 | font = pygame.font.SysFont("SimHei", 20) 30 | samil_font = pygame.font.SysFont("SimHei", 10) 31 | 32 | # 角色修炼事件 33 | hero_exp_event = pygame.USEREVENT + 1 34 | pygame.time.set_timer(hero_exp_event, 1000) 35 | 36 | # 角色气运劫:角色开始移动,期间膨胀发送战斗 37 | hero_move_event = pygame.USEREVENT + 2 38 | pygame.time.set_timer(hero_move_event, 1000 * 30) 39 | 40 | # 游戏角色排行榜 41 | hero_ranking = hero_ranking_list.HeroRankingList(font, samil_font) 42 | hero_ranking_group = pygame.sprite.Group() 43 | hero_ranking_group.add(hero_ranking) 44 | 45 | # 玩家排行和教程显示 46 | play_info_event = pygame.USEREVENT + 4 47 | pygame.time.set_timer(play_info_event, 1000 * 10) 48 | play_info = play.PlayInfo(font, samil_font) 49 | play_info_group = pygame.sprite.Group() 50 | play_info_group.add(play_info) 51 | play_info_group.update() 52 | 53 | # 卡牌详情展示 54 | card_info = card.Card(font, samil_font) 55 | card_info_group = pygame.sprite.Group() 56 | card_info_group.add(card_info) 57 | card_info_group.update() 58 | 59 | # 操作日志显示 60 | operate_group = pygame.sprite.Group() 61 | operate_win = operate_log.OperateLog(font, samil_font) 62 | operate_group.add(operate_win) 63 | 64 | # 灵气潮汐:修炼速度加快 65 | reiki_event = pygame.USEREVENT + 5 66 | pygame.time.set_timer(reiki_event, 1000 * 5) 67 | 68 | clock = pygame.time.Clock() 69 | 70 | while True: 71 | for event in pygame.event.get(): 72 | if event.type == pygame.QUIT: 73 | sys.exit() 74 | elif event.type == hero_move_event: 75 | hero.random_move(samil_font) 76 | elif event.type == hero_exp_event: 77 | hero.update_exp(samil_font) 78 | if event.type == play_info_event: 79 | play_info_group.update() 80 | if event.type == reiki_event: 81 | game.land.upgrade() 82 | 83 | screen.fill((255, 255, 255)) 84 | screen.blit(background, (0, 0)) 85 | 86 | if game.control.game_pre_stamp is not None: 87 | pre_game(screen, screen, font, clock, fps) 88 | continue 89 | 90 | if is_end(): 91 | game.control.game_pre_stamp = time.time() 92 | continue 93 | 94 | game.land.Land_group.draw(screen) 95 | 96 | hero_ranking.update() 97 | hero.Hero_groups.update() 98 | operate_group.update() 99 | hero.collide(samil_font) 100 | hero.Hero_groups.draw(screen) 101 | 102 | hero_ranking_group.draw(screen) 103 | play_info_group.draw(screen) 104 | card_info_group.draw(screen) 105 | operate_group.draw(screen) 106 | 107 | pygame.display.flip() 108 | clock.tick(fps) 109 | 110 | 111 | def pre_game(screen, surface2, font, clock, fps): 112 | interval = int(time.time()) - int(game.control.game_pre_stamp) 113 | if interval > 30: 114 | game.control.game_pre_stamp = None 115 | game.land.reset() 116 | images = image_source.GameImageSource() 117 | images.load() 118 | hero.Heroes, hero.Hero_groups = hero.create_heroes(images) 119 | 120 | text = [ 121 | get_win_hero(), 122 | "新的轮回开始了,当前阶段盲选,成功登顶后,奖励翻10倍", 123 | "输入 t + 角色编号进行投资吧", 124 | "当前角色编号范围:0-125", 125 | "%d秒后,开始游戏" % (30-interval), 126 | ] 127 | for i in range(len(text)): 128 | end_info = font.render(text[i], True, (255, 10, 10)) 129 | text_pos = end_info.get_rect(center=(800, 400 + 20 * i)) 130 | screen.blit(end_info, text_pos) 131 | 132 | screen.blit(surface2, (0, 0)) 133 | pygame.display.flip() 134 | clock.tick(fps) 135 | 136 | 137 | def is_end(): 138 | alive = 0 139 | has_top = False 140 | for item in hero.Heroes: 141 | if item.alive: 142 | alive = alive + 1 143 | if item.state == (len(xiuxian_state.State) - 1): 144 | has_top = True 145 | break 146 | if has_top or alive <= 1: 147 | return True 148 | return False 149 | 150 | 151 | def end(screen, surface2, font, clock, fps): 152 | if game.control.end_game_stamp is None: 153 | game.control.end_game_stamp = time.time() 154 | elif int(time.time()) - int(game.control.end_game_stamp) > 5: 155 | game.control.game_pre_stamp = time.time() 156 | 157 | text = [ 158 | "此轮游戏结束,将开始下次轮回", 159 | "修仙之巅:" + get_win_hero(), 160 | ] 161 | for i in range(len(text)): 162 | end_info = font.render(text[i], True, (255, 10, 10)) 163 | text_pos = end_info.get_rect(center=(800, 400 + 20 * i)) 164 | screen.blit(end_info, text_pos) 165 | 166 | screen.blit(surface2, (0, 0)) 167 | pygame.display.flip() 168 | clock.tick(fps) 169 | 170 | 171 | def get_win_hero(): 172 | rank = {} 173 | for item in hero.Heroes: 174 | if not item.alive: 175 | continue 176 | rank[item.state * 100 + item.level] = item 177 | 178 | info = "" 179 | top = 3 180 | for key in sorted(rank.keys(), reverse=True): 181 | if top < 0: 182 | break 183 | top = top - 1 184 | info += "第%d名:%s %s;" % (top, rank[key].name, xiuxian_state.State[rank[key].state]["name"]) 185 | return info 186 | -------------------------------------------------------------------------------- /game/hero.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pygame.sprite 4 | 5 | from common.five_element import FiveElementType 6 | from common import config 7 | from common import five_element 8 | from game import land 9 | from common import xiuxian_state 10 | 11 | 12 | hero_names = [ 13 | "东皇太一", 14 | "昊天", 15 | "女娲", 16 | "伏羲", 17 | "神农", 18 | "轩辕", 19 | "句龙", 20 | "祝融", 21 | "共工", 22 | "飞廉", 23 | "夸父", 24 | "蚩尤", 25 | "刑天", 26 | "仓颉", 27 | "喾", 28 | "尧", 29 | "舜", 30 | "禹", 31 | "后羿", 32 | "羲和", 33 | "嫦娥", 34 | "常羲", 35 | "应龙", 36 | "女魃", 37 | "旱魃", 38 | "烛龙", 39 | "元始", 40 | "通天", 41 | "太上", 42 | "紫微大帝", 43 | "青华大帝", 44 | "西王母", 45 | "东王公", 46 | "真武大帝", 47 | "玉虚真人", 48 | "佑圣真人", 49 | "斗姆帝君", 50 | "汉锺离", 51 | "吕洞宾", 52 | "张果老", 53 | "韩湘子", 54 | "铁拐李", 55 | "何仙姑", 56 | "蓝采和", 57 | "曹国舅", 58 | "鬼谷子", 59 | "张道陵", 60 | "许逊", 61 | "葛玄", 62 | "萨守坚", 63 | "魏伯阳", 64 | "魏华存", 65 | "葛洪", 66 | "太白金星", 67 | "毕方", 68 | "二郎神", 69 | "赵公明", 70 | "温元帅", 71 | "康元帅", 72 | "马天君", 73 | "王天君", 74 | "钟馗", 75 | "孟婆", 76 | "陆压", 77 | "接引", 78 | "准提", 79 | "广成子", 80 | "赤精子", 81 | "云中子", 82 | "雷震子", 83 | "韦护", 84 | "李靖", 85 | "金吒", 86 | "木吒", 87 | "哪吒", 88 | "牛郎", 89 | "织女", 90 | "千里眼", 91 | "顺风耳", 92 | "月老", 93 | "镇元大仙", 94 | "菩提祖师", 95 | "天蓬元帅", 96 | "卷帘大将", 97 | "三藏", 98 | "孙悟空", 99 | "牛魔王", 100 | "蛟魔王", 101 | "鹏魔王", 102 | "狮狔王", 103 | "猕猴王", 104 | "禺狨王", 105 | "六耳", 106 | "鲲鹏", 107 | "白泽", 108 | "石矶", 109 | "太乙", 110 | "姜尚", 111 | "申公豹", 112 | "云霄", 113 | "碧霄", 114 | "琼霄", 115 | "金光仙", 116 | "乌云仙", 117 | "毗卢仙", 118 | "灵牙仙", 119 | "虬首仙", 120 | "金箍仙", 121 | "定光仙", 122 | "多宝道人", 123 | "金灵圣母", 124 | "无当圣母", 125 | "龟灵圣母", 126 | "秦完", 127 | "赵江", 128 | "董全", 129 | "袁角", 130 | "金光圣母", 131 | "孙良", 132 | "白礼", 133 | "姚宾", 134 | "王奕", 135 | "张绍", 136 | "九曜星官", 137 | "闻仲", 138 | "鸿均", 139 | ] 140 | 141 | 142 | class Hero(pygame.sprite.Sprite): 143 | def __init__(self, index: int, x: int, y: int, five_element: FiveElementType, images): 144 | pygame.sprite.Sprite.__init__(self) 145 | self.radius = 10 146 | self.img = images.get_all_alpha_img() 147 | self.image = pygame.transform.scale(self.img, (60, 60)) 148 | self.rect = self.image.get_rect() 149 | self.rect.x = x - 10 150 | self.rect.y = y - 40 151 | self.collide_type = "hero" 152 | self.x = x 153 | self.y = y 154 | self.five_element = five_element 155 | self.state = 0 156 | self.exp = 0 157 | self.level = 1 158 | self.log = "" 159 | self.is_top = False 160 | self.is_moving = False 161 | self.target_x = None 162 | self.target_y = None 163 | self.speed = 1 164 | self.index = index 165 | self.bleed = 100 166 | self.alive = True 167 | self.attack = 1 * self.level + 10 * self.state 168 | self.is_attack = False 169 | self.name = "无名" 170 | self.font = pygame.font.SysFont("SimHei", 10) 171 | if self.index < len(hero_names): 172 | self.name = hero_names[self.index] 173 | 174 | def update(self): 175 | if self.is_moving: 176 | self.moving() 177 | 178 | def update_info(self, font): 179 | self.image.fill((255, 255, 255, 0)) 180 | 181 | color = self.five_element.value["color"] 182 | pygame.draw.circle(self.image, color, (10, 50), self.radius, 10) 183 | 184 | if self.five_element == five_element.FiveElementType.METAL: 185 | color = (0, 0, 0) 186 | number = self.font.render(str(self.index), True, (0, 0, 0)) 187 | else: 188 | number = self.font.render(str(self.index), True, (255, 255, 255)) 189 | self.image.blit(number, [2, 45]) 190 | 191 | state_str = "%s%d层" % (xiuxian_state.State[self.state]["name"], self.level) 192 | title = self.font.render(state_str, True, color) 193 | self.image.blit(title, [0, 20]) 194 | 195 | if self.log != "": 196 | log = font.render(self.log, True, color) 197 | self.image.blit(log, [0, 0]) 198 | 199 | name = self.font.render(self.name, True, color) 200 | self.image.blit(name, [20, 45]) 201 | 202 | def moving(self): 203 | x_direct = 1 204 | if (self.target_x - self.rect.x) < 0: 205 | x_direct = -1 206 | y_direct = 1 207 | if (self.target_y - self.rect.y) < 0: 208 | y_direct = -1 209 | 210 | if self.rect.x == self.target_x and self.rect.y == self.target_y: 211 | self.is_moving = False 212 | return 213 | 214 | if self.rect.x != self.target_x: 215 | self.rect.x = self.rect.x + self.speed * x_direct 216 | if self.rect.y != self.target_y: 217 | self.rect.y = self.rect.y + self.speed * y_direct 218 | 219 | if self.rect.x < 0: 220 | self.rect.x = 0 221 | elif self.rect.x + self.radius > config.WIN_WIDTH: 222 | self.rect.x = config.WIN_WIDTH - self.radius * 2 223 | if self.rect.y < 0: 224 | self.rect.y = 0 225 | elif self.rect.y + self.radius > config.WIN_HEIGHT: 226 | self.rect.y = config.WIN_HEIGHT - self.radius 227 | 228 | def get_exp(self, font): 229 | if self.is_top or self.is_moving or self.is_attack: 230 | return 231 | 232 | self.log = "" 233 | 234 | y_count = int(config.WIN_HEIGHT / config.UNIT_LENGTH) 235 | i = int((self.rect.x - 50 - 20 - config.LEFT_WIDTH) / config.UNIT_LENGTH) 236 | j = int((self.rect.y - 75 - 30) / config.UNIT_LENGTH) 237 | land_index = i * y_count + j 238 | if land_index >= len(land.Lands): 239 | return 240 | cur_land = land.Lands[land_index] 241 | add_exp = cur_land.exp 242 | if self.five_element == cur_land.five_element: 243 | add_exp = add_exp * 2 244 | self.exp = self.exp + add_exp 245 | self.log = "修炼+%d" % add_exp 246 | 247 | if self.state >= len(xiuxian_state.State): 248 | return 249 | 250 | while self.exp >= xiuxian_state.State[self.state]["exp"]: 251 | self.exp = self.exp - xiuxian_state.State[self.state]["exp"] 252 | self.level = self.level + 1 253 | 254 | if self.level >= xiuxian_state.State[self.state]["level"]: 255 | self.state = self.state + 1 256 | self.level = 1 257 | 258 | if self.state < len(xiuxian_state.State): 259 | self.log = "突破:%s%d层" % (xiuxian_state.State[self.state]["name"], self.level) 260 | else: 261 | self.is_top = True 262 | self.state = self.state - 1 263 | self.level = xiuxian_state.State[self.state]["level"] 264 | break 265 | 266 | self.bleed = 1000 * (self.state + 1) 267 | self.attack = 1 * self.level + 10 * self.state 268 | self.update_info(font) 269 | 270 | def collide(self, grp, font): 271 | self.is_attack = False 272 | if pygame.sprite.spritecollideany(self, grp): 273 | target = pygame.sprite.spritecollideany(self, grp) 274 | if target.collide_type == 'hero': 275 | if self.index == target.index: 276 | return 277 | cur_hero = "%s%d层" % (xiuxian_state.State[self.state]["name"], self.level) 278 | target_hero = "%s%d层" % (xiuxian_state.State[target.state]["name"], target.level) 279 | # print("发生碰撞:", self.index, target.index, cur_hero, target_hero) 280 | self.fire(target, font) 281 | self.is_attack = True 282 | 283 | def fire(self, target, font): 284 | reduce = random.randint(0, target.attack) 285 | self.bleed = self.bleed - reduce 286 | self.log = "血量-%d" % reduce 287 | 288 | reduce = random.randint(0, self.attack) 289 | target.bleed = target.bleed - reduce 290 | target.log = "血量-%d" % reduce 291 | 292 | self.update_info(font) 293 | target.update_info(font) 294 | dead_check(self) 295 | dead_check(target) 296 | 297 | 298 | def dead_check(hero: Hero): 299 | if hero.bleed < 1: 300 | if random.randint(0, 10) == 5: 301 | hero.kill() 302 | hero.alive = False 303 | print(hero.index, "被击杀") 304 | else: 305 | if hero.state > 1: 306 | hero.state = hero.state - 1 307 | x_count = int(config.WIN_WIDTH / config.UNIT_LENGTH) 308 | y_count = int(config.WIN_HEIGHT / config.UNIT_LENGTH) 309 | random_x = random.randint(0, x_count - 1) * config.UNIT_LENGTH + 30 310 | random_y = random.randint(0, y_count - 1) * config.UNIT_LENGTH + 55 311 | hero.target_x = random_x 312 | hero.target_y = random_y 313 | 314 | 315 | def create_heroes(images): 316 | x_count = int((config.WIN_WIDTH - config.LEFT_WIDTH - config.RIGHT_WIDTH) / config.UNIT_LENGTH) 317 | y_count = int(config.WIN_HEIGHT / config.UNIT_LENGTH) 318 | list = [] 319 | group = pygame.sprite.Group() 320 | for i in range(x_count): 321 | for j in range(y_count): 322 | x = i * config.UNIT_LENGTH + 50 + config.LEFT_WIDTH 323 | y = j * config.UNIT_LENGTH + 75 324 | sprite = Hero(i * y_count + j, x, y, five_element.get_random_five_element(), images) 325 | list.append(sprite) 326 | group.add(sprite) 327 | return list, group 328 | 329 | 330 | def random_move(font): 331 | x_count = int((config.WIN_WIDTH - config.LEFT_WIDTH - config.RIGHT_WIDTH) / config.UNIT_LENGTH) 332 | y_count = int(config.WIN_HEIGHT / config.UNIT_LENGTH) 333 | for item in Heroes: 334 | if not item.alive: 335 | continue 336 | random_x = random.randint(0, x_count-1) * config.UNIT_LENGTH + 40 + config.LEFT_WIDTH 337 | random_y = random.randint(0, y_count-1) * config.UNIT_LENGTH + 35 338 | item.is_moving = True 339 | item.log = "" 340 | item.update_info(font) 341 | item.target_x = random_x 342 | item.target_y = random_y 343 | 344 | 345 | def update_exp(font): 346 | for item in Heroes: 347 | if not item.alive: 348 | continue 349 | item.get_exp(font) 350 | 351 | 352 | def collide(font): 353 | for item in Heroes: 354 | if not item.alive: 355 | continue 356 | item.collide(Hero_groups, font) 357 | 358 | 359 | def add_hero_rel_player(hero_no, player_name): 360 | if hero_no not in hero_rel_player: 361 | hero_rel_player[hero_no] = [] 362 | if player_name not in hero_rel_player[hero_no]: 363 | hero_rel_player[hero_no].append(player_name) 364 | 365 | 366 | def has_invest(player_name): 367 | for hero_no in hero_rel_player: 368 | for name in hero_rel_player[hero_no]: 369 | if name == player_name: 370 | if hero_no in Heroes and Heroes[hero_no].alive: 371 | return True, hero_names[hero_no] 372 | return False, "" 373 | 374 | 375 | hero_rel_player = {} 376 | Heroes, Hero_groups = [], None 377 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /bliv/blivedm/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from typing import * 4 | 5 | __all__ = ( 6 | 'HeartbeatMessage', 7 | 'DanmakuMessage', 8 | 'GiftMessage', 9 | 'GuardBuyMessage', 10 | 'SuperChatMessage', 11 | 'SuperChatDeleteMessage', 12 | ) 13 | 14 | 15 | class HeartbeatMessage: 16 | """ 17 | 心跳消息 18 | 19 | :param popularity: 人气值 20 | """ 21 | 22 | def __init__( 23 | self, 24 | popularity: int = None, 25 | ): 26 | self.popularity: int = popularity 27 | 28 | @classmethod 29 | def from_command(cls, data: dict): 30 | return cls( 31 | popularity=data['popularity'], 32 | ) 33 | 34 | 35 | class DanmakuMessage: 36 | """ 37 | 弹幕消息 38 | 39 | :param mode: 弹幕显示模式(滚动、顶部、底部) 40 | :param font_size: 字体尺寸 41 | :param color: 颜色 42 | :param timestamp: 时间戳(毫秒) 43 | :param rnd: 随机数,前端叫作弹幕ID,可能是去重用的 44 | :param uid_crc32: 用户ID文本的CRC32 45 | :param msg_type: 是否礼物弹幕(节奏风暴) 46 | :param bubble: 右侧评论栏气泡 47 | :param dm_type: 弹幕类型,0文本,1表情,2语音 48 | :param emoticon_options: 表情参数 49 | :param voice_config: 语音参数 50 | :param mode_info: 一些附加参数 51 | 52 | :param msg: 弹幕内容 53 | 54 | :param uid: 用户ID 55 | :param uname: 用户名 56 | :param admin: 是否房管 57 | :param vip: 是否月费老爷 58 | :param svip: 是否年费老爷 59 | :param urank: 用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000 60 | :param mobile_verify: 是否绑定手机 61 | :param uname_color: 用户名颜色 62 | 63 | :param medal_level: 勋章等级 64 | :param medal_name: 勋章名 65 | :param runame: 勋章房间主播名 66 | :param medal_room_id: 勋章房间ID 67 | :param mcolor: 勋章颜色 68 | :param special_medal: 特殊勋章 69 | 70 | :param user_level: 用户等级 71 | :param ulevel_color: 用户等级颜色 72 | :param ulevel_rank: 用户等级排名,>50000时为'>50000' 73 | 74 | :param old_title: 旧头衔 75 | :param title: 头衔 76 | 77 | :param privilege_type: 舰队类型,0非舰队,1总督,2提督,3舰长 78 | """ 79 | 80 | def __init__( 81 | self, 82 | mode: int = None, 83 | font_size: int = None, 84 | color: int = None, 85 | timestamp: int = None, 86 | rnd: int = None, 87 | uid_crc32: str = None, 88 | msg_type: int = None, 89 | bubble: int = None, 90 | dm_type: int = None, 91 | emoticon_options: Union[dict, str] = None, 92 | voice_config: Union[dict, str] = None, 93 | mode_info: dict = None, 94 | 95 | msg: str = None, 96 | 97 | uid: int = None, 98 | uname: str = None, 99 | admin: int = None, 100 | vip: int = None, 101 | svip: int = None, 102 | urank: int = None, 103 | mobile_verify: int = None, 104 | uname_color: str = None, 105 | 106 | medal_level: str = None, 107 | medal_name: str = None, 108 | runame: str = None, 109 | medal_room_id: int = None, 110 | mcolor: int = None, 111 | special_medal: str = None, 112 | 113 | user_level: int = None, 114 | ulevel_color: int = None, 115 | ulevel_rank: str = None, 116 | 117 | old_title: str = None, 118 | title: str = None, 119 | 120 | privilege_type: int = None, 121 | ): 122 | self.mode: int = mode 123 | self.font_size: int = font_size 124 | self.color: int = color 125 | self.timestamp: int = timestamp 126 | self.rnd: int = rnd 127 | self.uid_crc32: str = uid_crc32 128 | self.msg_type: int = msg_type 129 | self.bubble: int = bubble 130 | self.dm_type: int = dm_type 131 | self.emoticon_options: Union[dict, str] = emoticon_options 132 | self.voice_config: Union[dict, str] = voice_config 133 | self.mode_info: dict = mode_info 134 | 135 | self.msg: str = msg 136 | 137 | self.uid: int = uid 138 | self.uname: str = uname 139 | self.admin: int = admin 140 | self.vip: int = vip 141 | self.svip: int = svip 142 | self.urank: int = urank 143 | self.mobile_verify: int = mobile_verify 144 | self.uname_color: str = uname_color 145 | 146 | self.medal_level: str = medal_level 147 | self.medal_name: str = medal_name 148 | self.runame: str = runame 149 | self.medal_room_id: int = medal_room_id 150 | self.mcolor: int = mcolor 151 | self.special_medal: str = special_medal 152 | 153 | self.user_level: int = user_level 154 | self.ulevel_color: int = ulevel_color 155 | self.ulevel_rank: str = ulevel_rank 156 | 157 | self.old_title: str = old_title 158 | self.title: str = title 159 | 160 | self.privilege_type: int = privilege_type 161 | 162 | @classmethod 163 | def from_command(cls, info: dict): 164 | if len(info[3]) != 0: 165 | medal_level = info[3][0] 166 | medal_name = info[3][1] 167 | runame = info[3][2] 168 | room_id = info[3][3] 169 | mcolor = info[3][4] 170 | special_medal = info[3][5] 171 | else: 172 | medal_level = 0 173 | medal_name = '' 174 | runame = '' 175 | room_id = 0 176 | mcolor = 0 177 | special_medal = 0 178 | 179 | return cls( 180 | mode=info[0][1], 181 | font_size=info[0][2], 182 | color=info[0][3], 183 | timestamp=info[0][4], 184 | rnd=info[0][5], 185 | uid_crc32=info[0][7], 186 | msg_type=info[0][9], 187 | bubble=info[0][10], 188 | dm_type=info[0][12], 189 | emoticon_options=info[0][13], 190 | voice_config=info[0][14], 191 | mode_info=info[0][15], 192 | 193 | msg=info[1], 194 | 195 | uid=info[2][0], 196 | uname=info[2][1], 197 | admin=info[2][2], 198 | vip=info[2][3], 199 | svip=info[2][4], 200 | urank=info[2][5], 201 | mobile_verify=info[2][6], 202 | uname_color=info[2][7], 203 | 204 | medal_level=medal_level, 205 | medal_name=medal_name, 206 | runame=runame, 207 | medal_room_id=room_id, 208 | mcolor=mcolor, 209 | special_medal=special_medal, 210 | 211 | user_level=info[4][0], 212 | ulevel_color=info[4][2], 213 | ulevel_rank=info[4][3], 214 | 215 | old_title=info[5][0], 216 | title=info[5][1], 217 | 218 | privilege_type=info[7], 219 | ) 220 | 221 | @property 222 | def emoticon_options_dict(self) -> dict: 223 | """ 224 | 示例: 225 | {'bulge_display': 0, 'emoticon_unique': 'official_13', 'height': 60, 'in_player_area': 1, 'is_dynamic': 1, 226 | 'url': 'https://i0.hdslb.com/bfs/live/a98e35996545509188fe4d24bd1a56518ea5af48.png', 'width': 183} 227 | """ 228 | if isinstance(self.emoticon_options, dict): 229 | return self.emoticon_options 230 | try: 231 | return json.loads(self.emoticon_options) 232 | except (json.JSONDecodeError, TypeError): 233 | return {} 234 | 235 | @property 236 | def voice_config_dict(self) -> dict: 237 | """ 238 | 示例: 239 | {'voice_url': 'https%3A%2F%2Fboss.hdslb.com%2Flive-dm-voice%2Fb5b26e48b556915cbf3312a59d3bb2561627725945.wav 240 | %3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3D2663ba902868f12f%252F20210731%252Fshjd%252Fs3%25 241 | 2Faws4_request%26X-Amz-Date%3D20210731T100545Z%26X-Amz-Expires%3D600000%26X-Amz-SignedHeaders%3Dhost%26 242 | X-Amz-Signature%3D114e7cb5ac91c72e231c26d8ca211e53914722f36309b861a6409ffb20f07ab8', 243 | 'file_format': 'wav', 'text': '汤,下午好。', 'file_duration': 1} 244 | """ 245 | if isinstance(self.voice_config, dict): 246 | return self.voice_config 247 | try: 248 | return json.loads(self.voice_config) 249 | except (json.JSONDecodeError, TypeError): 250 | return {} 251 | 252 | 253 | class GiftMessage: 254 | """ 255 | 礼物消息 256 | 257 | :param gift_name: 礼物名 258 | :param num: 数量 259 | :param uname: 用户名 260 | :param face: 用户头像URL 261 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 262 | :param uid: 用户ID 263 | :param timestamp: 时间戳 264 | :param gift_id: 礼物ID 265 | :param gift_type: 礼物类型(未知) 266 | :param action: 目前遇到的有'喂食'、'赠送' 267 | :param price: 礼物单价瓜子数 268 | :param rnd: 随机数,可能是去重用的。有时是时间戳+去重ID,有时是UUID 269 | :param coin_type: 瓜子类型,'silver'或'gold',1000金瓜子 = 1元 270 | :param total_coin: 总瓜子数 271 | :param tid: 可能是事务ID,有时和rnd相同 272 | """ 273 | 274 | def __init__( 275 | self, 276 | gift_name: str = None, 277 | num: int = None, 278 | uname: str = None, 279 | face: str = None, 280 | guard_level: int = None, 281 | uid: int = None, 282 | timestamp: int = None, 283 | gift_id: int = None, 284 | gift_type: int = None, 285 | action: str = None, 286 | price: int = None, 287 | rnd: str = None, 288 | coin_type: str = None, 289 | total_coin: int = None, 290 | tid: str = None, 291 | ): 292 | self.gift_name = gift_name 293 | self.num = num 294 | self.uname = uname 295 | self.face = face 296 | self.guard_level = guard_level 297 | self.uid = uid 298 | self.timestamp = timestamp 299 | self.gift_id = gift_id 300 | self.gift_type = gift_type 301 | self.action = action 302 | self.price = price 303 | self.rnd = rnd 304 | self.coin_type = coin_type 305 | self.total_coin = total_coin 306 | self.tid = tid 307 | 308 | @classmethod 309 | def from_command(cls, data: dict): 310 | return cls( 311 | gift_name=data['giftName'], 312 | num=data['num'], 313 | uname=data['uname'], 314 | face=data['face'], 315 | guard_level=data['guard_level'], 316 | uid=data['uid'], 317 | timestamp=data['timestamp'], 318 | gift_id=data['giftId'], 319 | gift_type=data['giftType'], 320 | action=data['action'], 321 | price=data['price'], 322 | rnd=data['rnd'], 323 | coin_type=data['coin_type'], 324 | total_coin=data['total_coin'], 325 | tid=data['tid'], 326 | ) 327 | 328 | 329 | class GuardBuyMessage: 330 | """ 331 | 上舰消息 332 | 333 | :param uid: 用户ID 334 | :param username: 用户名 335 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 336 | :param num: 数量 337 | :param price: 单价金瓜子数 338 | :param gift_id: 礼物ID 339 | :param gift_name: 礼物名 340 | :param start_time: 开始时间戳,和结束时间戳相同 341 | :param end_time: 结束时间戳,和开始时间戳相同 342 | """ 343 | 344 | def __init__( 345 | self, 346 | uid: int = None, 347 | username: str = None, 348 | guard_level: int = None, 349 | num: int = None, 350 | price: int = None, 351 | gift_id: int = None, 352 | gift_name: str = None, 353 | start_time: int = None, 354 | end_time: int = None, 355 | ): 356 | self.uid: int = uid 357 | self.username: str = username 358 | self.guard_level: int = guard_level 359 | self.num: int = num 360 | self.price: int = price 361 | self.gift_id: int = gift_id 362 | self.gift_name: str = gift_name 363 | self.start_time: int = start_time 364 | self.end_time: int = end_time 365 | 366 | @classmethod 367 | def from_command(cls, data: dict): 368 | return cls( 369 | uid=data['uid'], 370 | username=data['username'], 371 | guard_level=data['guard_level'], 372 | num=data['num'], 373 | price=data['price'], 374 | gift_id=data['gift_id'], 375 | gift_name=data['gift_name'], 376 | start_time=data['start_time'], 377 | end_time=data['end_time'], 378 | ) 379 | 380 | 381 | class SuperChatMessage: 382 | """ 383 | 醒目留言消息 384 | 385 | :param price: 价格(人民币) 386 | :param message: 消息 387 | :param message_trans: 消息日文翻译(目前只出现在SUPER_CHAT_MESSAGE_JPN) 388 | :param start_time: 开始时间戳 389 | :param end_time: 结束时间戳 390 | :param time: 剩余时间(约等于 结束时间戳 - 开始时间戳) 391 | :param id_: str,醒目留言ID,删除时用 392 | :param gift_id: 礼物ID 393 | :param gift_name: 礼物名 394 | :param uid: 用户ID 395 | :param uname: 用户名 396 | :param face: 用户头像URL 397 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 398 | :param user_level: 用户等级 399 | :param background_bottom_color: 底部背景色,'#rrggbb' 400 | :param background_color: 背景色,'#rrggbb' 401 | :param background_icon: 背景图标 402 | :param background_image: 背景图URL 403 | :param background_price_color: 背景价格颜色,'#rrggbb' 404 | """ 405 | 406 | def __init__( 407 | self, 408 | price: int = None, 409 | message: str = None, 410 | message_trans: str = None, 411 | start_time: int = None, 412 | end_time: int = None, 413 | time: int = None, 414 | id_: int = None, 415 | gift_id: int = None, 416 | gift_name: str = None, 417 | uid: int = None, 418 | uname: str = None, 419 | face: str = None, 420 | guard_level: int = None, 421 | user_level: int = None, 422 | background_bottom_color: str = None, 423 | background_color: str = None, 424 | background_icon: str = None, 425 | background_image: str = None, 426 | background_price_color: str = None, 427 | ): 428 | self.price: int = price 429 | self.message: str = message 430 | self.message_trans: str = message_trans 431 | self.start_time: int = start_time 432 | self.end_time: int = end_time 433 | self.time: int = time 434 | self.id: int = id_ 435 | self.gift_id: int = gift_id 436 | self.gift_name: str = gift_name 437 | self.uid: int = uid 438 | self.uname: str = uname 439 | self.face: str = face 440 | self.guard_level: int = guard_level 441 | self.user_level: int = user_level 442 | self.background_bottom_color: str = background_bottom_color 443 | self.background_color: str = background_color 444 | self.background_icon: str = background_icon 445 | self.background_image: str = background_image 446 | self.background_price_color: str = background_price_color 447 | 448 | @classmethod 449 | def from_command(cls, data: dict): 450 | return cls( 451 | price=data['price'], 452 | message=data['message'], 453 | message_trans=data['message_trans'], 454 | start_time=data['start_time'], 455 | end_time=data['end_time'], 456 | time=data['time'], 457 | id_=data['id'], 458 | gift_id=data['gift']['gift_id'], 459 | gift_name=data['gift']['gift_name'], 460 | uid=data['uid'], 461 | uname=data['user_info']['uname'], 462 | face=data['user_info']['face'], 463 | guard_level=data['user_info']['guard_level'], 464 | user_level=data['user_info']['user_level'], 465 | background_bottom_color=data['background_bottom_color'], 466 | background_color=data['background_color'], 467 | background_icon=data['background_icon'], 468 | background_image=data['background_image'], 469 | background_price_color=data['background_price_color'], 470 | ) 471 | 472 | 473 | class SuperChatDeleteMessage: 474 | """ 475 | 删除醒目留言消息 476 | 477 | :param ids: 醒目留言ID数组 478 | """ 479 | 480 | def __init__( 481 | self, 482 | ids: List[int] = None, 483 | ): 484 | self.ids: List[int] = ids 485 | 486 | @classmethod 487 | def from_command(cls, data: dict): 488 | return cls( 489 | ids=data['ids'], 490 | ) 491 | -------------------------------------------------------------------------------- /bliv/blivedm/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import collections 4 | import enum 5 | import json 6 | import logging 7 | import ssl as ssl_ 8 | import struct 9 | from typing import * 10 | 11 | import aiohttp 12 | import brotli 13 | 14 | from . import handlers 15 | 16 | __all__ = ( 17 | 'BLiveClient', 18 | ) 19 | 20 | logger = logging.getLogger('blivedm') 21 | 22 | ROOM_INIT_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom' 23 | DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo' 24 | DEFAULT_DANMAKU_SERVER_LIST = [ 25 | {'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244} 26 | ] 27 | 28 | HEADER_STRUCT = struct.Struct('>I2H2I') 29 | HeaderTuple = collections.namedtuple('HeaderTuple', ('pack_len', 'raw_header_size', 'ver', 'operation', 'seq_id')) 30 | 31 | 32 | # WS_BODY_PROTOCOL_VERSION 33 | class ProtoVer(enum.IntEnum): 34 | NORMAL = 0 35 | HEARTBEAT = 1 36 | DEFLATE = 2 37 | BROTLI = 3 38 | 39 | 40 | # go-common\app\service\main\broadcast\model\operation.go 41 | class Operation(enum.IntEnum): 42 | HANDSHAKE = 0 43 | HANDSHAKE_REPLY = 1 44 | HEARTBEAT = 2 45 | HEARTBEAT_REPLY = 3 46 | SEND_MSG = 4 47 | SEND_MSG_REPLY = 5 48 | DISCONNECT_REPLY = 6 49 | AUTH = 7 50 | AUTH_REPLY = 8 51 | RAW = 9 52 | PROTO_READY = 10 53 | PROTO_FINISH = 11 54 | CHANGE_ROOM = 12 55 | CHANGE_ROOM_REPLY = 13 56 | REGISTER = 14 57 | REGISTER_REPLY = 15 58 | UNREGISTER = 16 59 | UNREGISTER_REPLY = 17 60 | # B站业务自定义OP 61 | # MinBusinessOp = 1000 62 | # MaxBusinessOp = 10000 63 | 64 | 65 | # WS_AUTH 66 | class AuthReplyCode(enum.IntEnum): 67 | OK = 0 68 | TOKEN_ERROR = -101 69 | 70 | 71 | class InitError(Exception): 72 | """初始化失败""" 73 | 74 | 75 | class AuthError(Exception): 76 | """认证失败""" 77 | 78 | 79 | class BLiveClient: 80 | """ 81 | B站直播弹幕客户端,负责连接房间 82 | 83 | :param room_id: URL中的房间ID,可以用短ID 84 | :param uid: B站用户ID,0表示未登录 85 | :param session: cookie、连接池 86 | :param heartbeat_interval: 发送心跳包的间隔时间(秒) 87 | :param ssl: True表示用默认的SSLContext验证,False表示不验证,也可以传入SSLContext 88 | :param loop: 协程事件循环 89 | """ 90 | 91 | def __init__( 92 | self, 93 | room_id, 94 | uid=0, 95 | session: Optional[aiohttp.ClientSession] = None, 96 | heartbeat_interval=30, 97 | ssl: Union[bool, ssl_.SSLContext] = True, 98 | loop: Optional[asyncio.BaseEventLoop] = None, 99 | ): 100 | # 用来init_room的临时房间ID,可以用短ID 101 | self._tmp_room_id = room_id 102 | self._uid = uid 103 | 104 | if loop is not None: 105 | self._loop = loop 106 | elif session is not None: 107 | self._loop = session.loop # noqa 108 | else: 109 | self._loop = asyncio.get_event_loop() 110 | 111 | if session is None: 112 | self._session = aiohttp.ClientSession(loop=self._loop, timeout=aiohttp.ClientTimeout(total=10)) 113 | self._own_session = True 114 | else: 115 | self._session = session 116 | self._own_session = False 117 | if self._session.loop is not self._loop: # noqa 118 | raise RuntimeError('BLiveClient and session must use the same event loop') 119 | 120 | self._heartbeat_interval = heartbeat_interval 121 | self._ssl = ssl if ssl else ssl_._create_unverified_context() # noqa 122 | 123 | # 消息处理器,可动态增删 124 | self._handlers: List[handlers.HandlerInterface] = [] 125 | 126 | # 在调用init_room后初始化的字段 127 | # 真实房间ID 128 | self._room_id = None 129 | # 房间短ID,没有则为0 130 | self._room_short_id = None 131 | # 主播用户ID 132 | self._room_owner_uid = None 133 | # 弹幕服务器列表 134 | # [{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...] 135 | self._host_server_list: Optional[List[dict]] = None 136 | # 连接弹幕服务器用的token 137 | self._host_server_token = None 138 | 139 | # 在运行时初始化的字段 140 | # websocket连接 141 | self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None 142 | # 网络协程的future 143 | self._network_future: Optional[asyncio.Future] = None 144 | # 发心跳包定时器的handle 145 | self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None 146 | 147 | @property 148 | def is_running(self) -> bool: 149 | """ 150 | 本客户端正在运行,注意调用stop后还没完全停止也算正在运行 151 | """ 152 | return self._network_future is not None 153 | 154 | @property 155 | def room_id(self) -> Optional[int]: 156 | """ 157 | 房间ID,调用init_room后初始化 158 | """ 159 | return self._room_id 160 | 161 | @property 162 | def room_short_id(self) -> Optional[int]: 163 | """ 164 | 房间短ID,没有则为0,调用init_room后初始化 165 | """ 166 | return self._room_short_id 167 | 168 | @property 169 | def room_owner_uid(self) -> Optional[int]: 170 | """ 171 | 主播用户ID,调用init_room后初始化 172 | """ 173 | return self._room_owner_uid 174 | 175 | def add_handler(self, handler: 'handlers.HandlerInterface'): 176 | """ 177 | 添加消息处理器 178 | 注意多个处理器是并发处理的,不要依赖处理的顺序 179 | 消息处理器和接收消息运行在同一协程,如果处理消息耗时太长会阻塞接收消息,这种情况建议将消息推到队列,让另一个协程处理 180 | 181 | :param handler: 消息处理器 182 | """ 183 | if handler not in self._handlers: 184 | self._handlers.append(handler) 185 | 186 | def remove_handler(self, handler: 'handlers.HandlerInterface'): 187 | """ 188 | 移除消息处理器 189 | 190 | :param handler: 消息处理器 191 | """ 192 | try: 193 | self._handlers.remove(handler) 194 | except ValueError: 195 | pass 196 | 197 | def start(self): 198 | """ 199 | 启动本客户端 200 | """ 201 | if self.is_running: 202 | logger.warning('room=%s client is running, cannot start() again', self.room_id) 203 | return 204 | 205 | self._network_future = asyncio.ensure_future(self._network_coroutine_wrapper(), loop=self._loop) 206 | 207 | def stop(self): 208 | """ 209 | 停止本客户端 210 | """ 211 | if not self.is_running: 212 | logger.warning('room=%s client is stopped, cannot stop() again', self.room_id) 213 | return 214 | 215 | self._network_future.cancel() 216 | 217 | async def stop_and_close(self): 218 | """ 219 | 便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用 220 | """ 221 | if self.is_running: 222 | self.stop() 223 | await self.join() 224 | await self.close() 225 | 226 | async def join(self): 227 | """ 228 | 等待本客户端停止 229 | """ 230 | if not self.is_running: 231 | logger.warning('room=%s client is stopped, cannot join()', self.room_id) 232 | return 233 | 234 | await asyncio.shield(self._network_future) 235 | 236 | async def close(self): 237 | """ 238 | 释放本客户端的资源,调用后本客户端将不可用 239 | """ 240 | if self.is_running: 241 | logger.warning('room=%s is calling close(), but client is running', self.room_id) 242 | 243 | # 如果session是自己创建的则关闭session 244 | if self._own_session: 245 | await self._session.close() 246 | 247 | async def init_room(self): 248 | """ 249 | 初始化连接房间需要的字段 250 | 251 | :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True 252 | """ 253 | res = True 254 | if not await self._init_room_id_and_owner(): 255 | res = False 256 | # 失败了则降级 257 | self._room_id = self._room_short_id = self._tmp_room_id 258 | self._room_owner_uid = 0 259 | 260 | if not await self._init_host_server(): 261 | res = False 262 | # 失败了则降级 263 | self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST 264 | self._host_server_token = None 265 | return res 266 | 267 | async def _init_room_id_and_owner(self): 268 | try: 269 | async with self._session.get( 270 | ROOM_INIT_URL, 271 | headers={ 272 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 273 | ' Chrome/102.0.0.0 Safari/537.36' 274 | }, 275 | params={ 276 | 'room_id': self._tmp_room_id 277 | }, 278 | ssl=self._ssl 279 | ) as res: 280 | if res.status != 200: 281 | logger.warning('room=%d _init_room_id_and_owner() failed, status=%d, reason=%s', self._tmp_room_id, 282 | res.status, res.reason) 283 | return False 284 | data = await res.json() 285 | if data['code'] != 0: 286 | logger.warning('room=%d _init_room_id_and_owner() failed, message=%s', self._tmp_room_id, 287 | data['message']) 288 | return False 289 | if not self._parse_room_init(data['data']): 290 | return False 291 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 292 | logger.exception('room=%d _init_room_id_and_owner() failed:', self._tmp_room_id) 293 | return False 294 | return True 295 | 296 | def _parse_room_init(self, data): 297 | room_info = data['room_info'] 298 | self._room_id = room_info['room_id'] 299 | self._room_short_id = room_info['short_id'] 300 | self._room_owner_uid = room_info['uid'] 301 | return True 302 | 303 | async def _init_host_server(self): 304 | try: 305 | async with self._session.get( 306 | DANMAKU_SERVER_CONF_URL, 307 | headers={ 308 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 309 | ' Chrome/102.0.0.0 Safari/537.36' 310 | }, 311 | params={ 312 | 'id': self._room_id, 313 | 'type': 0 314 | }, 315 | ssl=self._ssl 316 | ) as res: 317 | if res.status != 200: 318 | logger.warning('room=%d _init_host_server() failed, status=%d, reason=%s', self._room_id, 319 | res.status, res.reason) 320 | return False 321 | data = await res.json() 322 | if data['code'] != 0: 323 | logger.warning('room=%d _init_host_server() failed, message=%s', self._room_id, data['message']) 324 | return False 325 | if not self._parse_danmaku_server_conf(data['data']): 326 | return False 327 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 328 | logger.exception('room=%d _init_host_server() failed:', self._room_id) 329 | return False 330 | return True 331 | 332 | def _parse_danmaku_server_conf(self, data): 333 | self._host_server_list = data['host_list'] 334 | self._host_server_token = data['token'] 335 | if not self._host_server_list: 336 | logger.warning('room=%d _parse_danmaku_server_conf() failed: host_server_list is empty', self._room_id) 337 | return False 338 | return True 339 | 340 | @staticmethod 341 | def _make_packet(data: dict, operation: int) -> bytes: 342 | """ 343 | 创建一个要发送给服务器的包 344 | 345 | :param data: 包体JSON数据 346 | :param operation: 操作码,见Operation 347 | :return: 整个包的数据 348 | """ 349 | body = json.dumps(data).encode('utf-8') 350 | header = HEADER_STRUCT.pack(*HeaderTuple( 351 | pack_len=HEADER_STRUCT.size + len(body), 352 | raw_header_size=HEADER_STRUCT.size, 353 | ver=1, 354 | operation=operation, 355 | seq_id=1 356 | )) 357 | return header + body 358 | 359 | async def _network_coroutine_wrapper(self): 360 | """ 361 | 负责处理网络协程的异常,网络协程具体逻辑在_network_coroutine里 362 | """ 363 | try: 364 | await self._network_coroutine() 365 | except asyncio.CancelledError: 366 | # 正常停止 367 | pass 368 | except Exception as e: # noqa 369 | logger.exception('room=%s _network_coroutine() finished with exception:', self.room_id) 370 | finally: 371 | logger.debug('room=%s _network_coroutine() finished', self.room_id) 372 | self._network_future = None 373 | 374 | async def _network_coroutine(self): 375 | """ 376 | 网络协程,负责连接服务器、接收消息、解包 377 | """ 378 | # 如果之前未初始化则初始化 379 | if self._host_server_token is None: 380 | if not await self.init_room(): 381 | raise InitError('init_room() failed') 382 | 383 | retry_count = 0 384 | while True: 385 | try: 386 | # 连接 387 | host_server = self._host_server_list[retry_count % len(self._host_server_list)] 388 | async with self._session.ws_connect( 389 | f"wss://{host_server['host']}:{host_server['wss_port']}/sub", 390 | headers={ 391 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 392 | ' Chrome/102.0.0.0 Safari/537.36' 393 | }, 394 | receive_timeout=self._heartbeat_interval + 5, 395 | ssl=self._ssl 396 | ) as websocket: 397 | self._websocket = websocket 398 | await self._on_ws_connect() 399 | 400 | # 处理消息 401 | message: aiohttp.WSMessage 402 | async for message in websocket: 403 | await self._on_ws_message(message) 404 | # 至少成功处理1条消息 405 | retry_count = 0 406 | 407 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 408 | # 掉线重连 409 | pass 410 | except AuthError: 411 | # 认证失败了,应该重新获取token再重连 412 | logger.exception('room=%d auth failed, trying init_room() again', self.room_id) 413 | if not await self.init_room(): 414 | raise InitError('init_room() failed') 415 | except ssl_.SSLError: 416 | logger.error('room=%d a SSLError happened, cannot reconnect', self.room_id) 417 | raise 418 | finally: 419 | self._websocket = None 420 | await self._on_ws_close() 421 | 422 | # 准备重连 423 | retry_count += 1 424 | logger.warning('room=%d is reconnecting, retry_count=%d', self.room_id, retry_count) 425 | await asyncio.sleep(1, loop=self._loop) 426 | 427 | async def _on_ws_connect(self): 428 | """ 429 | websocket连接成功 430 | """ 431 | await self._send_auth() 432 | self._heartbeat_timer_handle = self._loop.call_later(self._heartbeat_interval, self._on_send_heartbeat) 433 | 434 | async def _on_ws_close(self): 435 | """ 436 | websocket连接断开 437 | """ 438 | if self._heartbeat_timer_handle is not None: 439 | self._heartbeat_timer_handle.cancel() 440 | self._heartbeat_timer_handle = None 441 | 442 | async def _send_auth(self): 443 | """ 444 | 发送认证包 445 | """ 446 | auth_params = { 447 | 'uid': self._uid, 448 | 'roomid': self._room_id, 449 | 'protover': 3, 450 | 'platform': 'web', 451 | 'type': 2 452 | } 453 | if self._host_server_token is not None: 454 | auth_params['key'] = self._host_server_token 455 | await self._websocket.send_bytes(self._make_packet(auth_params, Operation.AUTH)) 456 | 457 | def _on_send_heartbeat(self): 458 | """ 459 | 定时发送心跳包的回调 460 | """ 461 | if self._websocket is None or self._websocket.closed: 462 | self._heartbeat_timer_handle = None 463 | return 464 | 465 | self._heartbeat_timer_handle = self._loop.call_later(self._heartbeat_interval, self._on_send_heartbeat) 466 | asyncio.ensure_future(self._send_heartbeat(), loop=self._loop) 467 | 468 | async def _send_heartbeat(self): 469 | """ 470 | 发送心跳包 471 | """ 472 | if self._websocket is None or self._websocket.closed: 473 | return 474 | 475 | try: 476 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 477 | except (ConnectionResetError, aiohttp.ClientConnectionError) as e: 478 | logger.warning('room=%d _send_heartbeat() failed: %r', self.room_id, e) 479 | except Exception: # noqa 480 | logger.exception('room=%d _send_heartbeat() failed:', self.room_id) 481 | 482 | async def _on_ws_message(self, message: aiohttp.WSMessage): 483 | """ 484 | 收到websocket消息 485 | 486 | :param message: websocket消息 487 | """ 488 | if message.type != aiohttp.WSMsgType.BINARY: 489 | logger.warning('room=%d unknown websocket message type=%s, data=%s', self.room_id, 490 | message.type, message.data) 491 | return 492 | 493 | try: 494 | await self._parse_ws_message(message.data) 495 | except (asyncio.CancelledError, AuthError): 496 | # 正常停止、认证失败,让外层处理 497 | raise 498 | except Exception: # noqa 499 | logger.exception('room=%d _parse_ws_message() error:', self.room_id) 500 | 501 | async def _parse_ws_message(self, data: bytes): 502 | """ 503 | 解析websocket消息 504 | 505 | :param data: websocket消息数据 506 | """ 507 | offset = 0 508 | try: 509 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 510 | except struct.error: 511 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 512 | return 513 | 514 | if header.operation in (Operation.SEND_MSG_REPLY, Operation.AUTH_REPLY): 515 | # 业务消息,可能有多个包一起发,需要分包 516 | while True: 517 | body = data[offset + header.raw_header_size: offset + header.pack_len] 518 | await self._parse_business_message(header, body) 519 | 520 | offset += header.pack_len 521 | if offset >= len(data): 522 | break 523 | 524 | try: 525 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 526 | except struct.error: 527 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 528 | break 529 | 530 | elif header.operation == Operation.HEARTBEAT_REPLY: 531 | # 服务器心跳包,前4字节是人气值,后面是客户端发的心跳包内容 532 | # pack_len不包括客户端发的心跳包内容,不知道是不是服务器BUG 533 | body = data[offset + header.raw_header_size: offset + header.raw_header_size + 4] 534 | popularity = int.from_bytes(body, 'big') 535 | # 自己造个消息当成业务消息处理 536 | body = { 537 | 'cmd': '_HEARTBEAT', 538 | 'data': { 539 | 'popularity': popularity 540 | } 541 | } 542 | await self._handle_command(body) 543 | 544 | else: 545 | # 未知消息 546 | body = data[offset + header.raw_header_size: offset + header.pack_len] 547 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 548 | header.operation, header, body) 549 | 550 | async def _parse_business_message(self, header: HeaderTuple, body: bytes): 551 | """ 552 | 解析业务消息 553 | """ 554 | if header.operation == Operation.SEND_MSG_REPLY: 555 | # 业务消息 556 | if header.ver == ProtoVer.BROTLI: 557 | # 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行 558 | body = await self._loop.run_in_executor(None, brotli.decompress, body) 559 | await self._parse_ws_message(body) 560 | elif header.ver == ProtoVer.NORMAL: 561 | # 没压缩过的直接反序列化,因为有万恶的GIL,这里不能并行避免阻塞 562 | if len(body) != 0: 563 | try: 564 | body = json.loads(body.decode('utf-8')) 565 | await self._handle_command(body) 566 | except asyncio.CancelledError: 567 | raise 568 | except Exception: 569 | logger.error('room=%d, body=%s', self.room_id, body) 570 | raise 571 | else: 572 | # 未知格式 573 | logger.warning('room=%d unknown protocol version=%d, header=%s, body=%s', self.room_id, 574 | header.ver, header, body) 575 | 576 | elif header.operation == Operation.AUTH_REPLY: 577 | # 认证响应 578 | body = json.loads(body.decode('utf-8')) 579 | if body['code'] != AuthReplyCode.OK: 580 | raise AuthError(f"auth reply error, code={body['code']}, body={body}") 581 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 582 | 583 | else: 584 | # 未知消息 585 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 586 | header.operation, header, body) 587 | 588 | async def _handle_command(self, command: dict): 589 | """ 590 | 解析并处理业务消息 591 | 592 | :param command: 业务消息 593 | """ 594 | # 外部代码可能不能正常处理取消,所以这里加shield 595 | results = await asyncio.shield( 596 | asyncio.gather( 597 | *(handler.handle(self, command) for handler in self._handlers), 598 | loop=self._loop, 599 | return_exceptions=True 600 | ), 601 | loop=self._loop 602 | ) 603 | for res in results: 604 | if isinstance(res, Exception): 605 | logger.exception('room=%d _handle_command() failed, command=%s', self.room_id, command, exc_info=res) 606 | --------------------------------------------------------------------------------