diff --git a/assets/img/ac/ac.png b/assets/img/ac/ac.png new file mode 100644 index 0000000..e362802 Binary files /dev/null and b/assets/img/ac/ac.png differ diff --git a/assets/img/ac/broken_ac.png b/assets/img/ac/broken_ac.png new file mode 100644 index 0000000..6070d0f Binary files /dev/null and b/assets/img/ac/broken_ac.png differ diff --git a/assets/img/ac/frozen_ac.png b/assets/img/ac/frozen_ac.png new file mode 100644 index 0000000..12ac12a Binary files /dev/null and b/assets/img/ac/frozen_ac.png differ diff --git a/konabot/common/web_render/__init__.py b/konabot/common/web_render/__init__.py index b9ec029..a06e1bd 100644 --- a/konabot/common/web_render/__init__.py +++ b/konabot/common/web_render/__init__.py @@ -54,7 +54,8 @@ class WebRenderer: instance = await cls.get_browser_instance() logger.debug(f"Using WebRendererInstance {id(instance)} to render {url} targeting {target}") return await instance.render(url, target, params=params, other_function=other_function, timeout=timeout) - + + @classmethod async def render_persistent_page(cls, page_id: str, url: str, target: str, params: dict = {}, other_function: callable = None, timeout: int = 30) -> bytes: ''' @@ -158,7 +159,7 @@ class WebRendererInstance: logger.debug(f"Navigating to {url} with timeout {timeout}") url_with_params = url + ("?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else "") await page.goto(url_with_params, timeout=timeout * 1000, wait_until="load") - logger.debug(f"Page loaded successfully") + logger.debug("Page loaded successfully") # 等待目标元素出现 await page.wait_for_selector(target, timeout=timeout * 1000) logger.debug(f"Target element '{target}' found, taking screenshot") @@ -176,29 +177,6 @@ class WebRendererInstance: logger.debug(f"Screenshot taken successfully") return screenshot - logger.debug(f"Navigating to {url} with timeout {timeout}") - try: - url_with_params = url + ("?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else "") - await page.goto(url_with_params, timeout=timeout * 1000, wait_until="load") - logger.debug("Page loaded successfully") - # 等待目标元素出现 - await page.wait_for_selector(target, timeout=timeout * 1000) - logger.debug(f"Target element '{target}' found, taking screenshot") - if other_function: - await other_function(page) - elements = await page.query_selector_all(target) - if not elements: - raise Exception(f"Target element '{target}' not found on the page.") - if index >= len(elements): - raise Exception(f"Index {index} out of range for elements matching '{target}'.") - element = elements[index] - screenshot = await element.screenshot() - logger.debug("Screenshot taken successfully") - return screenshot - finally: - await page.close() - await context.close() - async def close(self): await self.browser.close() await self.playwright.stop() diff --git a/konabot/plugins/air_conditioner/__init__.py b/konabot/plugins/air_conditioner/__init__.py index 12f8e29..b38cbb4 100644 --- a/konabot/plugins/air_conditioner/__init__.py +++ b/konabot/plugins/air_conditioner/__init__.py @@ -1,91 +1,120 @@ -from typing import Optional -from nonebot_plugin_alconna import Alconna, Args, UniMessage, UniMsg, on_alconna +from io import BytesIO +from typing import Optional, Union +from nonebot.adapters import Event as BaseEvent +from nonebot.adapters.console.event import MessageEvent as ConsoleMessageEvent +from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent +from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna +from PIL import Image from konabot.common.longtask import DepLongTaskTarget from konabot.common.path import ASSETS_PATH -from konabot.common.web_render import WebRenderer -from nonebot.adapters import Event as BaseEvent -from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent -from playwright.async_api import Page +from konabot.plugins.air_conditioner.ac import AirConditioner, generate_ac_image -async def open_handle(page: Page) -> None: - ''' - 开空调 - ''' - # 找到 id 为 power 的开关按钮元素 - power_button = await page.query_selector("#power") - if power_button: - # 点击按钮打开空调 - await power_button.click(force=True) +def get_ac(id: str) -> AirConditioner: + ac = AirConditioner.air_conditioners.get(id) + if ac is None: + ac = AirConditioner(id) + return ac -async def up_handle(page: Page) -> None: - ''' - 升温 - ''' - # 找到 id 为 add 的按钮元素 - add_button = await page.query_selector("#add") - if add_button: - # 点击按钮升温,无需检测是否稳定 - await add_button.click(force=True) +async def send_ac_image(event: type[AlconnaMatcher], ac: AirConditioner): + if(ac.burnt == True): + # 打开坏掉的空调图片 + with open(ASSETS_PATH / "img" / "ac" / "broken_ac.png", "rb") as f: + # 将其转为 GIF 格式发送 + output = BytesIO() + Image.open(f).save(output, format="GIF") + output.seek(0) + await event.send(await UniMessage().image(raw=output).export()) + return + if(ac.frozen == True): + # 打开坏掉的空调图片 + with open(ASSETS_PATH / "img" / "ac" / "frozen_ac.png", "rb") as f: + # 将其转为 GIF 格式发送 + output = BytesIO() + Image.open(f).save(output, format="GIF") + output.seek(0) + await event.send(await UniMessage().image(raw=output).export()) + return + ac_image = await generate_ac_image(ac) + await event.send(await UniMessage().image(raw=ac_image).export()) -async def down_handle(page: Page) -> None: - ''' - 降温 - ''' - # 找到 id 为 minus 的按钮元素 - minus_button = await page.query_selector("#minus") - if minus_button: - # 点击按钮降温 - await minus_button.click(force=True) +evt = on_alconna(Alconna( + "群空调" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) -def get_user_info(event: BaseEvent): - if isinstance(event, DiscordMessageEvent): - user_id = str(event.author.id) - user_name = str(event.author.name) - else: - user_id = str(event.get_user_id()) - user_name = str(event.get_user_id()) - return user_id, user_name - -evt = on_alconna( - Alconna( - f"群空调", - Args["condition", str] - ), - use_cmd_start=True, - use_cmd_sep=False, - skip_for_unmatch=True, -) @evt.handle() -async def _(msg: UniMsg, event: BaseEvent, target: DepLongTaskTarget, condition: Optional[str] = ""): - identify_code = f"air_conditioner_{target.channel_id}" - function_handle = None - match condition: - case "开空调" | "打开空调" | "启动空调" | "关闭空调" | "关空调" | "开关空调": - function_handle = open_handle - case "升温" | "调高温度" | "加温" | "加" | "调高" | "提高" | "加一度": - function_handle = up_handle - case "降温" | "调低温度" | "减温" | "减" | "调低" | "降低" | "减一度": - function_handle = down_handle - case "炸空调": - await WebRenderer.close_persistent_page(identify_code) - # 读取 boom 图片 - with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f: - boom_image = f.read() - await evt.send(await UniMessage().image(raw=boom_image).export()) - user_id, _ = get_user_info(event) - await evt.send(await UniMessage().at(user_id).text("空调被你炸毁了!我们重新装了一台!").export()) - return - case _: - return - - screenshot = await WebRenderer.render_persistent_page( - page_id=identify_code, - url="https://toolwa.com/ac/", - target="#kt", - other_function=lambda page: function_handle(page) if function_handle else None, - timeout=30 - ) +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + await send_ac_image(evt, ac) - await evt.send( - await UniMessage().image(raw=screenshot).export() - ) \ No newline at end of file +evt = on_alconna(Alconna( + "开空调" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) + +@evt.handle() +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + ac.on = True + await send_ac_image(evt, ac) + +evt = on_alconna(Alconna( + "关空调" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) + +@evt.handle() +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + ac.on = False + await send_ac_image(evt, ac) + +evt = on_alconna(Alconna( + "空调升温" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) + +@evt.handle() +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + if not ac.on or ac.burnt == True or ac.frozen == True: + await send_ac_image(evt, ac) + return + ac.temperature += 1 + if ac.temperature > 40: + # 打开爆炸图片 + with open(ASSETS_PATH / "img" / "other" / "boom.jpg", "rb") as f: + output = BytesIO() + Image.open(f).save(output, format="GIF") + await evt.send(await UniMessage().image(raw=output).export()) + ac.burnt = True + await evt.send("太热啦,空调炸了!") + return + await send_ac_image(evt, ac) + +evt = on_alconna(Alconna( + "空调降温" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) + +@evt.handle() +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + if not ac.on or ac.burnt == True or ac.frozen == True: + await send_ac_image(evt, ac) + return + ac.temperature -= 1 + if ac.temperature < 0: + ac.frozen = True + await send_ac_image(evt, ac) + +evt = on_alconna(Alconna( + "换空调" +), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) + +@evt.handle() +async def _(event: BaseEvent, target: DepLongTaskTarget): + id = target.channel_id + ac = get_ac(id) + ac.change_ac() + await send_ac_image(evt, ac) \ No newline at end of file diff --git a/konabot/plugins/air_conditioner/ac.py b/konabot/plugins/air_conditioner/ac.py new file mode 100644 index 0000000..f346159 --- /dev/null +++ b/konabot/plugins/air_conditioner/ac.py @@ -0,0 +1,225 @@ +from io import BytesIO + +import cv2 +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from konabot.common.path import ASSETS_PATH, FONTS_PATH + +class AirConditioner: + air_conditioners: dict[str, "AirConditioner"] = {} + + def __init__(self, id: str) -> None: + self.id = id + self.on = False + self.temperature = 24 # 默认温度 + self.burnt = False + self.frozen = False + AirConditioner.air_conditioners[id] = self + + def change_ac(self): + self.burnt = False + self.frozen = False + self.on = False + self.temperature = 24 # 重置为默认温度 + +def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)): + """ + 将文本转换为带透明背景的图像,图像大小刚好包含文本 + """ + # 创建临时图像来计算文本尺寸 + temp_image = Image.new('RGB', (1, 1), (255, 255, 255)) + temp_draw = ImageDraw.Draw(temp_image) + + font = ImageFont.truetype(FONTS_PATH / "montserrat.otf", font_size) + + # 获取文本边界框 + bbox = temp_draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 计算图像大小(文本大小 + 内边距) + image_width = int(text_width + 2 * padding) + image_height = int(text_height + 2 * padding) + + # 创建RGBA模式的空白图像(带透明通道) + image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + # 绘制文本(考虑内边距) + x = padding - bbox[0] # 调整起始位置 + y = padding - bbox[1] + + # 设置文本颜色(带透明度) + if len(text_color) == 3: + text_color = text_color + (255,) # 添加完全不透明的alpha值 + + draw.text((x, y), text, fill=text_color, font=font) + + # 转换为OpenCV格式(BGRA) + image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGBA2BGRA) + return image_cv + +def perspective_transform(image, target, corners): + """ + 对图像进行透视变换(保持透明通道) + target: 画布 + corners: 四个角点的坐标,顺序为 [左上, 右上, 右下, 左下] + """ + height, width = image.shape[:2] + + # 源点(原始图像的四个角) + src_points = np.array([ + [0, 0], # 左上 + [width-1, 0], # 右上 + [width-1, height-1], # 右下 + [0, height-1] # 左下 + ], dtype=np.float32) + + # 目标点(变换后的四个角) + dst_points = np.array(corners, dtype=np.float32) + + # 计算透视变换矩阵 + matrix = cv2.getPerspectiveTransform(src_points, dst_points) + + # 获取画布大小 + target_height, target_width = target.shape[:2] + + # 应用透视变换(保持所有通道,包括alpha) + transformed = cv2.warpPerspective(image, matrix, (target_width, target_height), flags=cv2.INTER_LINEAR) + + return transformed, matrix + +def blend_with_transparency(background, foreground, position): + """ + 将带透明通道的前景图像合成到背景图像上 + position: 前景图像在背景图像上的位置 (x, y) + """ + bg = background.copy() + + # 如果背景没有alpha通道,添加一个 + if bg.shape[2] == 3: + bg = cv2.cvtColor(bg, cv2.COLOR_BGR2BGRA) + bg[:, :, 3] = 255 # 完全不透明 + + x, y = position + fg_height, fg_width = foreground.shape[:2] + bg_height, bg_width = bg.shape[:2] + + # 确保位置在图像范围内 + x = max(0, min(x, bg_width - fg_width)) + y = max(0, min(y, bg_height - fg_height)) + + # 提取前景的alpha通道并归一化 + alpha_foreground = foreground[:, :, 3] / 255.0 + + # 对于每个颜色通道进行合成 + for c in range(3): + bg_region = bg[y:y+fg_height, x:x+fg_width, c] + fg_region = foreground[:, :, c] + + # alpha混合公式 + bg[y:y+fg_height, x:x+fg_width, c] = ( + alpha_foreground * fg_region + + (1 - alpha_foreground) * bg_region + ) + + # 更新背景的alpha通道(如果需要) + bg_alpha_region = bg[y:y+fg_height, x:x+fg_width, 3] + bg[y:y+fg_height, x:x+fg_width, 3] = np.maximum(bg_alpha_region, foreground[:, :, 3]) + + return bg + +def precise_blend_with_perspective(background, foreground, corners): + """ + 精确合成:根据四个角点将前景图像透视合成到背景上 + """ + # 创建与背景相同大小的空白图像 + bg_height, bg_width = background.shape[:2] + + # 如果背景没有alpha通道,转换为BGRA + if background.shape[2] == 3: + background_bgra = cv2.cvtColor(background, cv2.COLOR_BGR2BGRA) + else: + background_bgra = background.copy() + + # 创建与背景相同大小的前景图层 + foreground_layer = np.zeros((bg_height, bg_width, 4), dtype=np.uint8) + + # 计算前景图像在背景中的边界框 + min_x = int(min(corners[:, 0])) + max_x = int(max(corners[:, 0])) + min_y = int(min(corners[:, 1])) + max_y = int(max(corners[:, 1])) + + # 将变换后的前景图像放置到对应位置 + fg_height, fg_width = foreground.shape[:2] + if min_y + fg_height <= bg_height and min_x + fg_width <= bg_width: + foreground_layer[min_y:min_y+fg_height, min_x:min_x+fg_width] = foreground + + # 创建掩码(只在前景有内容的地方合成) + mask = (foreground_layer[:, :, 3] > 0) + + # 合成图像 + result = background_bgra.copy() + for c in range(3): + result[:, :, c][mask] = foreground_layer[:, :, c][mask] + result[:, :, 3][mask] = foreground_layer[:, :, 3][mask] + + return result + +def wiggle_transform(image) -> list[np.ndarray]: + ''' + 返回一组图像振动的帧组,模拟空调运作时的抖动效果 + ''' + frames = [] + height, width = image.shape[:2] + shifts = [(-2, 0), (2, 0), (0, -2), (0, 2), (0, 0)] + for dx, dy in shifts: + M = np.float32([[1, 0, dx], [0, 1, dy]]) + shifted = cv2.warpAffine(image, M, (width, height)) + frames.append(shifted) + return frames + +async def generate_ac_image(ac: AirConditioner) -> BytesIO: + # 找到空调底图 + ac_image = cv2.imread(str(ASSETS_PATH / "img" / "ac" / "ac.png"), cv2.IMREAD_UNCHANGED) + + if not ac.on: + # 空调关闭状态,直接返回底图 + pil_final = Image.fromarray(ac_image) + output = BytesIO() + pil_final.save(output, format="GIF") + return output + + # 根据生成温度文本图像 + text = f"{ac.temperature}°C" + text_image = text_to_transparent_image( + text, + font_size=60, + text_color=(0, 0, 0) # 黑色文字 + ) + + # 获取长宽比 + height, width = text_image.shape[:2] + aspect_ratio = width / height + + # 定义3D变换的四个角点(透视效果) + # 顺序: [左上, 右上, 右下, 左下] + corners = np.array([ + [123, 45], # 左上 + [284, 101], # 右上 + [290, 140], # 右下 + [119, 100] # 左下 + ], dtype=np.float32) + + # 对文本图像进行3D变换(保持透明通道) + transformed_text, transform_matrix = perspective_transform(text_image, ac_image, corners) + + final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0)) + + frames = wiggle_transform(final_image_simple) + pil_frames = [Image.fromarray(frame) for frame in frames] + output = BytesIO() + pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50) + return output \ No newline at end of file diff --git a/konabot/plugins/idiomgame/__init__.py b/konabot/plugins/idiomgame/__init__.py index 79fb508..84dce7b 100644 --- a/konabot/plugins/idiomgame/__init__.py +++ b/konabot/plugins/idiomgame/__init__.py @@ -69,10 +69,11 @@ class TryStopState(Enum): class TryVerifyState(Enum): VERIFIED = 0 VERIFIED_AND_REAL = 1 - NOT_IDIOM = 2 - WRONG_FIRST_CHAR = 3 - BUT_NO_NEXT = 4 - GAME_END = 5 + ALREADY_USED = 2 + NOT_IDIOM = 3 + WRONG_FIRST_CHAR = 4 + BUT_NO_NEXT = 5 + GAME_END = 6 class IdiomGame: @@ -96,12 +97,14 @@ class IdiomGame: self.all_buff_score = 0 self.lock = asynkio.Lock() self.remain_rounds = 0 # 剩余回合数 + self.already_idioms: dict[str, int] = {} # 已经使用过的成语和使用过的次数 + self.idiom_history: list[list[str]] = [] # 成语使用历史记录,多个数组以存储不同成语链 IdiomGame.INSTANCE_LIST[group_id] = self def be_able_to_play(self) -> bool: if self.last_play_date != datetime.date.today(): self.last_play_date = datetime.date.today() - self.remain_playing_times = 1 + self.remain_playing_times = 3 if self.remain_playing_times > 0: self.remain_playing_times -= 1 return True @@ -115,6 +118,8 @@ class IdiomGame: self.last_char = self.last_idiom[-1] if not self.is_nextable(self.last_char): self.choose_start_idiom() + else: + self.add_history_idiom(self.last_idiom, new_chain=True) return self.last_idiom @classmethod @@ -148,6 +153,9 @@ class IdiomGame: def clear_score_board(self): self.score_board = {} self.last_char = "" + self.all_buff_score = 0 + self.already_idioms = {} + self.idiom_history = [] def get_score_board(self) -> dict: return self.score_board @@ -169,6 +177,8 @@ class IdiomGame: self.last_char = self.last_idiom[-1] if not self.is_nextable(self.last_char): self._skip_idiom_async() + else: + self.add_history_idiom(self.last_idiom, new_chain=True) return self.last_idiom async def try_verify_idiom(self, idiom: str, user_id: str) -> TryVerifyState: @@ -184,6 +194,29 @@ class IdiomGame: 判断是否有成语可以接 """ return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR + + def add_already_idiom(self, idiom: str): + if idiom in self.already_idioms: + self.already_idioms[idiom] += 1 + else: + self.already_idioms[idiom] = 1 + + def get_already_used_num(self, idiom: str) -> int: + if idiom in self.already_idioms: + return self.already_idioms[idiom] + return 0 + + def add_history_idiom(self, idiom: str, new_chain: bool = False): + if new_chain or len(self.idiom_history) == 0: + self.idiom_history.append([idiom]) + else: + self.idiom_history[-1].append(idiom) + + def display_history(self) -> list[str]: + result = [] + for chain in self.idiom_history: + result.append(" -> ".join(chain)) + return result def _verify_idiom(self, idiom: str, user_id: str) -> list[TryVerifyState]: state = [] @@ -196,13 +229,18 @@ class IdiomGame: state.append(TryVerifyState.NOT_IDIOM) return state # 成语合法,更新状态 + self.add_history_idiom(idiom) + score_k = 0.5 ** self.get_already_used_num(idiom) # 每被使用过一次,得分减半 + if(score_k != 1): + state.append(TryVerifyState.ALREADY_USED) + self.add_already_idiom(idiom) state.append(TryVerifyState.VERIFIED) self.last_idiom = idiom self.last_char = idiom[-1] - self.add_score(user_id, 1) + self.add_score(user_id, 1 * score_k) # 先加 1 分 if idiom in IdiomGame.ALL_IDIOMS: state.append(TryVerifyState.VERIFIED_AND_REAL) - self.add_score(user_id, 4) # 再加 4 分 + self.add_score(user_id, 4 * score_k) # 再加 4 分 self.remain_rounds -= 1 if self.remain_rounds <= 0: self.now_playing = False @@ -217,7 +255,7 @@ class IdiomGame: if user_id not in self.score_board: return 0 # 避免浮点数精度问题导致过长 - handled_score = round(self.score_board[user_id]["score"], 1) + handled_score = round(self.score_board[user_id]["score"] + self.all_buff_score, 1) return handled_score def add_score(self, user_id: str, score: int): @@ -401,7 +439,7 @@ async def end_game(event: BaseEvent, group_id: str): result_text = UniMessage().text("游戏结束!\n最终得分榜:\n") score_board = instance.get_score_board() if len(score_board) == 0: - result_text += "无人得分!" + result_text += "无人得分!\n" else: # 按分数排序,名字用 at 的方式 sorted_score = sorted( @@ -413,6 +451,13 @@ async def end_game(event: BaseEvent, group_id: str): + UniMessage().at(user_id) + f": {round(info['score'] + instance.get_all_buff_score(), 1)} 分\n" ) + if len(instance.idiom_history) == 0: + result_text += "\n本局没有任何接龙记录。" + else: + result_text += "\n你们的接龙记录是:\n" + history_lines = instance.display_history() + for line in history_lines: + result_text += line + "\n" await evt.send(await result_text.export()) instance.clear_score_board() @@ -499,20 +544,39 @@ async def _(event: BaseEvent, msg: UniMsg, target: DepLongTaskTarget): .export() ) return + already_used_num = instance.get_already_used_num(user_idiom) if TryVerifyState.VERIFIED_AND_REAL in state: - await evt.send( - await UniMessage() - .at(user_id) - .text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!") - .export() - ) + score = 5 * (0.5 ** (already_used_num - 1)) + if already_used_num > 1: + await evt.send( + await UniMessage() + .at(user_id) + .text(f" 接上了,这是个被重复用过的成语,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!") + .export() + ) + else: + await evt.send( + await UniMessage() + .at(user_id) + .text(f" 接上了,这是个真实成语,喜提 5 分!你有 {instance.get_user_score(user_id)} 分!") + .export() + ) elif TryVerifyState.VERIFIED in state: - await evt.send( - await UniMessage() - .at(user_id) - .text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!") - .export() - ) + score = 1 * (0.5 ** (already_used_num - 1)) + if already_used_num > 1: + await evt.send( + await UniMessage() + .at(user_id) + .text(f" 接上了,但重复了,喜提 {score} 分!你有 {instance.get_user_score(user_id)} 分!") + .export() + ) + else: + await evt.send( + await UniMessage() + .at(user_id) + .text(f" 接上了,喜提 1 分!你有 {instance.get_user_score(user_id)} 分!") + .export() + ) if TryVerifyState.GAME_END in state: await evt.send(await UniMessage().text("全部回合结束!").export()) await end_game(event, group_id)