diff --git a/README.md b/README.md index a04767c..ff9180c 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,9 @@ npm run build | `confirm_yes` | 是 | 确认账号正确 | | `confirm_no` | 不是 | 确认账号不正确,重新扫码 | | `run` | 执行, 跑, 开始 | 执行配置组(需带参数) | -| `daily` | 自动每日 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 | -| `switch_party` | 切换队伍, 更换队伍 | 修改并执行配置组“切换队伍” | -| `edit_party` | 修改队员, 更换队员 | 校验四名角色后修改并执行配置组“修改队员” | +| `daily` | 自动每日, 每日, 日常, 做每日, 做日常 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 | +| `switch_party` | 切换队伍, 更换队伍, 换队伍, 换队 | 修改并执行配置组“切换队伍” | +| `edit_party` | 修改队员, 更换队员, 换队员, 换角色 | 校验四名角色后修改并执行配置组“修改队员” | | `leave` | 退出 | 退出队列 | | `reset` | 重置 | 一级用户重启原神和 BGI | | `points` | 积分 | 查询积分 | diff --git a/app/danmu_queue.py b/app/danmu_queue.py index 29c54c6..0538645 100644 --- a/app/danmu_queue.py +++ b/app/danmu_queue.py @@ -251,6 +251,72 @@ COMMAND_ALLOWED_ROLE_DEFAULTS = { "queue_list": ALL_DANMU_ROLES, "help": ALL_DANMU_ROLES, } +NEW_COMMAND_ALIAS_DEFAULTS = { + "daily": ["自动每日", "每日", "日常", "做每日", "做日常"], + "switch_party": ["切换队伍", "更换队伍", "换队伍", "换队"], + "edit_party": ["修改队员", "更换队员", "换队员", "换角色"], +} +NEW_COMMAND_PREFERRED_ALIASES = { + "daily": "每日", + "switch_party": "换队", + "edit_party": "换角色", +} +FRONTEND_COMMAND_HINT_KEYS = tuple(NEW_COMMAND_ALIAS_DEFAULTS) + + +def configured_command_aliases( + config_data: dict[str, Any], + command_key: str, + fallback: list[str] | tuple[str, ...] = (), +) -> list[str]: + commands = config_data.get("commands", {}) + cfg = commands.get(command_key, {}) if isinstance(commands, dict) else {} + if not isinstance(cfg, dict) or not cfg.get("enabled", True): + return [] + source = cfg.get("aliases") + if not isinstance(source, list): + source = list(fallback) + aliases: list[str] = [] + for item in source: + alias = str(item).strip() + if alias and alias not in aliases: + aliases.append(alias) + return aliases + + +def preferred_command_alias( + config_data: dict[str, Any], + command_key: str, + fallback: str, +) -> str: + aliases = configured_command_aliases(config_data, command_key, (fallback,)) + preferred = NEW_COMMAND_PREFERRED_ALIASES.get(command_key, "") + if preferred in aliases: + return preferred + return min(aliases, key=len) if aliases else fallback + + +def build_public_command_hints(config_data: dict[str, Any]) -> dict[str, dict[str, Any]]: + commands = config_data.get("commands", {}) + hints: dict[str, dict[str, Any]] = {} + for command_key in FRONTEND_COMMAND_HINT_KEYS: + cfg = commands.get(command_key, {}) if isinstance(commands, dict) else {} + enabled = isinstance(cfg, dict) and bool(cfg.get("enabled", True)) + aliases = configured_command_aliases( + config_data, + command_key, + NEW_COMMAND_ALIAS_DEFAULTS[command_key], + ) + hints[command_key] = { + "enabled": enabled, + "aliases": aliases, + "preferred_alias": preferred_command_alias( + config_data, + command_key, + NEW_COMMAND_PREFERRED_ALIASES[command_key], + ) if aliases else "", + } + return hints def normalize_allowed_roles(value: Any, default: list[str]) -> list[str]: @@ -628,9 +694,9 @@ class Config: "confirm_yes": {"enabled": True, "aliases": ["是"]}, "confirm_no": {"enabled": True, "aliases": ["不是"]}, "run": {"enabled": True, "aliases": ["执行", "跑", "开始"]}, - "daily": {"enabled": True, "aliases": ["自动每日"]}, - "switch_party": {"enabled": True, "aliases": ["切换队伍", "更换队伍"]}, - "edit_party": {"enabled": True, "aliases": ["修改队员", "更换队员"]}, + "daily": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["daily"])}, + "switch_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["switch_party"])}, + "edit_party": {"enabled": True, "aliases": list(NEW_COMMAND_ALIAS_DEFAULTS["edit_party"])}, "leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]}, "reset": {"enabled": True, "aliases": ["重置"]}, "points": {"enabled": True, "aliases": ["积分"]}, @@ -5817,7 +5883,22 @@ class CommandHandler: if answer == "是": self.queue_mgr.set_login_status("logged_in") self.logger.info(f"[登录确认] {uname}({uid}) 确认账号正确,升为二级") - await self.broadcast(f"「{uname}」账号已确认,发送\"执行 组名\"开始(如: 执行 泡泡桔)") + daily_alias = preferred_command_alias(self.config.data, "daily", "自动每日") + switch_alias = preferred_command_alias(self.config.data, "switch_party", "切换队伍") + edit_alias = preferred_command_alias(self.config.data, "edit_party", "修改队员") + run_aliases = configured_command_aliases(self.config.data, "run", ("执行",)) + run_alias = run_aliases[0] if run_aliases else "执行" + await self.broadcast( + f"「{uname}」账号已确认,可发送“{daily_alias}”完成基础每日", + tts=True, + ) + await self.broadcast( + f"其他模式:{daily_alias} 秘境 风本 / {daily_alias} 地脉 经验 蒙德 / " + f"{daily_alias} 委托;{switch_alias} 永冻队 / {edit_alias} 四名角色;" + f"普通任务仍发送“{run_alias} 组名”", + tts=False, + danmu=True, + ) return self.logger.info(f"[登录确认] {uname}({uid}) 确认不是本人账号,重新扫码上号") await self.broadcast(f"「{uname}」账号不正确,正在关闭当前账号并重新扫码上号", tts=True) @@ -6536,12 +6617,8 @@ class CommandHandler: def _get_cmd_aliases_str(self, key: str, fallback: str = "") -> str: """获取指令的别名展示字符串,用于帮助信息。""" - cmds = self.config.data.get("commands", {}) - cfg = cmds.get(key, {}) - if cfg.get("enabled", True): - aliases = cfg.get("aliases", []) - return "/".join(str(a).strip() for a in aliases if str(a).strip()) - return fallback + aliases = configured_command_aliases(self.config.data, key, (fallback,)) + return "/".join(aliases) if aliases else fallback async def _cmd_help(self, uid: int, uname: str): q = self._get_cmd_aliases_str("queue", "排队") @@ -10082,7 +10159,7 @@ class WebServer: return web.Response(status=404, text="no upload") async def _api_frontend_config(self, request): - """返回前台视觉配置。""" + """返回前台视觉配置和可公开展示的指令提示。""" cfg = self.config.data.setdefault("frontend", {}) return web.json_response({ "background_image": cfg.get("background_image", ""), @@ -10090,6 +10167,7 @@ class WebServer: "background_blur": float(cfg.get("background_blur", 0)), "background_fit": cfg.get("background_fit", "cover"), "theme": cfg.get("theme", "classic"), + "command_hints": build_public_command_hints(self.config.data), }) async def _api_music_monitor_config(self, request): diff --git a/config/config.json b/config/config.json index 4eb8652..5b50846 100644 --- a/config/config.json +++ b/config/config.json @@ -260,7 +260,11 @@ "daily": { "enabled": true, "aliases": [ - "自动每日" + "自动每日", + "每日", + "日常", + "做每日", + "做日常" ], "allowed_roles": [ "super_admin", @@ -271,7 +275,9 @@ "enabled": true, "aliases": [ "切换队伍", - "更换队伍" + "更换队伍", + "换队伍", + "换队" ], "allowed_roles": [ "super_admin", @@ -282,7 +288,9 @@ "enabled": true, "aliases": [ "修改队员", - "更换队员" + "更换队员", + "换队员", + "换角色" ], "allowed_roles": [ "super_admin", diff --git a/docs/自动每日.md b/docs/自动每日.md index 1578937..b3a3f83 100644 --- a/docs/自动每日.md +++ b/docs/自动每日.md @@ -10,32 +10,46 @@ BetterGI.exe startOneDragon <配置名称> ```text 自动每日 +每日 +日常 +做每日 +做日常 自动每日 秘境 <秘境正式名称或俗称> 自动每日 地脉 <经验|摩拉> <国家> 自动每日 委托 切换队伍 <队伍名称> 更换队伍 <队伍名称> +换队伍 <队伍名称> +换队 <队伍名称> 修改队员 <队员1> <队员2> <队员3> <队员4> 更换队员 <队员1> <队员2> <队员3> <队员4> +换队员 <队员1> <队员2> <队员3> <队员4> +换角色 <队员1> <队员2> <队员3> <队员4> ``` 示例: ```text 自动每日 +每日 +日常 秘境 风本 自动每日 秘境 风本 自动每日 秘境 少女套 自动每日 地脉 经验 蒙德 自动每日 地脉 摩拉 枫丹 自动每日 委托 切换队伍 永冻队 +换队 永冻队 修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海 +换角色 神里绫华 申鹤 枫原万叶 珊瑚宫心海 修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海 ``` 队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。 +执行类指令可以省略指令后的空格,例如 `每日秘境 风本`、`换队永冻队`、`换角色芙宁娜纳西妲钟离雷电将军`。系统按最长别名优先解析,因此“换队员”不会被误识别为“换队”。 + ## 每日流程 每日流程固定为: @@ -177,3 +191,4 @@ User/JsScript/AutoSwitchRoles/combat_avatar.json - 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json` 或 `combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。 - BGI 启动失败:确认版本为 `0.63.0+`、`bettergi.exe_path` 和 `bettergi.work_dir` 正确,并查看 BetterGI 日志。 - 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。 +- 自动秘境异常后扫码上号卡在“退出秘境”:确认项目维护的 `integrations/扫码上号` 已同步。脚本会 OCR 识别“退出秘境”并点击右侧确认,OCR 按钮定位失败时使用 1920×1080 坐标 `(1164, 757)` 兜底。 diff --git a/integrations/扫码上号/main.js b/integrations/扫码上号/main.js index 6855fc3..b753158 100644 --- a/integrations/扫码上号/main.js +++ b/integrations/扫码上号/main.js @@ -1,6 +1,6 @@ (async function () { // ======================================== - // 扫码上号 v3 + // 扫码上号 v3.1 // 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成 // 状态文件: status.txt (登录中 / 已登录) // ======================================== @@ -20,6 +20,25 @@ // 脚本启动 → 写入"登录中" writeStatus("登录中"); + function settingNumber(name, fallback) { + const value = typeof settings !== "undefined" && settings ? Number(settings[name]) : NaN; + return Number.isFinite(value) ? value : fallback; + } + + function settingBoolean(name, fallback) { + if (typeof settings === "undefined" || !settings || settings[name] === undefined) return fallback; + return settings[name] !== false && String(settings[name]).toLowerCase() !== "false"; + } + + const useExitDomainOcr = settingBoolean("useOcr", true); + let confirmExitX = settingNumber("confirmExitX", 1164); + let confirmExitY = settingNumber("confirmExitY", 757); + if (confirmExitX === 830 && confirmExitY === 600) { + confirmExitX = 1164; + confirmExitY = 757; + log.info("检测到旧版退出秘境坐标,已自动修正为 (1164, 757)"); + } + // ---------- 加载图像资源 ---------- const tapMat = file.readImageMatSync("assets/tap.png"); const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png"); @@ -115,6 +134,54 @@ return null; } + function normalizeOcrText(text) { + return String(text || "").replace(/\s+/g, ""); + } + + function readOcrResults(x, y, w, h) { + const cap = captureGameRegion(); + try { + const results = cap.findMulti(RecognitionObject.ocr(x, y, w, h)); + return Array.from(results).map((result) => ({ + text: normalizeOcrText(result.text), + x: Number(result.x || 0), + y: Number(result.y || 0), + w: Number(result.width || result.Width || 0), + h: Number(result.height || result.Height || 0) + })); + } catch (e) { + log.warn("退出秘境弹窗 OCR 失败: " + e); + return []; + } finally { + cap.dispose(); + } + } + + async function handleExitDomainDialog() { + const results = readOcrResults(480, 220, 960, 620); + const combinedText = results.map((result) => result.text).join(""); + if (!combinedText.includes("退出秘境")) return false; + + log.info("检测到退出秘境确认弹窗"); + const confirmResult = results + .filter((result) => result.text.includes("确认") && result.x >= 900) + .sort((left, right) => right.x - left.x)[0]; + if (useExitDomainOcr && confirmResult) { + const x = Math.round(confirmResult.x + confirmResult.w / 2); + const y = Math.round(confirmResult.y + confirmResult.h / 2); + log.info(`OCR 定位确认按钮,点击 (${x}, ${y})`); + click(x, y); + } else { + const reason = useExitDomainOcr ? "OCR 未定位到确认按钮" : "已关闭 OCR 按钮定位"; + log.warn(`${reason},使用固定坐标 (${confirmExitX}, ${confirmExitY})`); + click(confirmExitX, confirmExitY); + } + + log.info("已点击退出秘境确认,等待返回主界面..."); + await sleep(6000); + return true; + } + // ======================================== // 前置处理:检测登录前的提示图标 // 检测到则点击 (1830, 985),1秒后点击 (1100, 675),再1秒后进入初始判定; @@ -155,11 +222,20 @@ log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像"); let paimonFound = findImageMatchRobust(paimonMat); + if (!paimonFound && await handleExitDomainDialog()) { + paimonFound = findImageMatchRobust(paimonMat); + if (paimonFound) log.info("退出秘境后已检测到派蒙头像"); + } + for (let i = 0; !paimonFound && i < 8; i++) { log.info(`第 ${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`); keyPress("Escape"); await sleep(1000); + if (await handleExitDomainDialog()) { + log.info("退出秘境弹窗已处理,重新检测主界面"); + } paimonFound = findImageMatchRobust(paimonMat); + if (paimonFound) log.info("已检测到派蒙头像,继续退出账号流程"); } if (!paimonFound) { diff --git a/integrations/扫码上号/manifest.json b/integrations/扫码上号/manifest.json index 4b2c948..23eb35e 100644 --- a/integrations/扫码上号/manifest.json +++ b/integrations/扫码上号/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "扫码上号", - "version": "1.0", + "version": "1.1", "bgi_version": "0.48.0", "description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码", "authors": [ diff --git a/integrations/扫码上号/settings.json b/integrations/扫码上号/settings.json index 40d19e7..49577ba 100644 --- a/integrations/扫码上号/settings.json +++ b/integrations/扫码上号/settings.json @@ -44,7 +44,7 @@ { "name": "useOcr", "type": "checkbox", - "label": "使用OCR识别文本(否则用固定坐标)", + "label": "退出秘境确认按钮使用 OCR 定位(关闭后使用固定坐标)", "default": true }, { @@ -53,14 +53,14 @@ { "name": "confirmExitX", "type": "input-text", - "label": "确认退出按钮 X(固定坐标模式)", - "default": "830" + "label": "退出秘境确认按钮 X(1920×1080 固定坐标)", + "default": "1164" }, { "name": "confirmExitY", "type": "input-text", - "label": "确认退出按钮 Y(固定坐标模式)", - "default": "600" + "label": "退出秘境确认按钮 Y(1920×1080 固定坐标)", + "default": "757" }, { "name": "qrLoginX", diff --git a/tests/test_daily_commands.py b/tests/test_daily_commands.py index 1386179..079b415 100644 --- a/tests/test_daily_commands.py +++ b/tests/test_daily_commands.py @@ -1,4 +1,5 @@ import asyncio +import json import logging import tempfile import unittest @@ -21,6 +22,8 @@ from app.danmu_queue import ( COMMAND_ALLOWED_ROLE_DEFAULTS, BetterGIRunner, CommandHandler, + Config, + build_public_command_hints, ) @@ -32,14 +35,17 @@ class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase): data={ "commands": { "run": {"enabled": True, "aliases": ["执行", "跑"]}, - "daily": {"enabled": True, "aliases": ["自动每日"]}, + "daily": { + "enabled": True, + "aliases": ["自动每日", "每日", "日常", "做每日", "做日常"], + }, "switch_party": { "enabled": True, - "aliases": ["切换队伍", "更换队伍"], + "aliases": ["切换队伍", "更换队伍", "换队伍", "换队"], }, "edit_party": { "enabled": True, - "aliases": ["修改队员", "更换队员"], + "aliases": ["修改队员", "更换队员", "换队员", "换角色"], }, } }, @@ -315,6 +321,86 @@ class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase): self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"), ("修改队员", "芙宁娜纳西妲钟离雷电将军"), ) + self.assertEqual( + self.handler._split_command_text("日常秘境 风本"), + ("日常", "秘境 风本"), + ) + self.assertEqual( + self.handler._split_command_text("换队永冻队"), + ("换队", "永冻队"), + ) + self.assertEqual( + self.handler._split_command_text("换角色芙宁娜纳西妲钟离雷电将军"), + ("换角色", "芙宁娜纳西妲钟离雷电将军"), + ) + + def test_longest_new_alias_wins_for_contiguous_arguments(self): + self.assertEqual( + self.handler._split_command_text("换队员芙宁娜纳西妲钟离雷电将军"), + ("换队员", "芙宁娜纳西妲钟离雷电将军"), + ) + + async def test_confirmed_login_announces_daily_and_party_usage(self): + self.queue_mgr.state["login_status"] = "confirming" + + await self.handler._cmd_confirm_login(123, "测试用户", "是") + + self.assertEqual(self.broadcaster.broadcast.await_count, 2) + first, second = self.broadcaster.broadcast.await_args_list + self.assertIn("发送“每日”完成基础每日", first.args[0]) + self.assertTrue(first.kwargs["tts"]) + self.assertIn("每日 秘境 风本", second.args[0]) + self.assertIn("换队 永冻队", second.args[0]) + self.assertIn("换角色 四名角色", second.args[0]) + self.assertIn("执行 组名", second.args[0]) + self.assertFalse(second.kwargs["tts"]) + self.assertTrue(second.kwargs["danmu"]) + + def test_public_command_hints_are_sanitized_and_use_shortest_alias(self): + config_data = { + "commands": self.config.data["commands"], + "bilibili": {"cookie": "secret"}, + } + + hints = build_public_command_hints(config_data) + + self.assertEqual(set(hints), {"daily", "switch_party", "edit_party"}) + self.assertEqual(hints["daily"]["preferred_alias"], "每日") + self.assertEqual(hints["switch_party"]["preferred_alias"], "换队") + self.assertEqual(hints["edit_party"]["preferred_alias"], "换角色") + self.assertNotIn("bilibili", hints) + + def test_new_command_alias_defaults_are_complete(self): + config_path = self.root / "config.json" + config_path.write_text(json.dumps({}), encoding="utf-8") + + config = Config(str(config_path)) + + self.assertEqual( + config.data["commands"]["daily"]["aliases"], + ["自动每日", "每日", "日常", "做每日", "做日常"], + ) + self.assertEqual( + config.data["commands"]["switch_party"]["aliases"], + ["切换队伍", "更换队伍", "换队伍", "换队"], + ) + self.assertEqual( + config.data["commands"]["edit_party"]["aliases"], + ["修改队员", "更换队员", "换队员", "换角色"], + ) + + def test_disabled_command_hint_has_no_public_alias(self): + config_data = { + "commands": { + "daily": {"enabled": False, "aliases": ["每日"]}, + } + } + + hints = build_public_command_hints(config_data) + + self.assertFalse(hints["daily"]["enabled"]) + self.assertEqual(hints["daily"]["aliases"], []) + self.assertEqual(hints["daily"]["preferred_alias"], "") def test_new_command_permissions_match_run(self): for key in ("daily", "switch_party", "edit_party"): diff --git a/tests/test_login_script.py b/tests/test_login_script.py new file mode 100644 index 0000000..5420a7f --- /dev/null +++ b/tests/test_login_script.py @@ -0,0 +1,33 @@ +import json +import unittest +from pathlib import Path + + +class LoginScriptDomainExitTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.script_dir = Path(__file__).resolve().parents[1] / "integrations" / "扫码上号" + cls.source = (cls.script_dir / "main.js").read_text(encoding="utf-8") + cls.settings = json.loads((cls.script_dir / "settings.json").read_text(encoding="utf-8")) + cls.manifest = json.loads((cls.script_dir / "manifest.json").read_text(encoding="utf-8")) + + def test_domain_exit_requires_specific_ocr_text_before_click(self): + self.assertIn('combinedText.includes("退出秘境")', self.source) + self.assertIn('result.text.includes("确认") && result.x >= 900', self.source) + + def test_legacy_and_default_confirm_coordinates_are_corrected(self): + defaults = { + item.get("name"): item.get("default") + for item in self.settings + if isinstance(item, dict) and item.get("name") + } + self.assertEqual(defaults["confirmExitX"], "1164") + self.assertEqual(defaults["confirmExitY"], "757") + self.assertIn("confirmExitX === 830 && confirmExitY === 600", self.source) + + def test_private_login_script_version_is_updated(self): + self.assertEqual(self.manifest["version"], "1.1") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/index.html b/web/index.html index 315477c..315eda9 100644 --- a/web/index.html +++ b/web/index.html @@ -47,9 +47,10 @@ width: 100vw; height: 100vh; display: flex; - align-items: center; - justify-content: center; + align-items: flex-start; + justify-content: flex-start; background: transparent; + overflow: hidden; } body.preview #viewport { @@ -1315,6 +1316,34 @@ border: 1px solid rgba(255, 242, 201, 0.13); } + .command-quick-reference { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 12px; + margin: 0 12px 2px; + padding: 3px 8px; + border-top: 1px solid rgba(255, 242, 201, 0.15); + border-bottom: 1px solid rgba(255, 242, 201, 0.15); + color: rgba(244, 255, 247, 0.86); + font-size: 12px; + font-weight: 720; + line-height: 1.25; + } + + .command-quick-reference span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .command-quick-reference strong { + color: #fff0b5; + font-weight: 900; + } + .tutorial-stage { position: relative; z-index: 1; @@ -1322,19 +1351,20 @@ display: flex; align-items: center; justify-content: center; - padding: 0 14px 3px; + min-height: 32px; + padding: 0 10px 1px; overflow: hidden; } .tutorial-text { width: 100%; color: var(--text); - font-size: 26px; + font-size: 20px; font-weight: 900; - line-height: 1.35; + line-height: 1.25; text-align: center; white-space: nowrap; - letter-spacing: 0.7px; + letter-spacing: 0; text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18); opacity: 1; transform: translateY(0); @@ -1342,12 +1372,12 @@ } .tutorial-text.long { - font-size: 23px; - letter-spacing: 0.35px; + font-size: 18px; + letter-spacing: 0; } .tutorial-text.extra-long { - font-size: 21px; + font-size: 16px; letter-spacing: 0; } @@ -1852,6 +1882,7 @@
直播间使用说明
+
发送“签到”领取积分,发送“排队”加入队伍
@@ -1898,7 +1929,7 @@ return m + ":" + String(sec % 60).padStart(2, "0"); } - const tutorialSlides = [ + const baseTutorialSlides = [ "发送“签到”领取积分,发送“排队”加入队伍", "成为队首后,请在90秒内发送“上号”", "发送“上号”后,请在4分钟内完成扫码登录", @@ -1907,8 +1938,85 @@ "发送“积分”查询积分,发送“队列”查看排队情况", "发送“退出”离开队伍,发送“点歌 歌名”点歌" ]; + const fallbackCommandHints = { + daily: { enabled: true, aliases: ["自动每日", "每日", "日常"], preferred_alias: "每日" }, + switch_party: { enabled: true, aliases: ["切换队伍", "换队"], preferred_alias: "换队" }, + edit_party: { enabled: true, aliases: ["修改队员", "换角色"], preferred_alias: "换角色" } + }; + let commandHints = fallbackCommandHints; + let tutorialSlides = baseTutorialSlides.slice(); let tutorialIndex = 0; + function normalizeCommandHint(value, fallback) { + const source = value && typeof value === "object" ? value : fallback; + const aliases = Array.isArray(source.aliases) + ? source.aliases.map((item) => String(item || "").trim()).filter(Boolean) + : fallback.aliases.slice(); + const preferred = String(source.preferred_alias || "").trim() + || aliases.reduce((best, item) => !best || item.length < best.length ? item : best, "") + || fallback.preferred_alias; + return { + enabled: source.enabled !== false, + aliases, + preferred_alias: preferred + }; + } + + function renderCommandQuickReference() { + const el = document.getElementById("command-quick-reference"); + if (!el) return; + const daily = commandHints.daily; + if (!daily || !daily.enabled) { + el.hidden = true; + el.innerHTML = ""; + return; + } + const cmd = esc(daily.preferred_alias || "每日"); + el.hidden = false; + el.innerHTML = [ + `${cmd}:基础流程`, + `${cmd} 秘境 <名称>`, + `${cmd} 地脉 <类型> <国家>`, + `${cmd} 委托:每日委托` + ].join(""); + } + + function rebuildTutorialSlides() { + const slides = baseTutorialSlides.slice(); + const daily = commandHints.daily; + const switchParty = commandHints.switch_party; + const editParty = commandHints.edit_party; + if (daily && daily.enabled) { + const cmd = daily.preferred_alias || "每日"; + slides.push(`发送“${cmd}”完成邮件、树脂、尘歌壶和每日奖励`); + slides.push(`发送“${cmd} 秘境 风本”刷到树脂耗尽`); + slides.push(`发送“${cmd} 地脉 经验 蒙德”刷经验地脉`); + slides.push(`发送“${cmd} 委托”执行每日委托配置`); + } + const partyParts = []; + if (switchParty && switchParty.enabled) { + partyParts.push(`“${switchParty.preferred_alias || "换队"} 永冻队”切换队伍`); + } + if (editParty && editParty.enabled) { + partyParts.push(`“${editParty.preferred_alias || "换角色"} 四名角色”修改队员`); + } + if (partyParts.length) slides.push(`发送${partyParts.join(",发送")}`); + tutorialSlides = slides; + tutorialIndex %= tutorialSlides.length; + renderCommandQuickReference(); + showTutorialSlide(tutorialIndex, false); + } + + function applyCommandHints(value) { + const source = value && typeof value === "object" ? value : {}; + commandHints = { + daily: normalizeCommandHint(source.daily, fallbackCommandHints.daily), + switch_party: normalizeCommandHint(source.switch_party, fallbackCommandHints.switch_party), + edit_party: normalizeCommandHint(source.edit_party, fallbackCommandHints.edit_party) + }; + rebuildTutorialSlides(); + } + function renderTutorialDots() { const dots = document.getElementById("tutorial-dots"); if (!dots) return; @@ -2344,8 +2452,10 @@ themeClasses.forEach((c) => document.body.classList.remove(c)); const theme = cfg.theme || "classic"; if (theme && theme !== "classic") document.body.classList.add("theme-" + theme); + applyCommandHints(cfg.command_hints); } catch (e) { console.error("frontend-config", e); + applyCommandHints(fallbackCommandHints); } } @@ -2647,9 +2757,12 @@ function scaleCanvas() { const canvas = document.getElementById("canvas"); const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080); - canvas.style.transform = "scale(" + scale + ")"; + const offsetX = Math.max(0, (window.innerWidth - 1920 * scale) / 2); + const offsetY = Math.max(0, (window.innerHeight - 1080 * scale) / 2); + canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; } + applyCommandHints(fallbackCommandHints); startTutorialCarousel(); fetchFrontendConfig(); fetchState();