refactor: 删除 legacy 的大部分内容

This commit is contained in:
unanmed 2026-10-19 20:12:47 +08:00
parent aea30987f6
commit f53f611db0
50 changed files with 149 additions and 7781 deletions

View File

@ -93,13 +93,13 @@ Output: 已清空的 `docs/`(无旧内容)+ 已清理并注册好的 `docs/.
(1) 删除旧内容:删除文件 `docs/index.md`,并递归删除目录 `docs/guide/`、`docs/dev/`、`docs/logger/`、`docs/api/`(连同其中全部文件与子目录,例如 `docs/guide/img/`、`docs/api/*/` 等)。用文件系统删除(如 `Remove-Item -Recurse -Force`),使 git 把这些受跟踪路径记为删除;不要遗留空目录。删除白名单严格限定为上述五个路径,绝不触碰 `docs/intermediate/`、`docs/.vitepress/`。
(2) 就地编辑 `docs/.vitepress/config.ts`(最小作用域 Edit):从首部静态 import 中删除两条属 API 侧边栏机制的导入——从 `./apiSidebar` 导入的默认值(第 3 行),以及从 `./api` 导入的 `generateSidebar`(第 5 行);删除仅在开发期调用 `generateSidebar` 的 `listenSidebar()` 函数定义及其在 `vite.plugins` 数组中的调用(`MermaidPlugin()` 保留、`optimizeDeps.include` 与 `ssr.noExternal` 保留)。从 `themeConfig.nav` 删除四条指向已删内容的旧入口(主页 / 指南 / API / 错误代码),nav 数组留待 Task 2 写入「中级教程」一条。从 `themeConfig.sidebar` 删除三个指向已删章节的旧键(分别对应 guide、logger、api 三个路径前缀)及其中全部内容。其余配置(`title` / `description` / `base` / `markdown` / `outDir` / `socialLinks` / `search` / `locales`)逐字保留。
(2) 就地编辑 `docs/.vitepress/config.ts`(最小作用域 Edit):从首部静态 import 中删除两条属 API 侧边栏机制的导入——从 `./apiSidebar` 导入的默认值(第 3 行),以及从 `./api` 导入的 `generateSidebar`(第 5 行);删除仅在开发期调用 `generateSidebar` 的 `listenSidebar()` 函数定义及其在 `vite.plugins` 数组中的调用(`MermaidPlugin()` 保留、`optimizeDeps.include` 与 `ssr.noExternal` 保留);并同步删除此时已成为未使用导入的 `Plugin` 类型——`listenSidebar()` 是它唯一的消费者,故 `docs/.vitepress/config.ts:1` 的 `import { defineConfig, Plugin } from 'vitepress'` 须改为 `import { defineConfig } from 'vitepress'`(否则命中 `docs/**/*.ts` 上 eslint `@typescript-eslint/no-unused-vars`(error),使 lint 失败)。从 `themeConfig.nav` 删除四条指向已删内容的旧入口(主页 / 指南 / API / 错误代码),nav 数组留待 Task 2 写入「中级教程」一条。从 `themeConfig.sidebar` 删除三个指向已删章节的旧键(分别对应 guide、logger、api 三个路径前缀)及其中全部内容。其余配置(`title` / `description` / `base` / `markdown` / `outDir` / `socialLinks` / `search` / `locales`)逐字保留。
(3) 编辑 `docs/.vitepress/api.ts`:在 `generateSidebar()` 入口处加一个「`docs/api` 目录不存在时直接 `return`」的守卫,使 `docs/api/` 被删除后 `tsx docs/.vitepress/init.ts`(以及依赖它的 `docs:dev`)不再因对缺失目录 `readdir` 抛错。守卫用 `node:fs` 的 `existsSync` 判断(dev.md 禁止 try-catch);新增的 `import { existsSync } from 'node:fs';` 放在文件首部既有 import 之后、import 之间不得插注释或空行。**不改动** `generateSidebar()` 的扫描 / 命名 / 排序 / 写文件逻辑(D-03:保留按包生成机制,供后续 API 参考重建)。若工作区残留 gitignored 的 `docs/.vitepress/apiSidebar.ts`,一并删除(已无消费者,且为会被覆盖的生成物)。
完成三步后运行构建命令确认站点通过。删除后 `docs/` 下已无 markdown 页面,VitePress 总会注入虚拟 `404.md`,故零页面时构建仍可成功;本步只需验证「无 import / dead-link 失败」。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $gone=@('docs/guide','docs/dev','docs/logger','docs/api','docs/index.md'); foreach($p in $gone){ if(Test-Path $p){ Write-Error "STILL-PRESENT $p" } }; $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); if($cfg -match "'/guide/'"){ Write-Error "CFG-OLD-GUIDE-KEY" }; if($cfg -match "'/logger/'"){ Write-Error "CFG-OLD-LOGGER-KEY" }; if($cfg -match "'/api/'"){ Write-Error "CFG-OLD-API-KEY" }; if($cfg -match "apiSidebar"){ Write-Error "CFG-APISIDEBAR-IMPORT" }; if($cfg -match "listenSidebar"){ Write-Error "CFG-LISTENSIDEBAR" }; if($cfg -match "generateSidebar"){ Write-Error "CFG-GENERATESIDEBAR" }; $api=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/api.ts')); if($api -notmatch "existsSync"){ Write-Error "API-NO-GUARD" }; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; if($LASTEXITCODE -ne 0){ $out | Write-Output; exit 1 }; $txt = $out -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'W0-CLEAR-OK'</automated>
<automated>$ErrorActionPreference='Stop'; $gone=@('docs/guide','docs/dev','docs/logger','docs/api','docs/index.md'); foreach($p in $gone){ if(Test-Path $p){ Write-Error "STILL-PRESENT $p" } }; $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); if($cfg -match "'/guide/'"){ Write-Error "CFG-OLD-GUIDE-KEY" }; if($cfg -match "'/logger/'"){ Write-Error "CFG-OLD-LOGGER-KEY" }; if($cfg -match "'/api/'"){ Write-Error "CFG-OLD-API-KEY" }; if($cfg -match "apiSidebar"){ Write-Error "CFG-APISIDEBAR-IMPORT" }; if($cfg -match "listenSidebar"){ Write-Error "CFG-LISTENSIDEBAR" }; if($cfg -match "generateSidebar"){ Write-Error "CFG-GENERATESIDEBAR" }; $api=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/api.ts')); if($api -notmatch "existsSync"){ Write-Error "API-NO-GUARD" }; $ErrorActionPreference='Continue'; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; $ec=$LASTEXITCODE; $ErrorActionPreference='Stop'; if($ec -ne 0){ $out | Write-Output; exit 1 }; $txt = ($out | ForEach-Object { $_.ToString() }) -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'W0-CLEAR-OK'</automated>
<fails_when>non-zero exit, or output contains "STILL-PRESENT", "CFG-OLD-", "CFG-APISIDEBAR-IMPORT", "CFG-LISTENSIDEBAR", "CFG-GENERATESIDEBAR", "API-NO-GUARD", "NO-BUILD-COMPLETE", "DEAD-LINK", or "IMPORT-FAIL", or output lacks "W0-CLEAR-OK"</fails_when>
</verify>
<done>旧文档树与首页已删除;`config.ts` 不再含旧 nav/sidebar 键与 apiSidebar/api 导入;`api.ts` 含缺失目录守卫;`tsx docs/.vitepress/init.ts` 不抛错且 `docs:build` 打印 build complete、无 dead link / import 错误;输出 W0-CLEAR-OK。</done>
@ -110,7 +110,7 @@ Output: 已清空的 `docs/`(无旧内容)+ 已清理并注册好的 `docs/.
<files>docs/.vitepress/config.ts, docs/intermediate/</files>
<action>用**就地编辑(Edit,最小作用域)**修改 `docs/.vitepress/config.ts`,只做两处新增,并对 Task 1 已清空的 nav/sidebar 收尾。(1) 在 `nav` 数组写入一条 `{ text: '中级教程', link: '/intermediate/data-vs-render' }`(Task 1 已移除四条旧入口,此处之后 nav 仅此一条)。(2) 在 `sidebar` 对象中新增 `'/intermediate/'` 键,其值为一个 `{ text: '中级教程', collapsed: false, items: [...] }`,`items` 内按 D-20 顺序嵌套四个分组:`概念与导航`(`data-vs-render` / `src-tour` / `code-basics` / `hooks`)、`基础接口`(`data-state-overview` / `hero-attribute` / `hero-items` / `hero-equipment` / `hero-location` / `map-structure` / `map-tiles` / `flags`)、`战斗`(`combat-overview` / `enemy-specials` / `enemy-aura` / `enemy-query` / `enemy-final` / `enemy-calculator` / `enemy-map-damage` / `enemy-combat-script` / `enemy-status-hooks`)、`其他`(`events` / `replay-commands` / `data-plugins`)。每条写为 `{ text: '<已获批显示名>', link: '/intermediate/<文件名>' }`(绝对路径、不带 `.md`,写法与既有条目一致;分组嵌套写法参照 `'/guide/'` 原嵌套形状),显示名沿用此前获批的:`数据端与渲染端` / `src 导览` / `代码基础` / `钩子` / `数据端总览` / `勇士属性` / `勇士道具` / `勇士装备` / `勇士位置` / `地图结构` / `地图图块` / `Flag 变量` / `战斗总览` / `怪物特殊属性` / `光环` / `查询效果` / `最终属性` / `伤害计算` / `地图伤害` / `战斗脚本` / `状态钩子` / `自定义事件` / `录像指令` / `数据插件`(依次对应上述 24 个文件名)。创建目录 `docs/intermediate/`(`New-Item -ItemType Directory -Force -Path docs/intermediate`),本步仅为后续写作计划与本地预览提供落点(git 不跟踪空目录)。新增后运行构建命令确认站点通过(命令先跑 init.ts,init.ts 现已在 `docs/api/` 缺失时安全跳过)。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $names=@('data-vs-render','src-tour','code-basics','hooks','data-state-overview','hero-attribute','hero-items','hero-equipment','hero-location','map-structure','map-tiles','flags','combat-overview','enemy-specials','enemy-aura','enemy-query','enemy-final','enemy-calculator','enemy-map-damage','enemy-combat-script','enemy-status-hooks','events','replay-commands','data-plugins'); $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); if($cfg -notmatch "text: '中级教程'"){ Write-Error "NO-NAV-ENTRY" }; if($cfg -notmatch "link: '/intermediate/data-vs-render'"){ Write-Error "NO-NAV-TARGET" }; if($cfg -notmatch "'/intermediate/'"){ Write-Error "NO-INTERMEDIATE-KEY" }; $targets=[regex]::Matches($cfg,"link:\s*'/intermediate/([a-z-]+)'") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique; $want=$names | Sort-Object -Unique; if(($targets -join ',') -ne ($want -join ',')){ Write-Error ("SIDEBAR-TARGETS " + ($targets -join ',')) }; foreach($g in @('概念与导航','基础接口','战斗','其他')){ if($cfg -notmatch [regex]::Escape("text: '$g'")){ Write-Error "NO-GROUP $g" } }; if(-not (Test-Path 'docs/intermediate')){ Write-Error "NO-INTERMEDIATE-DIR" }; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; if($LASTEXITCODE -ne 0){ $out | Write-Output; exit 1 }; $txt = $out -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'W0-REGISTER-OK'</automated>
<automated>$ErrorActionPreference='Stop'; $names=@('data-vs-render','src-tour','code-basics','hooks','data-state-overview','hero-attribute','hero-items','hero-equipment','hero-location','map-structure','map-tiles','flags','combat-overview','enemy-specials','enemy-aura','enemy-query','enemy-final','enemy-calculator','enemy-map-damage','enemy-combat-script','enemy-status-hooks','events','replay-commands','data-plugins'); $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); if($cfg -notmatch "text: '中级教程'"){ Write-Error "NO-NAV-ENTRY" }; if($cfg -notmatch "link: '/intermediate/data-vs-render'"){ Write-Error "NO-NAV-TARGET" }; if($cfg -notmatch "'/intermediate/'"){ Write-Error "NO-INTERMEDIATE-KEY" }; $targets=[regex]::Matches($cfg,"link:\s*'/intermediate/([a-z-]+)'") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique; $want=$names | Sort-Object -Unique; if(($targets -join ',') -ne ($want -join ',')){ Write-Error ("SIDEBAR-TARGETS " + ($targets -join ',')) }; foreach($g in @('概念与导航','基础接口','战斗','其他')){ if($cfg -notmatch [regex]::Escape("text: '$g'")){ Write-Error "NO-GROUP $g" } }; if(-not (Test-Path 'docs/intermediate')){ Write-Error "NO-INTERMEDIATE-DIR" }; $ErrorActionPreference='Continue'; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; $ec=$LASTEXITCODE; $ErrorActionPreference='Stop'; if($ec -ne 0){ $out | Write-Output; exit 1 }; $txt = ($out | ForEach-Object { $_.ToString() }) -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'W0-REGISTER-OK'</automated>
<fails_when>non-zero exit, or output contains "NO-NAV-ENTRY", "NO-NAV-TARGET", "NO-INTERMEDIATE-KEY", "SIDEBAR-TARGETS", "NO-GROUP", "NO-INTERMEDIATE-DIR", "NO-BUILD-COMPLETE", "DEAD-LINK", or "IMPORT-FAIL", or output lacks "W0-REGISTER-OK"</fails_when>
</verify>
<done>`config.ts` 新增 nav 一条与 `'/intermediate/'` sidebar 键(去重后恰 24 条 link、4 个分组,顺序 4/8/9/3);`docs/intermediate/` 目录存在;`docs:build` 通过并打印 build complete;输出 W0-REGISTER-OK。</done>

View File

@ -51,10 +51,9 @@ Output: 4 个新 markdown 文件(中文正文,英文标识符)。
@.planning/phases/10-docs-restructure/10-RESEARCH.md
@.planning/phases/10-docs-restructure/10-PATTERNS.md
@dev.md
@docs/guide/quick-start.md
@docs/guide/system.md
**写作约定:**
- 旧文档 `docs/guide/quick-start.md` 与 `docs/guide/system.md` 已被 `10-01` 在本批删除(D-04),不再作为 `@` 引用加载(旧路径在 10-01 后不存在,加载会失败)。其页面骨架 / mermaid / 提示容器 / 外链等 analog 片段已内联于上方的 `10-PATTERNS.md`(「通用页面骨架」Analog A/B 及各页 Analog),写作时以 `10-PATTERNS.md` 所载片段为准。
- 文件命名 kebab-case(dev.md:68)、内容 CRLF(dev.md:116);页面骨架为单 `#` 主标题 + `##` 小节(PATTERNS「单 H1」)。
- 每篇 2–5 处跳转链接即可(D-17);**禁止**任何「后续再讲/留到高级教程/届时」式元叙述(D-16);指向渲染端内容时当作指向一个**已存在的平行部分**。
- 本组为阅读顺序最前,**不得**在正文中以 markdown 链接指向尚未写就的 `docs/intermediate/` 其它页面(否则构建报 dead link);确需提及时以代码span形式书写页面名,不加链接。
@ -81,7 +80,7 @@ Output: 4 个新 markdown 文件(中文正文,英文标识符)。
- `data-vs-render.md`:一级标题「数据端与渲染端」;正文讲「数据端负责算、渲染端负责画」,比喻「棋局/账本」对「棋盘/摄像机」;指明两者分别在 `src/data`、`src/client`;重点讲**录像验证**(只跑数据端);含一张 mermaid 图;**不**出现「渲染端不能反向推送」的表述(留高级)。
- `src-tour.md`:一级标题「src 导览」;详讲 `src/data`、`src/client`、`src/content`、`src/constant.ts`;**一句带过** `src/bootstrap.ts`、`src/data.ts`、`src/package.json`、`src/styles.less`;**不讲** `src/main.ts`、`src/App.vue`、`src/types/`;含一张「该不该改」表。
</behavior>
<action>按 D-21 写 `data-vs-render.md`:先给结论(数据端「算」、渲染端「画」),用「棋局/账本」对「棋盘/摄像机」的比喻点明职责边界,说明二者分别在 `src/data` 与 `src/client`,然后重点展开**录像验证只跑数据端**这一好处并给出 mermaid 图(图用 ` ```mermaid ` 代码块,语法照 `docs/guide/quick-start.md:27-31`)。素材:`dev.md:125-158`(双端分离)、`src/data.ts:1-5`、`dev.md:132`(`Mota.r()` 门控)。不要写渲染端反向推送相关结论(D-21)。\n\n按 D-22 写 `src-tour.md`:以目录导览方式详讲 `src/data`(数据端)、`src/client`(渲染端)、`src/content`(内容层)、`src/constant.ts`(常量),素材 `file:line` 见 10-RESEARCH「概念与导航 · src-tour」(如 `src/data/entry.ts:1-32`、`src/data/initialize.ts:1-85`、`src/content/item.ts:1-24`、`src/constant.ts:1-51`);`src/bootstrap.ts`、`src/data.ts`、`src/package.json`、`src/styles.less` 各用一句话带过(一般不需改动);不含 `src/main.ts`、`src/App.vue`、`src/types/`;末尾用一张 markdown 表格回答「该不该改」(列出目录/文件 → 是否通常需要改动)。两篇均遵守写作约定(kebab-case 文件名、CRLF、单 H1、无元叙述、跳转链接适量;本组不链接尚未写就的页面)。接口名/路径须逐字核对源码(RESEARCH Pitfall 5)。</action>
<action>按 D-21 写 `data-vs-render.md`:先给结论(数据端「算」、渲染端「画」),用「棋局/账本」对「棋盘/摄像机」的比喻点明职责边界,说明二者分别在 `src/data` 与 `src/client`,然后重点展开**录像验证只跑数据端**这一好处并给出 mermaid 图(图用 ` ```mermaid ` 代码块,语法照 `docs/guide/quick-start.md:27-31`)。该旧文件已由 `10-01` 删除,片段见 `10-PATTERNS.md`。素材:`dev.md:125-158`(双端分离)、`src/data.ts:1-5`、`dev.md:132`(`Mota.r()` 门控)。不要写渲染端反向推送相关结论(D-21)。\n\n按 D-22 写 `src-tour.md`:以目录导览方式详讲 `src/data`(数据端)、`src/client`(渲染端)、`src/content`(内容层)、`src/constant.ts`(常量),素材 `file:line` 见 10-RESEARCH「概念与导航 · src-tour」(如 `src/data/entry.ts:1-32`、`src/data/initialize.ts:1-85`、`src/content/item.ts:1-24`、`src/constant.ts:1-51`);`src/bootstrap.ts`、`src/data.ts`、`src/package.json`、`src/styles.less` 各用一句话带过(一般不需改动);不含 `src/main.ts`、`src/App.vue`、`src/types/`;末尾用一张 markdown 表格回答「该不该改」(列出目录/文件 → 是否通常需要改动)。两篇均遵守写作约定(kebab-case 文件名、CRLF、单 H1、无元叙述、跳转链接适量;本组不链接尚未写就的页面)。接口名/路径须逐字核对源码(RESEARCH Pitfall 5)。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $files=@('docs/intermediate/data-vs-render.md','docs/intermediate/src-tour.md'); foreach($p in $files){ if(-not(Test-Path $p)){ Write-Error "MISSING $p" }; $raw=[IO.File]::ReadAllText((Resolve-Path $p)); if($raw -match "(?<!`r)`n"){ Write-Error "LF-ONLY $p" }; if(([regex]::Matches($raw,'(?m)^# ')).Count -ne 1){ Write-Error "H1-COUNT $p" } }; if(([IO.File]::ReadAllText((Resolve-Path 'docs/intermediate/data-vs-render.md')) -notmatch 'mermaid')){ Write-Error "NO-MERMAID data-vs-render" }; Write-Output 'STATIC-OK'</automated>
<fails_when>non-zero exit, or output contains "MISSING", "LF-ONLY", "H1-COUNT", or "NO-MERMAID"</fails_when>
@ -96,7 +95,7 @@ Output: 4 个新 markdown 文件(中文正文,英文标识符)。
- `code-basics.md`:一级标题;按 ①简明语法 ②模块与引入 ③类型入门 顺序;类型部分把**泛型比喻成「类型的函数」**;讲 `interface` → `implements` 约定(**建议但非强制**);结尾给出外链 `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`。
- `hooks.md`:一级标题;讲钩子是什么;列常用钩子族一览;含**「需求 → 钩子」对照表**(核心);讲 `addHook` / `removeHook`;`hook/initialize.ts` 一笔带过;标明哪些是 async 钩子;用一句话说明「钩子可能被非本对象触发」。
</behavior>
<action>按 D-23 写 `code-basics.md`:三段式——①简明语法(变量、函数、分支等基础,可参照 `docs/guide/system.md:160-191` 的模块化示例风格);②模块与引入(`import`/`export`,用 ` ```ts ` 代码块);③类型入门(基础类型 → 接口 → 泛型;把**泛型比作「类型的函数」**,讲 `interface` → `implements` 是**建议但非强制**的约定)。结尾以一句自然引导给出**唯一获批外链** `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`(D-23;不得新增其它外链)。本页无源码依赖。\n\n按 D-24 写 `hooks.md`:先从概念讲「钩子是什么」,再列**常用钩子族一览**(族名清单的 `file:line` 见 10-RESEARCH「概念与导航 · hooks」,如 `IHeroAttributeHooks`、`IHeroLocationHooks`、`IObjectMoverHooks`、`ICombatFlowHooks`、`IMapLayerHooks` 等),核心是**「需求 → 钩子」对照表**(markdown 表格,左列需求、右列对应钩子/族);然后讲 `addHook` / `removeHook` 的用法(顶层接口名,D-15;机制接口在 `packages/common/src/hook.ts:23-52`);`hook/initialize.ts` 一句带过(注册点,`src/data/hook/initialize.ts:17-26`);标注哪些钩子族是 async;末尾一句说明「钩子可能被非本对象触发」(多主对象时才需判断)。两篇遵守写作约定;`hooks` 的对照表若需引用后续页面,用代码span写页面名而不加链接。</action>
<action>按 D-23 写 `code-basics.md`:三段式——①简明语法(变量、函数、分支等基础,可参照 `docs/guide/system.md:160-191`(该旧文件已由 `10-01` 删除,片段见 `10-PATTERNS.md`)的模块化示例风格);②模块与引入(`import`/`export`,用 ` ```ts ` 代码块);③类型入门(基础类型 → 接口 → 泛型;把**泛型比作「类型的函数」**,讲 `interface` → `implements` 是**建议但非强制**的约定)。结尾以一句自然引导给出**唯一获批外链** `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`(D-23;不得新增其它外链)。本页无源码依赖。\n\n按 D-24 写 `hooks.md`:先从概念讲「钩子是什么」,再列**常用钩子族一览**(族名清单的 `file:line` 见 10-RESEARCH「概念与导航 · hooks」,如 `IHeroAttributeHooks`、`IHeroLocationHooks`、`IObjectMoverHooks`、`ICombatFlowHooks`、`IMapLayerHooks` 等),核心是**「需求 → 钩子」对照表**(markdown 表格,左列需求、右列对应钩子/族);然后讲 `addHook` / `removeHook` 的用法(顶层接口名,D-15;机制接口在 `packages/common/src/hook.ts:23-52`);`hook/initialize.ts` 一句带过(注册点,`src/data/hook/initialize.ts:17-26`);标注哪些钩子族是 async;末尾一句说明「钩子可能被非本对象触发」(多主对象时才需判断)。两篇遵守写作约定;`hooks` 的对照表若需引用后续页面,用代码span写页面名而不加链接。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $files=@('docs/intermediate/code-basics.md','docs/intermediate/hooks.md'); foreach($p in $files){ if(-not(Test-Path $p)){ Write-Error "MISSING $p" }; $raw=[IO.File]::ReadAllText((Resolve-Path $p)); if($raw -match "(?<!`r)`n"){ Write-Error "LF-ONLY $p" }; if(([regex]::Matches($raw,'(?m)^# ')).Count -ne 1){ Write-Error "H1-COUNT $p" } }; if(([IO.File]::ReadAllText((Resolve-Path 'docs/intermediate/code-basics.md')) -notmatch 'https://h5mota\.com/bbs/thread/\?tid=1018')){ Write-Error "NO-APPROVED-LINK code-basics" }; if(([IO.File]::ReadAllText((Resolve-Path 'docs/intermediate/hooks.md')) -notmatch 'addHook')){ Write-Error "NO-ADDHOOK hooks" }; Write-Output 'STATIC-OK'</automated>
<fails_when>non-zero exit, or output contains "MISSING", "LF-ONLY", "H1-COUNT", "NO-APPROVED-LINK", or "NO-ADDHOOK"</fails_when>

View File

@ -59,9 +59,9 @@ Output: 8 个新 markdown 文件。
@.planning/phases/10-docs-restructure/10-CONTEXT.md
@.planning/phases/10-docs-restructure/10-RESEARCH.md
@.planning/phases/10-docs-restructure/10-PATTERNS.md
@docs/guide/implements/special.md
**写作约定:**
- 旧文档 `docs/guide/implements/special.md` 已被 `10-01` 在本批删除(D-04),不再作为 `@` 引用加载(旧路径在 10-01 后不存在,加载会失败)。其 Analog C 骨架片段(`// [!code ++]` 行高亮、`{起-止}` 区块高亮、how-to 叙述、代码块语言标注)已内联于上方的 `10-PATTERNS.md`,写作时以该文件所载片段为准。
- 骨架统一照 Analog C(`docs/guide/implements/special.md`):`# 标题` → 引言 → `## 小节` → ` ```ts ` 代码块;标新增行用 `// [!code ++]`,标改动区块用 ` ```ts {起-止} `。**只借结构、不抄旧接口名**。
- 代码块内每处接口名/字段名必须按 10-RESEARCH「基础接口」表的 `file:line` 逐字核对(RESEARCH Pitfall 5)。
- kebab-case 文件名、CRLF、单 H1、无元叙述(D-16);跳转链接 2–5 处/篇(D-17)。

View File

@ -61,10 +61,9 @@ Output: 9 个新 markdown 文件。
@.planning/phases/10-docs-restructure/10-CONTEXT.md
@.planning/phases/10-docs-restructure/10-RESEARCH.md
@.planning/phases/10-docs-restructure/10-PATTERNS.md
@docs/guide/implements/special.md
@docs/dev/enemy/enemy-combat-flow.md
**写作约定与前置:**
- 旧文档 `docs/guide/implements/special.md` 与 `docs/dev/enemy/enemy-combat-flow.md` 已被 `10-01` 在本批删除(D-04),不再作为 `@` 引用加载(旧路径在 10-01 后不存在,加载会失败)。其骨架 / 战斗流程 analog 片段已内联于上方的 `10-PATTERNS.md`,写作时以该文件所载片段为准。
- 骨架照 Analog C(`docs/guide/implements/special.md`):`# 标题` → 引言 → `## 小节` → 代码块;`// [!code ++]` 标新增行,` ```ts {起-止} ` 标改动区块。**只借结构、不抄旧接口名**(旧文档对应旧接口)。
- 代码块内每处接口名/字段名按 10-RESEARCH「战斗」表 `file:line` 逐字核对(RESEARCH Pitfall 5)。
- kebab-case、CRLF、单 H1、无元叙述(D-16);跳转链接 2–5 处/篇。
@ -94,7 +93,7 @@ Output: 9 个新 markdown 文件。
- `enemy-specials`:特殊属性是什么、在数据里的样子、两类定义(无参 `defineNonePropertySpecial` / 有参 `defineCommonSerializableSpecial`);注册在 `src/data/enemy/special.ts`;如何自定义新属性(步骤);「属性实现位置」对照表;多个示例(有参/无参、`name`/`desc` 用字符串与函数);编辑器识别约定(`// {code} - {name}`、`/** {code} - {name} */`、`ISpecial{code}Value`、`#region` 不能删);怪物比较简单说。
- `enemy-aura`:先讲优先级(高优先级可影响低优先级、反之不行;与查询共用优先级);内置光环(普通 23 / 支援 24 / 特殊光环 26);运作方式(转换器 + 视图并注册);四种内置范围只列名;多个自定义示例(基础光环「减少周围怪物金币与经验」+ 高级特殊光环「给周围怪物上光环、优先级高于特殊光环」);讲清 `apply` 与 `applySpecial` 差别。
</behavior>
<action>按 D-33/D-34/D-35 撰写。`combat-overview` 素材:`packages-user/data-system/src/combat/types.ts:847-901`(`ICombatFlow`)、同文件:747-755(`IEnemyContext.buildup()` 四阶段文档)、`src/data/enemy/initialize.ts:27-70`(注册与装配全流程)、`packages-user/data-state/src/core.ts:79-83,123-127`(L2 装配);mermaid 图照 D-33 流程,写法参照 `docs/guide/quick-start.md:27-31`。`enemy-specials` 素材:`data-base/src/enemy/types.ts:31-75,164-248`(`ISpecial`/`IReadonlyEnemy`/`IEnemyManager`/`SpecialCreation`)、`data-base/src/enemy/special.ts:136-150`(两类定义函数)、`src/data/enemy/special.ts:1-477`(内置注册;注释清单:72-84;编辑器识别注释:8-14,98,100,473)、`src/data/enemy/comparer.ts:1-31`(怪物比较)。`enemy-aura` 素材:`src/data/enemy/aura.ts:1-301`(`CommonAura` code 23:29-119;`GuardAura` code 24:125-184;`SpecialAura` code 26:190-299;`SpecialAuraModifier`:206-242;`priority` 实测值 CommonAura=10、GuardAura=5、SpecialAura=20,见 aura.ts:50,147,249)、`data-system/src/combat/types.ts:99-191`(`IAuraView`/`IEnemyAuraView`/`IAuraConverter`/`IEnemySpecialModifier`)、注册 `registerAuraConverter`:638、全局光环 `addAura`:727、范围类 `FullRange`/`RectRange`/`ManhattanRange`/`RayRange` 来自 `@motajs/common`。遵守写作约定。</action>
<action>按 D-33/D-34/D-35 撰写。`combat-overview` 素材:`packages-user/data-system/src/combat/types.ts:847-901`(`ICombatFlow`)、同文件:747-755(`IEnemyContext.buildup()` 四阶段文档)、`src/data/enemy/initialize.ts:27-70`(注册与装配全流程)、`packages-user/data-state/src/core.ts:79-83,123-127`(L2 装配);mermaid 图照 D-33 流程,写法参照 `docs/guide/quick-start.md:27-31`(该旧文件已由 `10-01` 删除,片段见 `10-PATTERNS.md`)。`enemy-specials` 素材:`data-base/src/enemy/types.ts:31-75,164-248`(`ISpecial`/`IReadonlyEnemy`/`IEnemyManager`/`SpecialCreation`)、`data-base/src/enemy/special.ts:136-150`(两类定义函数)、`src/data/enemy/special.ts:1-477`(内置注册;注释清单:72-84;编辑器识别注释:8-14,98,100,473)、`src/data/enemy/comparer.ts:1-31`(怪物比较)。`enemy-aura` 素材:`src/data/enemy/aura.ts:1-301`(`CommonAura` code 23:29-119;`GuardAura` code 24:125-184;`SpecialAura` code 26:190-299;`SpecialAuraModifier`:206-242;`priority` 实测值 CommonAura=10、GuardAura=5、SpecialAura=20,见 aura.ts:50,147,249)、`data-system/src/combat/types.ts:99-191`(`IAuraView`/`IEnemyAuraView`/`IAuraConverter`/`IEnemySpecialModifier`)、注册 `registerAuraConverter`:638、全局光环 `addAura`:727、范围类 `FullRange`/`RectRange`/`ManhattanRange`/`RayRange` 来自 `@motajs/common`。遵守写作约定。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $files=@('docs/intermediate/combat-overview.md','docs/intermediate/enemy-specials.md','docs/intermediate/enemy-aura.md'); foreach($p in $files){ if(-not(Test-Path $p)){ Write-Error "MISSING $p" }; $raw=[IO.File]::ReadAllText((Resolve-Path $p)); if($raw -match "(?<!`r)`n"){ Write-Error "LF-ONLY $p" }; if(([regex]::Matches($raw,'(?m)^# ')).Count -ne 1){ Write-Error "H1-COUNT $p" } }; if(([IO.File]::ReadAllText((Resolve-Path 'docs/intermediate/combat-overview.md')) -notmatch 'mermaid')){ Write-Error "NO-MERMAID combat-overview" }; if(([IO.File]::ReadAllText((Resolve-Path 'docs/intermediate/enemy-aura.md')) -notmatch 'applySpecial')){ Write-Error "NO-APPLYSPECIAL enemy-aura" }; Write-Output 'STATIC-OK'</automated>
<fails_when>non-zero exit, or output contains "MISSING", "LF-ONLY", "H1-COUNT", "NO-MERMAID", or "NO-APPLYSPECIAL"</fails_when>

View File

@ -48,10 +48,9 @@ Output: 3 个新 markdown 文件。
@.planning/phases/10-docs-restructure/10-CONTEXT.md
@.planning/phases/10-docs-restructure/10-RESEARCH.md
@.planning/phases/10-docs-restructure/10-PATTERNS.md
@docs/guide/implements/special.md
@docs/dev/replay/replay-system.md
**写作约定与前置:**
- 旧文档 `docs/guide/implements/special.md` 与 `docs/dev/replay/replay-system.md` 已被 `10-01` 在本批删除(D-04),不再作为 `@` 引用加载(旧路径在 10-01 后不存在,加载会失败)。其骨架 / 「核心概念定义」组织方式的 analog 片段已内联于上方的 `10-PATTERNS.md`,写作时以该文件所载片段为准。
- 骨架照 Analog C(`special.md`);`replay-commands` 的组织方式可借 `docs/dev/replay/replay-system.md:29-40` 的「核心概念定义」分节写法,但**不沿用**其「每节都是 `#`」风格(本批用单 H1 + `##`)。
- 代码块接口名/字段按 10-RESEARCH「其他」表 `file:line` 逐字核对(RESEARCH Pitfall 5)。
- kebab-case、CRLF、单 H1、无元叙述(D-16);跳转链接 2–5 处/篇。

View File

@ -68,10 +68,10 @@ Output: 验收摘要 `10-06-SUMMARY.md`;站点构建产物 `public/_docs`(gi
<task type="auto">
<name>Task 1: 全量构建 + 静态验收核对</name>
<files>docs/.vitepress/config.ts</files>
<files>docs/.vitepress/config.ts(只读核对;本计划不改动仓库文件,frontmatter files_modified 为空,产物仅 SUMMARY)</files>
<action>先跑全量构建,再做静态核对。(1) 构建:`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"`,确认打印 build complete、无 dead link、无 `Failed to resolve import`,且产物目录 `public/_docs` 已生成;旧内容已于 10-01 随本批删除,本批 dead-link 数应为 0。(2) 旧内容删除基线:确认 `docs/guide`、`docs/dev`、`docs/logger`、`docs/api`、`docs/index.md` 均不存在(首页 `docs/index.md` 本批**有意缺省**,不作为缺页问题)。(3) 静态核对 24 个文件:`docs/intermediate/` 下恰存在 D-20 的 24 个文件名(`data-vs-render`/`src-tour`/`code-basics`/`hooks`/`data-state-overview`/`hero-attribute`/`hero-items`/`hero-equipment`/`hero-location`/`map-structure`/`map-tiles`/`flags`/`combat-overview`/`enemy-specials`/`enemy-aura`/`enemy-query`/`enemy-final`/`enemy-calculator`/`enemy-map-damage`/`enemy-combat-script`/`enemy-status-hooks`/`events`/`replay-commands`/`data-plugins`,各加 `.md`);每个文件 CRLF、单 H1。(4) 核对 `config.ts` 的 `'/intermediate/'` 键:去重后恰含 24 个 `/intermediate/<名>` 目标,且与 24 个文件名一一对应(导航入口 `中级教程` 复用其中一个目标,去重后仍为 24)。(5) 外链白名单:全 24 篇内出现的外链仅为 `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`(若文本中还出现其它 `http`,逐一报告供用户判断,不擅自删改)。发现任何缺页/错链只报告,不擅自补写。</action>
<verify>
<automated>$ErrorActionPreference='Stop'; $names=@('data-vs-render','src-tour','code-basics','hooks','data-state-overview','hero-attribute','hero-items','hero-equipment','hero-location','map-structure','map-tiles','flags','combat-overview','enemy-specials','enemy-aura','enemy-query','enemy-final','enemy-calculator','enemy-map-damage','enemy-combat-script','enemy-status-hooks','events','replay-commands','data-plugins'); $missing=@(); foreach($n in $names){ $p="docs/intermediate/$n.md"; if(-not(Test-Path $p)){ $missing+=$p; continue }; $raw=[IO.File]::ReadAllText((Resolve-Path $p)); if($raw -match "(?<!`r)`n"){ Write-Error "LF-ONLY $p" }; if(([regex]::Matches($raw,'(?m)^# ')).Count -ne 1){ Write-Error "H1-COUNT $p" } }; if($missing.Count -gt 0){ Write-Error ("MISSING: " + ($missing -join ',')) }; foreach($o in @('docs/guide','docs/dev','docs/logger','docs/api','docs/index.md')){ if(Test-Path $o){ Write-Error "OLD-CONTENT-PRESENT $o" } }; $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); $targets=[regex]::Matches($cfg,"link:\s*'/intermediate/([a-z-]+)'") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique; $want=$names | Sort-Object -Unique; if(($targets -join ',') -ne ($want -join ',')){ Write-Error ("SIDEBAR-TARGETS " + ($targets -join ',')) }; $ext=@(); foreach($n in $names){ $p="docs/intermediate/$n.md"; if(-not(Test-Path $p)){continue}; $m=[regex]::Matches([IO.File]::ReadAllText((Resolve-Path $p)),'https?://[^\s\)]+'); foreach($x in $m){ if($x.Value -notmatch '^https://h5mota\.com/bbs/thread/\?tid=1018'){ $ext+=$x.Value } } }; if($ext.Count -gt 0){ Write-Error ("EXT-LINK-NOT-APPROVED: " + (($ext | Select-Object -Unique) -join ',')) }; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; if($LASTEXITCODE -ne 0){ $out | Write-Output; exit 1 }; $txt = $out -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'ACCEPTANCE-OK'</automated>
<automated>$ErrorActionPreference='Stop'; $names=@('data-vs-render','src-tour','code-basics','hooks','data-state-overview','hero-attribute','hero-items','hero-equipment','hero-location','map-structure','map-tiles','flags','combat-overview','enemy-specials','enemy-aura','enemy-query','enemy-final','enemy-calculator','enemy-map-damage','enemy-combat-script','enemy-status-hooks','events','replay-commands','data-plugins'); $missing=@(); foreach($n in $names){ $p="docs/intermediate/$n.md"; if(-not(Test-Path $p)){ $missing+=$p; continue }; $raw=[IO.File]::ReadAllText((Resolve-Path $p)); if($raw -match "(?<!`r)`n"){ Write-Error "LF-ONLY $p" }; if(([regex]::Matches($raw,'(?m)^# ')).Count -ne 1){ Write-Error "H1-COUNT $p" } }; if($missing.Count -gt 0){ Write-Error ("MISSING: " + ($missing -join ',')) }; foreach($o in @('docs/guide','docs/dev','docs/logger','docs/api','docs/index.md')){ if(Test-Path $o){ Write-Error "OLD-CONTENT-PRESENT $o" } }; $cfg=[IO.File]::ReadAllText((Resolve-Path 'docs/.vitepress/config.ts')); $targets=[regex]::Matches($cfg,"link:\s*'/intermediate/([a-z-]+)'") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique; $want=$names | Sort-Object -Unique; if(($targets -join ',') -ne ($want -join ',')){ Write-Error ("SIDEBAR-TARGETS " + ($targets -join ',')) }; $ext=@(); foreach($n in $names){ $p="docs/intermediate/$n.md"; if(-not(Test-Path $p)){continue}; $m=[regex]::Matches([IO.File]::ReadAllText((Resolve-Path $p)),'https?://[^\s\)]+'); foreach($x in $m){ if($x.Value -notmatch '^https://h5mota\.com/bbs/thread/\?tid=1018'){ $ext+=$x.Value } } }; if($ext.Count -gt 0){ Write-Error ("EXT-LINK-NOT-APPROVED: " + (($ext | Select-Object -Unique) -join ',')) }; $ErrorActionPreference='Continue'; $out = cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build" 2>&1; $ec=$LASTEXITCODE; $ErrorActionPreference='Stop'; if($ec -ne 0){ $out | Write-Output; exit 1 }; $txt = ($out | ForEach-Object { $_.ToString() }) -join "`n"; if($txt -notmatch 'build complete'){ Write-Error "NO-BUILD-COMPLETE" }; if($txt -match 'dead link'){ Write-Error "DEAD-LINK" }; if($txt -match 'Failed to resolve import'){ Write-Error "IMPORT-FAIL" }; Write-Output 'ACCEPTANCE-OK'</automated>
<fails_when>non-zero exit, or output contains "LF-ONLY", "H1-COUNT", "MISSING:", "OLD-CONTENT-PRESENT", "SIDEBAR-TARGETS", "EXT-LINK-NOT-APPROVED", "NO-BUILD-COMPLETE", "DEAD-LINK", or "IMPORT-FAIL"; or output lacks "ACCEPTANCE-OK"</fails_when>
</verify>
<done>旧内容删除基线成立(guide/dev/logger/api 与首页均不存在);构建通过且产物生成、dead-link 为 0;24 个文件齐全、CRLF、单 H1;sidebar 去重后恰 24 个目标并与文件一一对应;无非白名单外链;输出 ACCEPTANCE-OK。</done>

View File

@ -85,30 +85,81 @@
**逐篇内容**
- **D-21(概念·`data-vs-render`)** 数据端负责「算」、渲染端负责「画」;比喻「棋局 / 账本」对「棋盘 / 摄像机」;两者分别在 `src/data`、`src/client`;只讲好处,重点**录像验证**(只跑数据端);附 mermaid 图;**不提**「渲染端不能反向推送」(留高级);不提「后续再讲」。
- **D-22(概念·`src-tour`)** 详讲 `src/data`、`src/client`、`src/content`、`src/constant.ts`;**一句带过** `src/bootstrap.ts`、`src/data.ts`(数据端入口、需外部 bootstrap)、`src/package.json`、`src/styles.less`(一般不需要动);**不讲** `src/main.ts`、`src/App.vue`、`src/types/`(均已弃用);含一张「该不该改」表。
- **D-23(概念·`code-basics`)** 顺序 ①简明语法 ②模块与引入 ③类型入门;类型把**泛型比喻成「类型的函数」**;讲 `interface` → `implements` 约定(**建议但非强制**,不遵循也不会出问题);三者都大概讲;结尾给 JS/TS 教程链接 `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`(该教程针对本引擎设计、更适合本引擎作者)。
- **D-24(概念·`hooks`)** 钩子是什么;常用钩子族一览;**需求 → 钩子对照表**(核心);`addHook` / `removeHook`;`hook/initialize.ts` 一笔带过;async 钩子只提哪些是;「钩子可能被非本对象触发」简说一句(多主对象时才需要判断,一般不会串)。
- **D-25(基础·`data-state-overview`)** 数据端主对象 `state` 是访问一切入口;一张表列常用对象与获取方式:`hero` / `maps` / `flags` / `enemyManager` / `enemyContext` / `combat` / `eventSystem` / `pathfinding` / `tileStore` / `itemStore` / `mapStore` / `eventStore` / `faceManager` / `roleFace` / `replaySystem` / `loader` / `loadManager` / `getDataPlugin(id)` / `emitTip(...)`;按 L0–L3 **轻量分组标注**(分层不影响接口使用,细讲留高级)。放在基础接口组最前。
- **D-26(基础·`hero-attribute`)** 属性列表;基础 vs 最终属性(`getBaseAttribute` / `getFinalAttribute`);改基础属性 `set` / `add` / `mul` / `div`(经 `getModifiableAttribute()`);**修饰器**:内置 `ValueModifier`(数值加减,`@system/value`)与 `PercentageModifier`(**按基础值算百分比**,`@system/percentage`);`addModifier` / `deleteModifier`;提 `save` 参数——**不进存档时,应由外部其它系统负责分配该修饰器,避免读档后不一致**;`getAttribute()` 只读、`getIsolatedAttribute()` 试算;示例:攻击 +N 永久、防御 −X% 临时;指回 hooks 篇。
- **D-27(基础·`hero-items`)** `IHeroItems`(`addItem` 可传负数、`Pick` 立刻生效、`getItemState`、`iterateItems`、`useItem` 仅 `Constant` / `Consumable`、`itemCount`);**四种类别区分**:`Constant`(永久)/ `Consumable`(消耗)/ `Pick`(即捡即用)/ `Equipment`(装备,虽是道具但属单独系统);道具效果提一句属**内容层**,并说明道具效果可调用哪些内容(`state`、道具数据);示例:发黄钥匙 / 减道具 / 使用道具。
- **D-28(基础·`hero-equipment`)** `IHeroEquipment`(`slots` / `canEquipTo` / `equip` 含 `autoUnload` / `unequip` / `equipped` / `getEquipped` / `getEquips`);`IHeroEquipsStore`(`add` / `delete` / `get` / `instancesOf` / `instances` / `count`);`IEquipmentState` 的 `value` / `percentage`(本质是修饰器);`compareEquip` **简单讲**;**排序器不讲**(高级);以「会用常用接口」为界。
- **D-29(基础·`hero-location`)** 只讲**获取位置**(`hero.location` 的 `x` / `y` / `direction`、`hero.getLocation()`)与**获取楼层**(`floorId` / `map`);移动位置、切换楼层留高级。
- **D-30(基础·`map-structure`)** `IMapState` 楼层集合(`maps` / `getMap` / `getActiveMap` / `iterateAllMaps`)与**楼层激活**(active);`IGameMap`(`layerList` / `eventLayer` / `tileStore` / 宽高);`IMapLayer`(`alias` 别名、宽高);**事件层本质就是个图层,归本篇**。
- **D-31(基础·`map-tiles`)** 只讲 `getBlock` / `setBlock` / `removeBlock` / `putMapData` / `getMapData` / `iterateBlocks`;`getLocationData` 属低级需求,**留高级**。
- **D-32(基础·`flags`)** 全局**可存档**变量系统;**所有接口都讲(含 `occupied`)**:`setField` / `getField` / `getOrInsert` / `getOrInsertComputed` / `deleteField` / `setFieldValue` / `addFieldValue` / `getFieldValue` / `getFieldValueDefaults`;会进存档;**建议**不滥用、不用的变量及时 `deleteField`;示例:记录开关 / 计数。
- **D-33(战斗·`combat-overview`)** 伤害大致怎么算;流程(基础数据 → 光环 / 查询 / 最终 → 伤害计算 → 战前脚本 → 战斗 → 战后结算 + 状态钩子);mermaid 图;末尾「流程步骤 → 对应文档」对照表;临界值不在本篇提。
- **D-34(战斗·`enemy-specials`)** 特殊属性是什么、在数据里的样子、两类定义(无参 `defineNonePropertySpecial` / 有参 `defineCommonSerializableSpecial`);`src/data/enemy/special.ts` 注册;**如何自定义新特殊属性**(步骤);**「属性实现位置」对照表**(内置属性各自实现在哪);**多个示例**(有参 / 无参、`name` / `desc` 用字符串与函数);编辑器识别约定(`// {code} - {name}`、`/** {code} - {name} */`、`ISpecial{code}Value`、`#region` 不能删);**怪物比较**简单说、一般不改、真要改参考高级教程。
- **D-35(战斗·`enemy-aura`)** **先讲优先级**(高优先级可影响低优先级、反之不行;与查询效果共用优先级);内置光环(普通 23 / 支援 24 / 特殊光环 26);运作方式(写「转换器 + 视图」并注册,**不深讲为何**);四种内置范围只列名(`FullRange` / `RectRange` / `ManhattanRange` / `RayRange`);**多个自定义示例**:基础光环(减少周围怪物金币与经验)+ 高级特殊光环(给周围怪物上光环、优先级高于特殊光环);借示例讲清基础光环 vs 特殊光环差距;`apply` 与 `applySpecial` 差别讲清。
- **D-36(战斗·`enemy-query`)** **新增内容(引擎未内置)**;解决「特殊属性查询外部状态」;常规查询 `CommonQuery`(`registerCommonQueryEffect(code, effect)`,`apply(handler, special, query)`)与特殊查询 `SpecialQuery`(`registerSpecialQueryEffect(effect)`,`for(ctx)` → 修饰器 `shouldQuery` / `add` / `delete` / `modify`);与光环共用优先级;**三个示例**:抱团(范围内每有一个带该属性的怪物就给自身加成)、同化(把范围内怪物特殊属性同化到自身,数值型加算叠加、否则取位置索引最小者)、号角(场上每有一只怪物就给自身加成);说明三者能干什么即可;用**抱团**对比「用光环实现复杂度更高」;设计原因留高级。
- **D-37(战斗·`enemy-final`)** 最终属性效果:**只影响自身、不依赖也不影响外部环境**的一段收尾调整;内置坚固(3)、模仿(8);接口 `IEnemyFinalEffect`(`priority` + `apply`)、`registerFinalEffect`;**三方对比**(光环:不依赖外部但影响外部;查询:依赖外部仅影响自身;最终:两者皆无);示例:**仿攻**(怪物攻击 = 玩家攻击);`priority` 一句带过;回指 `combat-overview` 流程。
- **D-38(战斗·`enemy-calculator`)** `IDamageCalculator`(`calculate` → `{ damage, turn }`、`getCriticalLimit`);注册 `useCalculator`;**逐段导读**内置伤害公式并指明「加自己的属性加在哪一步」;**支援怪**较难 → 写「实在理解不了可暂时跳过」;示例:**等差数列属性**(怪物攻击每回合按固定值递增,等差公式)等;核心讲明调整的是 `f(enemy, hero)` 里的 **`f`**;**临界**:定义 + 现有约定(伤害值单调递减;不单调也可、但临界不准)+ 临界值唯一依赖「最后的伤害值」,**不讲二分过程**。
- **D-39(战斗·`enemy-map-damage`)** 概念(领域 / 夹击 / 阻击 / 激光 / 伏击);三部分(伤害视图 / 转换器 / 合并器,浅讲);注册 `useConverter` / `useReducer`;内置五类表(`code` / 实现位置**写文档时核对源码**);自定义示例:**受地形遮挡的激光**;合并器简单讲(决定一个点受多个地图伤害时取什么伤害类型,深入留高级);**实际结算用户届时补**。
- **D-40(战斗·`enemy-combat-script`)** 战斗脚本(战前 `before` 返回 `bool` 拦截、战后 `after` 结算,按 `priority`);内置 `MainCombatScript`;注册 `addCombatScript`;**「脚本 vs 钩子」选型表**;战前示例「怪物拥有某特殊属性时无法与其战斗」、战后示例「战后玩家回复固定量生命值」;`priority` 一句。
- **D-41(战斗·`enemy-status-hooks`)** 状态类属性(中毒 / 衰弱 / 重生)必须挂钩子;三类内置(中毒:`onStepEnd` + `onAfterTeleportTo` **两个钩子**;衰弱:`onAfterCombat` + 修饰器;重生:`onAfterCombat` + `onSetFloor` **跨钩子协同**);注册在 `initialize.ts`;**强调一个状态可能要用多个钩子**;自定义示例「下次战斗伤害乘二」(并说明还要改伤害计算器,串起 `enemy-calculator`);修饰器概念见 `hero-attribute`。
- **D-42(其他·`events`)** 只讲**如何自定义新事件**(内建函数),不讲原理、不列内建全量;`BuiltInFunction`(`name` + `func(param, env)`);`IBlockEventEnv` 常用字段(其余指向 API);参数接口在 `types.ts`;注册在 `createEventRegistrations()`;内置示例导读;**自定义示例:设置 flag、勇士回血**;编辑器识别方式未设计完 → 文档留余地。
- **D-43(其他·`replay-commands`)** 录像系统用途 + **核心原则**「玩家直接产生、会影响最终成绩的行为必须计入录像」并解释其含义;**如何新增自定义录像需改 `packages-user` 下的引擎内容**;主动录像 vs 被动录像(`ReplayCommandType.Active` / `Passive`);`@shouldReplay` / `@ignoreReplay` 的功能与检测(`begin` / `end` 区间);**不给示例**,后续其他教程引入自定义录像时直接链接过去。
- **D-44(其他·`data-plugins`)** 插件是强拓展系统,中级只讲基础、**高级教程详解其工作原理**;`IDataPluginDefine`(`id` / `dependencies` / `awake` / `initialize` / `afterLoad`);时机次序 `awake`(装配)→ `initialize`(**加载前**)→ `afterLoad`(加载后);注册在 `src/data/plugin/list.ts` 的 `dataPlugins`;`state.addSaveableContent`;示例**占位**(待用户提供**较简单**插件,**不用商店插件**);写明「是否纳入本中级篇取决于示例难度」。
- **D-21:** (概念·`data-vs-render`)数据端负责「算」、渲染端负责「画」;比喻「棋局 / 账本」对「棋盘 / 摄像机」;两者分别在 `src/data`、`src/client`;只讲好处,重点**录像验证**(只跑数据端);附 mermaid 图;**不提**「渲染端不能反向推送」(留高级);不提「后续再讲」。
- **D-22:** (概念·`src-tour`)详讲 `src/data`、`src/client`、`src/content`、`src/constant.ts`;**一句带过** `src/bootstrap.ts`、`src/data.ts`(数据端入口、需外部 bootstrap)、`src/package.json`、`src/styles.less`(一般不需要动);**不讲** `src/main.ts`、`src/App.vue`、`src/types/`(均已弃用);含一张「该不该改」表。
- **D-23:** (概念·`code-basics`)顺序 ①简明语法 ②模块与引入 ③类型入门;类型把**泛型比喻成「类型的函数」**;讲 `interface` → `implements` 约定(**建议但非强制**,不遵循也不会出问题);三者都大概讲;结尾给 JS/TS 教程链接 `https://h5mota.com/bbs/thread/?tid=1018&p=3#p41`(该教程针对本引擎设计、更适合本引擎作者)。
- **D-24:** (概念·`hooks`)钩子是什么;常用钩子族一览;**需求 → 钩子对照表**(核心);`addHook` / `removeHook`;`hook/initialize.ts` 一笔带过;async 钩子只提哪些是;「钩子可能被非本对象触发」简说一句(多主对象时才需要判断,一般不会串)。
- **D-25:** (基础·`data-state-overview`)数据端主对象 `state` 是访问一切入口;一张表列常用对象与获取方式:`hero` / `maps` / `flags` / `enemyManager` / `enemyContext` / `combat` / `eventSystem` / `pathfinding` / `tileStore` / `itemStore` / `mapStore` / `eventStore` / `faceManager` / `roleFace` / `replaySystem` / `loader` / `loadManager` / `getDataPlugin(id)` / `emitTip(...)`;按 L0–L3 **轻量分组标注**(分层不影响接口使用,细讲留高级)。放在基础接口组最前。
- **D-26:** (基础·`hero-attribute`)属性列表;基础 vs 最终属性(`getBaseAttribute` / `getFinalAttribute`);改基础属性 `set` / `add` / `mul` / `div`(经 `getModifiableAttribute()`);**修饰器**:内置 `ValueModifier`(数值加减,`@system/value`)与 `PercentageModifier`(**按基础值算百分比**,`@system/percentage`);`addModifier` / `deleteModifier`;提 `save` 参数——**不进存档时,应由外部其它系统负责分配该修饰器,避免读档后不一致**;`getAttribute()` 只读、`getIsolatedAttribute()` 试算;示例:攻击 +N 永久、防御 −X% 临时;指回 hooks 篇。
- **D-27:** (基础·`hero-items`)`IHeroItems`(`addItem` 可传负数、`Pick` 立刻生效、`getItemState`、`iterateItems`、`useItem` 仅 `Constant` / `Consumable`、`itemCount`);**四种类别区分**:`Constant`(永久)/ `Consumable`(消耗)/ `Pick`(即捡即用)/ `Equipment`(装备,虽是道具但属单独系统);道具效果提一句属**内容层**,并说明道具效果可调用哪些内容(`state`、道具数据);示例:发黄钥匙 / 减道具 / 使用道具。
- **D-28:** (基础·`hero-equipment`)`IHeroEquipment`(`slots` / `canEquipTo` / `equip` 含 `autoUnload` / `unequip` / `equipped` / `getEquipped` / `getEquips`);`IHeroEquipsStore`(`add` / `delete` / `get` / `instancesOf` / `instances` / `count`);`IEquipmentState` 的 `value` / `percentage`(本质是修饰器);`compareEquip` **简单讲**;**排序器不讲**(高级);以「会用常用接口」为界。
- **D-29:** (基础·`hero-location`)只讲**获取位置**(`hero.location` 的 `x` / `y` / `direction`、`hero.getLocation()`)与**获取楼层**(`floorId` / `map`);移动位置、切换楼层留高级。
- **D-30:** (基础·`map-structure`)`IMapState` 楼层集合(`maps` / `getMap` / `getActiveMap` / `iterateAllMaps`)与**楼层激活**(active);`IGameMap`(`layerList` / `eventLayer` / `tileStore` / 宽高);`IMapLayer`(`alias` 别名、宽高);**事件层本质就是个图层,归本篇**。
- **D-31:** (基础·`map-tiles`)只讲 `getBlock` / `setBlock` / `removeBlock` / `putMapData` / `getMapData` / `iterateBlocks`;`getLocationData` 属低级需求,**留高级**。
- **D-32:** (基础·`flags`)全局**可存档**变量系统;**所有接口都讲(含 `occupied`)**:`setField` / `getField` / `getOrInsert` / `getOrInsertComputed` / `deleteField` / `setFieldValue` / `addFieldValue` / `getFieldValue` / `getFieldValueDefaults`;会进存档;**建议**不滥用、不用的变量及时 `deleteField`;示例:记录开关 / 计数。
- **D-33:** (战斗·`combat-overview`)伤害大致怎么算;流程(基础数据 → 光环 / 查询 / 最终 → 伤害计算 → 战前脚本 → 战斗 → 战后结算 + 状态钩子);mermaid 图;末尾「流程步骤 → 对应文档」对照表;临界值不在本篇提。
- **D-34:** (战斗·`enemy-specials`)特殊属性是什么、在数据里的样子、两类定义(无参 `defineNonePropertySpecial` / 有参 `defineCommonSerializableSpecial`);`src/data/enemy/special.ts` 注册;**如何自定义新特殊属性**(步骤);**「属性实现位置」对照表**(内置属性各自实现在哪);**多个示例**(有参 / 无参、`name` / `desc` 用字符串与函数);编辑器识别约定(`// {code} - {name}`、`/** {code} - {name} */`、`ISpecial{code}Value`、`#region` 不能删);**怪物比较**简单说、一般不改、真要改参考高级教程。
- **D-35:** (战斗·`enemy-aura`)**先讲优先级**(高优先级可影响低优先级、反之不行;与查询效果共用优先级);内置光环(普通 23 / 支援 24 / 特殊光环 26);运作方式(写「转换器 + 视图」并注册,**不深讲为何**);四种内置范围只列名(`FullRange` / `RectRange` / `ManhattanRange` / `RayRange`);**多个自定义示例**:基础光环(减少周围怪物金币与经验)+ 高级特殊光环(给周围怪物上光环、优先级高于特殊光环);借示例讲清基础光环 vs 特殊光环差距;`apply` 与 `applySpecial` 差别讲清。
- **D-36:** (战斗·`enemy-query`)**新增内容(引擎未内置)**;解决「特殊属性查询外部状态」;常规查询 `CommonQuery`(`registerCommonQueryEffect(code, effect)`,`apply(handler, special, query)`)与特殊查询 `SpecialQuery`(`registerSpecialQueryEffect(effect)`,`for(ctx)` → 修饰器 `shouldQuery` / `add` / `delete` / `modify`);与光环共用优先级;**三个示例**:抱团(范围内每有一个带该属性的怪物就给自身加成)、同化(把范围内怪物特殊属性同化到自身,数值型加算叠加、否则取位置索引最小者)、号角(场上每有一只怪物就给自身加成);说明三者能干什么即可;用**抱团**对比「用光环实现复杂度更高」;设计原因留高级。
- **D-37:** (战斗·`enemy-final`)最终属性效果:**只影响自身、不依赖也不影响外部环境**的一段收尾调整;内置坚固(3)、模仿(8);接口 `IEnemyFinalEffect`(`priority` + `apply`)、`registerFinalEffect`;**三方对比**(光环:不依赖外部但影响外部;查询:依赖外部仅影响自身;最终:两者皆无);示例:**仿攻**(怪物攻击 = 玩家攻击);`priority` 一句带过;回指 `combat-overview` 流程。
- **D-38:** (战斗·`enemy-calculator`)`IDamageCalculator`(`calculate` → `{ damage, turn }`、`getCriticalLimit`);注册 `useCalculator`;**逐段导读**内置伤害公式并指明「加自己的属性加在哪一步」;**支援怪**较难 → 写「实在理解不了可暂时跳过」;示例:**等差数列属性**(怪物攻击每回合按固定值递增,等差公式)等;核心讲明调整的是 `f(enemy, hero)` 里的 **`f`**;**临界**:定义 + 现有约定(伤害值单调递减;不单调也可、但临界不准)+ 临界值唯一依赖「最后的伤害值」,**不讲二分过程**。
- **D-39:** (战斗·`enemy-map-damage`)概念(领域 / 夹击 / 阻击 / 激光 / 伏击);三部分(伤害视图 / 转换器 / 合并器,浅讲);注册 `useConverter` / `useReducer`;内置五类表(`code` / 实现位置**写文档时核对源码**);自定义示例:**受地形遮挡的激光**;合并器简单讲(决定一个点受多个地图伤害时取什么伤害类型,深入留高级);**实际结算用户届时补**。
- **D-40:** (战斗·`enemy-combat-script`)战斗脚本(战前 `before` 返回 `bool` 拦截、战后 `after` 结算,按 `priority`);内置 `MainCombatScript`;注册 `addCombatScript`;**「脚本 vs 钩子」选型表**;战前示例「怪物拥有某特殊属性时无法与其战斗」、战后示例「战后玩家回复固定量生命值」;`priority` 一句。
- **D-41:** (战斗·`enemy-status-hooks`)状态类属性(中毒 / 衰弱 / 重生)必须挂钩子;三类内置(中毒:`onStepEnd` + `onAfterTeleportTo` **两个钩子**;衰弱:`onAfterCombat` + 修饰器;重生:`onAfterCombat` + `onSetFloor` **跨钩子协同**);注册在 `initialize.ts`;**强调一个状态可能要用多个钩子**;自定义示例「下次战斗伤害乘二」(并说明还要改伤害计算器,串起 `enemy-calculator`);修饰器概念见 `hero-attribute`。
- **D-42:** (其他·`events`)只讲**如何自定义新事件**(内建函数),不讲原理、不列内建全量;`BuiltInFunction`(`name` + `func(param, env)`);`IBlockEventEnv` 常用字段(其余指向 API);参数接口在 `types.ts`;注册在 `createEventRegistrations()`;内置示例导读;**自定义示例:设置 flag、勇士回血**;编辑器识别方式未设计完 → 文档留余地。
- **D-43:** (其他·`replay-commands`)录像系统用途 + **核心原则**「玩家直接产生、会影响最终成绩的行为必须计入录像」并解释其含义;**如何新增自定义录像需改 `packages-user` 下的引擎内容**;主动录像 vs 被动录像(`ReplayCommandType.Active` / `Passive`);`@shouldReplay` / `@ignoreReplay` 的功能与检测(`begin` / `end` 区间);**不给示例**,后续其他教程引入自定义录像时直接链接过去。
- **D-44:** (其他·`data-plugins`)插件是强拓展系统,中级只讲基础、**高级教程详解其工作原理**;`IDataPluginDefine`(`id` / `dependencies` / `awake` / `initialize` / `afterLoad`);时机次序 `awake`(装配)→ `initialize`(**加载前**)→ `afterLoad`(加载后);注册在 `src/data/plugin/list.ts` 的 `dataPlugins`;`state.addSaveableContent`;示例**占位**(待用户提供**较简单**插件,**不用商店插件**);写明「是否纳入本中级篇取决于示例难度」。
### 文档写作流程(长期规范,适用于后续所有文档写作,不止本批)
- **D-45(prettier 格式化):** 文档写完后**必须**跑 prettier 格式化——prettier 也管 markdown;不格式化虽不报 eslint 错,但用户日后手动修改会产生大量格式变动。推荐命令:`node node_modules/prettier/bin/prettier.cjs --write "docs/intermediate/*.md"`(或等价的 `pnpm exec prettier --write`)。若格式化会阻断某条命令执行,**先跳过并在对话中提出**,由用户自行格式化。
- **D-46(两阶段修订循环):** 修订必须依次经过两阶段,缺一不可:
1. **子代理自审循环**:如 plan-checker,以及**按文档实际情况选择合适视角**的子代理反向审阅(面向新手的文档可用「不懂代码的读者」人格;面向有基础作者或特定领域的文档,应选更贴近该受众/领域的视角)→ 问题清单 → 迭代修正。**审阅视角不固定为「不懂代码的读者」,须因文档而异。**
2. **用户主导的修订循环**:用户以更贴近本领域的视角提出问题 → AI 派子代理逐条修改文档 → 回报 → 用户继续提,直到用户满意并确认。
**必须经过用户循环且获用户确认后才可收尾**,不得由 AI 自行收尾。适用于后续所有文档写作。
- **D-47(高级教程预留,修订 D-16):** 凡「留到高级教程细讲」的内容,必须**显式说明**并**预留链接标记**。由于 `docs/advanced/` 尚未建立,**不得**写指向它的真实 markdown 链接(会造成死链、构建失败);改用非断链占位(如代码 span `高级教程 · <topic>` 或 `<!-- TODO(advanced): <topic> -->`),待高级教程存在后再替换为真链接。此条在「高级教程引用」范围内取代 D-16 的「禁元叙述」。
- **D-48(实操写法,长期规范):** 系统/原理讲解保持现状(可以讲、且现在这样就好);需要重做的是**「实操/示例」部分**。每处实操统一写成可分步照抄的形式:
1. **目标**:先说要做出什么效果;
2. **改哪里**:哪个文件、文件内哪个位置;
3. **复制什么**:只给要新增的那段,**不含 `import`**;写清「顶部通常已有这些 import,不要再粘;若报缺少 X,再在顶部补 `import ...`」;
4. **改什么**:复制后只需改的数值/字段/名称;
5. **怎么验证**:改完看什么现象;
6. **注意**:常见错误、别碰什么。
另外:示例取**最短可行**(如「下次战斗伤害翻倍」直接用 `onAfterCombat`,不额外写战后脚本);**不写 `dev.md` 的引擎开发约定**(如 import 必须置顶、注释位置等);**不写发布前/数量类无关信息**(如「发布前会有约 100 个内置事件」)。适用于后续所有文档写作。
- **D-49(示例与「改内置优先」):**
1. 示例代码必须**贴合引擎现状**:定义 `class` 再 `new`,**不要**写 `const obj = { ... }` 这类 plain object(引擎代码即如此)。例外:引擎本身就以对象字面量声明的内容(如 `src/content/item.ts` 的 `ItemEffect`),可沿用其现有写法。
2. 文档主体应主要讲**如何改内置类**(如 `src/data/enemy/*`、`src/data/hook/*` 中已有的类);在**末尾**再简述「何时需要自己新写类、怎么写」。
3. 必须明确写明:**如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。**
- **D-50(通用模板 vs 示例):** 对每类可扩展机制,文档要提供的是**机制本身的通用模板**,而不是某个具体效果的模板/示例:
1. 单独一节(如「模板:复制什么、改什么」),给出一段可复制的**骨架**(类 / 转换器 / 视图 / 效果等),并用 `[需改]` 标出作者需按自己需求修改的部分;
2. 模板与示例**分开**,不要把某个特定效果(如某个具体光环)当作模板;
3. 示例仅用于辅助说明,尽量短。
示例:光环应给「光环转换器 + 光环视图」的通用骨架 + 需改点,而不是给「减少金币经验的光环」当模板。
- **D-51(特殊属性实现的落点与讲解方式):**
1. **不要建议新建文件**:已有实现文件(如光环的 `src/data/enemy/aura.ts`)就全部写在其中,不要建议「新增一个自己的文件」。样板集中放置是为了方便管理与后续编辑器自动识别;只有**查询属性**需要单独新建 `query.ts`。
2. 对 `aura.ts` / `query.ts` 这类独立的特殊属性实现,建议用 `#region` 包裹,结构更清晰、后续编辑器也可能据此识别。暂定识别约定:`//#region {code}-{name}` … `//#endregion`。
3. 必须用**通俗语言解释「副作用」是什么**(刚接触代码的作者不懂这个概念)。
4. 说明**地图伤害是按「计算后怪物」计算**的:因此若光环给怪物新增了「领域」这类属性,该怪物确实会有领域效果;旧样板无法动态给怪物加特殊属性,这一点值得单独说明。若某种属性确实既要依赖外部环境、又要修改外部环境,应拆成「查询效果 + 光环效果」两个,二者通过新增一个怪物属性桥接(指向高级教程)。
5. `getCriticalLimit` 一般用不到,提一句即可,并说明高级教程会详细讲。
6. **接口讲解方式**:不要上来就搬接口列表;先说「为了实现 X,我们需要修改 Y 接口」,再讲这个接口的参数,这样更自然。
- **D-52(加粗边界,长期规范):** `**加粗**` 的内容**不要以标点开头或结尾**(CommonMark emphasis 边界规则会导致不渲染)。若确需加粗含标点的短语,应调整结构,使 `**` 相邻处为标点/空白,或改用 HTML `<strong>`。提交前用 flanking 规则自检所有 markdown。适用于后续所有文档写作。
- **D-53(高级教程分层):** 高级教程本身按难度/依赖分为 **I / II / III** 三层(避免与顶层「初级/中级/高级教程」撞名):
- **I**:核心为**插件系统**,后续内容都是基于插件系统的拓展;
- **II**:系统级实现(如战斗上下文/光环/查询/优先级、伤害与地图伤害、渲染层与布局、加载与插件生命周期、自定义录像行为等);
- **III**:原理与架构(双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试等)。
由于渲染端尚未完工,**当前只规划/编写数据端内容**。
- **D-54(高级教程定位):** 文档面向**使用者**(不是引擎编写者):在适度解析的同时,必须告诉读者**怎么用、怎么改、怎么自定义**;仍沿用 D-48(实操六步)、D-49(改内置优先/类示例)、D-50(通用模板单独成节)、D-51(落点/副作用/接口讲解方式)、D-52(加粗边界)等规范。
- **D-55(优先插件):** 明确建议:**新增系统优先通过插件系统实现**;只有不得不修改内置系统时才去改内部。理由:版本迁移时 `src` 基本不会改,但内置包(`packages` / `packages-user`)有可能会改,改内置会增加升级时的维护成本。
- **D-56(高级 I 内容,7 篇):**
1. **高级 I 导览**:I/II/III 分层、阅读路径与前置、D-55 原则;
2. **声明式 vs 命令式接口**:旧样板命令式(写一行即生效)vs 新样板声明式(告诉系统要做什么,系统在合适时机执行);必须讲透,作为后续基础;
3. **插件系统**:自定义新插件、插件间调用、`dependencies` 依赖与排序、生命周期进阶、插件 id 稳定性与版本迁移;
4. **可存档内容(`ISaveableContent`)**:存档一致性、三级压缩、注册存档对象、存档顺序 / `afterLoad` / `bindSaveableExecuter`、跨对象引用、结构化约束(structured-cloneable:Object/Array/number/string/boolean/null/Map/Set/TypedArray/ArrayBuffer 等)、必须返回经 `structuredClone` 的对象;
5. **录像一致性**:`@shouldReplay` / `@ignoreReplay` 该写在什么方法/函数上、能保证什么、不能保证什么;自定义录像行为 → II,深层原理 → III;
6. **data-common 的 Store**:讲清各 Store 存了什么,明确是**内容层原始数据**、运行时一般**只读**,修改很可能绕过存档/录像/一致性而引发问题;
7. **钩子深入**:多对象场景(地图与图层)、如何判断「触发者是不是我关心的 map/layer」(身份校验)、同一钩子被多个对象复用、注册/移除与异步钩子。
每篇仍保留「怎么验证」。
- **D-57(高级 I 读者修订循环):** 高级 I 完成后,**必须**再走用户主导的修订循环(D-46 第 2 阶段);且这次的读者人格**不能复用中级教程的「不懂代码的游戏作者」**——应面向**有一定代码基础、已读完中级教程、想做深度定制/理解扩展机制**的作者(具体人格在计划中明确,并经用户确认)。
</decisions>
@ -192,4 +243,4 @@
*Phase: 10-docs-restructure*
*Context gathered: 2026-10-14*
*Context updated: 2026-10-17(旧内容删除并入本批 Wave 0 / 10-01;中级教程第一批 24 篇讨论结果沿用)*
*Context updated: 2026-10-18(旧内容删除并入本批 Wave 0 / 10-01;中级教程第一批 24 篇讨论结果沿用;新增文档写作流程规范 D-45/D-46:prettier 格式化 + 两阶段修订循环)*

View File

@ -145,7 +145,7 @@ export const specials: SpecialDeclaration[] = [
**Analog:** 自身(edit-in-place),形状参照同文件既有 `nav`/`sidebar`。
**现有 `nav`(待新增一条,不改旧条目)**(`docs/.vitepress/config.ts:53-58`):
**现有 `nav`(本批将新增 `中级教程` 一条,并删除 `主页`/`指南`/`API`/`错误代码` 旧条目——D-04 已并入本批 `10-01` Wave 0)**(`docs/.vitepress/config.ts:53-58`):
```ts
nav: [
{ text: '主页', link: '/' },
@ -192,15 +192,13 @@ sidebar: {
]
}
],
'/guide/': [ /* 原样保留,禁止改动 */ ],
'/logger/': [ /* 原样保留,禁止改动 */ ],
'/api/': [{ text: 'API 列表', items: api }]
// '/guide/'、'/logger/'、'/api/' 三个旧键已随 D-04 并入本批(10-01),随旧内容一并移除
}
```
**硬约束:**
- 只**新增** `nav` 一条 + `sidebar['/intermediate/']` 键;**禁止**改动/删除 `'/guide/'`、`'/logger/'`、`'/api/'` 及既有 `nav` 条目(D-14 / RESEARCH Pitfall 3)。
- **不要**触碰 `docs/.vitepress/api.ts`、`init.ts`、`apiSidebar.ts`(后者 gitignored 且会被覆盖,`api.ts:71`)。
- **新增** `nav` 一条 + `sidebar['/intermediate/']` 键;**同时**(D-04 已并入本批 / `10-01`)移除因旧内容被删而失效的旧 `nav` 条目(主页 / 指南 / API / 错误代码)与旧 `sidebar` 键 `'/guide/'`、`'/logger/'`、`'/api/'`。删除 / 改键的路径白名单以 `10-01-PLAN.md` 为准。
- `docs/.vitepress/api.ts` 仅按 `10-01` 追加「`docs/api/` 缺失即跳过」守卫,`generateSidebar()` 的扫描逻辑不改(D-03);`init.ts` 不改。`apiSidebar.ts` 为 gitignored 生成物,`10-01` 会一并删除(已无消费者,`api.ts:71` 会被覆盖)。
- 24 条链接必须全部挂上,否则页面「写了但看不见」(RESEARCH Pitfall 1 / D-09 人工审阅)。
---
@ -427,7 +425,7 @@ graph LR
### 越界红线
**Source:** D-14(CONTEXT)、RESEARCH Pitfall 3
**Apply to:** 本批**全部**改动。
> 本批**只**做 `docs/intermediate/*` + `config.ts` 的 intermediate 注册。**禁止**删除/修改 `docs/guide/*`、`docs/dev/*`、`docs/logger/*`,也**禁止**改动 `nav`/`sidebar` 的既有 guide/logger/api 键与 `api.ts`/`init.ts`。越界项只记录、不实施。
> 本批范围 = `docs/intermediate/*` + `config.ts` 的 intermediate 注册,**并**(D-04 已并入本批 / `10-01`)删除旧内容(`docs/guide/*`、`docs/dev/*`、`docs/logger/*`、`docs/api/*` 与首页 `docs/index.md`)、清理其失效的 `nav`/`sidebar` 键,及给 `docs/.vitepress/api.ts` 追加缺失目录守卫。**不**触碰 `src/`、`packages/`、`packages-user/` 生产代码;`init.ts` 不改。越界项只记录、不实施。
---

View File

@ -244,9 +244,9 @@ nav: [
链接写法:`{ text: '数据端与渲染端', link: '/intermediate/data-vs-render' }`(与既有 `'/guide/'` 写法一致,用绝对路径、不带 `.md`)。
**不需要改的:** `docs/.vitepress/api.ts`、`init.ts`(它们的 `generateSidebar` **只**扫描 `docs/api/`,与本批无关,[VERIFIED: docs/.vitepress/api.ts:5,19])。
**需要小改 / 不需改的:** `docs/.vitepress/init.ts` 不需改(其 `generateSidebar` **只**扫描 `docs/api/`,[VERIFIED: docs/.vitepress/api.ts:5,19]);`docs/.vitepress/api.ts` 需按 `10-01` 追加「`docs/api/` 缺失即跳过」守卫(扫描逻辑不变)。
**注意(D-04 与本批的关系):** 计划**不要**在本批删除 `docs/guide/*`、`docs/dev/*`、`docs/logger/*` 或改动既有 `nav`/`sidebar` 的 guide/logger 键——旧内容删除是 D-04 的独立任务,本批只负责 intermediate(见 CONTEXT「第一批已派发内容」界定与 D-14)。若计划顺带改了旧键,属越界。
**注意(D-04 与本批的关系,已更新):** D-04 的旧内容删除**已并入本批**:`10-01`(Wave 0)会删除 `docs/guide/*`、`docs/dev/*`、`docs/logger/*`、`docs/api/*` 与首页 `docs/index.md`,并清理 `config.ts` 中指向这些内容的旧 `nav` 条目(主页 / 指南 / API / 错误代码)与旧 `sidebar` 键(guide / logger / api 三个路径前缀)。删除后本批 dead-link 数应为 0(原 `docs/dev/store/tile-store.md` 的 3 处死链随删除消失)。本文件下方若仍见「本批不得删除旧文档 / 不得改旧键」之类的旧版表述,均已作废,以本段与 `10-01-PLAN.md` 为准。
### API 参考生成机制(回答 research question 2)
@ -255,7 +255,7 @@ nav: [
- 触发方式:仅通过 `docs/.vitepress/init.ts`(init.ts:3-5)在 `docs:dev` 时执行(package.json:26);并有一个 vite dev 插件 `listenSidebar()` 在文件增删/重命名时重新生成(config.ts:7-36,192)。
- 没有 `typedoc` / `api-extractor` 的仓库内脚本或配置(`package.json` scripts 无相关命令;`.github/`、`script/` 下无相关配置)。`pnpm-lock.yaml` 里存在 `@microsoft/api-extractor@7.57.6`(作为某依赖的传递依赖),但**仓库没有任何脚本调用它**。
**结论(供 D-03 计划):** 「API 参考按包生成」的现有机制 = 已检入的 `docs/api/**/*.md` 文件树 + `generateSidebar()` 扫描。本批(中级教程)**不涉及** `docs/api/` 的改动;且 `docs/api/` 当前**已含 legacy 包条目**(`docs/api/index.md:10-13,25,30-31` 列出 `@motajs/legacy-*`、`@user/legacy-plugin-*`、`@user/data-fallback`),与 D-05「排除 legacy」冲突——但 D-05 属第 5 部分(API 参考)任务,**不在本批范围**,仅记录备用。
**结论(供 D-03 计划):** 「API 参考按包生成」的现有机制 = 已检入的 `docs/api/**/*.md` 文件树 + `generateSidebar()` 扫描。本批**不做** API 参考的**内容重建**(注:`docs/api/` 目录本体会随 D-04 并入本批的 `10-01` 一并删除,此处「不涉及改动」仅指不重建其正文);且 `docs/api/` 当前**已含 legacy 包条目**(`docs/api/index.md:10-13,25,30-31` 列出 `@motajs/legacy-*`、`@user/legacy-plugin-*`、`@user/data-fallback`),与 D-05「排除 legacy」冲突——但 D-05 属第 5 部分(API 参考)任务,**不在本批范围**,仅记录备用。
### 写作 / 验收工作流(回答 research question 3)
@ -341,7 +341,7 @@ graph TD
### Anti-Patterns to Avoid
- **手改 `docs/.vitepress/apiSidebar.ts`:** 它被 `.gitignore` 忽略(.gitignore:54)、被 `.prettierignore` 忽略(.prettierignore:17)、且每次 `generateSidebar()` 运行会被覆盖(api.ts:71)。改它无效。
- **在本批删除旧文档:** D-04 的删除是独立任务;本批只做 intermediate(D-14 不擅自扩大范围)。
- **(旧范围界定,已作废)把旧文档删除当作本批外任务:** D-04 已更新——旧内容删除(`docs/guide/*`、`docs/dev/*`、`docs/logger/*`、`docs/api/*` 与首页 `docs/index.md`)连同旧 `nav`/`sidebar` 键清理**已并入本批 `10-01`(Wave 0)**,与中级教程同期进行。
- **新增文档却忘了注册 sidebar:** 不会导致构建失败,但页面在导航中不可见——D-09 的人工审阅会因此不通过。
- **在文档里写"渲染端教程后续再讲"之类元叙述:** D-16 明令禁止。
@ -449,11 +449,11 @@ graph TD
**How to avoid:** 先 `pnpm docs:dev` 或 `tsx docs/.vitepress/init.ts` 生成后再构建;或把构建命令写成先 init 再 build。
**Warning signs:** 构建报 `Failed to resolve import "./apiSidebar"`。
### Pitfall 3: 越界改旧文档
**What goes wrong:** 顺手删/改了 `docs/guide/*`、`docs/dev/*`、`docs/logger/*` 或改动了 `nav`/`sidebar` 的 guide/logger 键。
**Why it happens:** D-04 确有"旧内容全删重写"的总目标,但那是独立任务。
**How to avoid:** 严守 D-14 与"第一批 = 中级教程"的界定;越界项只记录不实施。
**Warning signs:** 计划 diff 触及 `docs/guide/**`、`docs/dev/**`、`docs/logger/**`。
### Pitfall 3: 越界删除 / 改旧文档(范围已随 D-04 更新)
**What goes wrong:** 越过 `10-01` 的白名单去删除或修改内容——白名单仅为 `docs/guide/*`、`docs/dev/*`、`docs/logger/*`、`docs/api/*` 与首页 `docs/index.md`;此外误改 `src/`、`packages/`、`packages-user/` 或 `docs/intermediate/` 也属越界。
**Why it happens:** D-04 已把「旧内容全删重写」并入本批 `10-01`(Wave 0),删除本身**现在是本批内**的合法动作;越界风险已从「不该删」转为「删多了 / 删错对象 / 误改生产代码」。
**How to avoid:** 严守 `10-01` 的五个路径白名单并保持站点可构建;删除对象受 git 跟踪,误删可 `git restore` 回滚。
**Warning signs:** 计划 diff 触及 `src/**`、`packages*/**` 或 `docs/intermediate/**`。
### Pitfall 4: 写出被禁止的元叙述
**What goes wrong:** 出现"渲染端教程后续再讲""这块留到高级教程再说"之类。
@ -475,7 +475,7 @@ graph TD
## Code Examples
### 在 config.ts 里注册 intermediate 侧边栏(本批唯一必改的配置)
### 在 config.ts 里注册 intermediate 侧边栏(本批配置改动之一;旧键清理见 `10-01`)
```ts
// 依据现有写法:docs/.vitepress/config.ts:59-156 的 sidebar 为按路径前缀分键的手写对象
@ -500,9 +500,7 @@ sidebar: {
]
}
],
'/guide/': [ /* 原样保留 */ ],
'/logger/': [ /* 原样保留 */ ],
'/api/': [{ text: 'API 列表', items: api }]
// '/guide/'、'/logger/'、'/api/' 三个旧键已随 D-04 并入本批(10-01)随旧内容一并移除
}
```
(来源:结构参照仓库现行 `docs/.vitepress/config.ts:59-156`;`link` 写法与 config.ts:64-124 一致)
@ -532,7 +530,7 @@ sidebar: {
| `docs/api/index.md` 含 legacy 包 | 计划排除 legacy(D-05) | 2026-10-14 | 非本批 |
**Deprecated/outdated:**
- 详细介绍引擎接口设计的旧"开发文档"(`docs/dev/*`,含 `template.md` 的「需求综述 → 需求理解 → 接口设计分析 → 实现思路」方法论)——D-04 将删除重写;本批不涉及。
- 详细介绍引擎接口设计的旧"开发文档"(`docs/dev/*`,含 `template.md` 的「需求综述 → 需求理解 → 接口设计分析 → 实现思路」方法论)——D-04 已并入本批 Wave 0(`10-01`)删除重写。
## Assumptions Log
@ -546,7 +544,7 @@ sidebar: {
**若上表非空:** A1、A3 建议由用户在计划前的汇报中被确认(涉及验收命令);A4 可在执行时直接观察。
## Open Questions
## Open Questions (RESOLVED)
1. **验收命令的确切形态**
- What we know: `docs:dev` 会生成 `apiSidebar.ts`;`docs:build` 不会;当前工作区无该文件。
@ -678,7 +676,7 @@ sidebar: {
**Confidence:** HIGH
**要点回顾:**
1. 本批 = 24 篇中文中级教程(`docs/intermediate/`)+ 在 `docs/.vitepress/config.ts` 新增 `nav` 与 `sidebar['/intermediate/']`;API 参考与旧文档删除**不在本批**。
1. 本批 = 24 篇中文中级教程(`docs/intermediate/`)+ 在 `docs/.vitepress/config.ts` 新增 `nav` 与 `sidebar['/intermediate/']`;旧文档删除与旧 `nav`/`sidebar` 键清理**已并入本批 Wave 0(`10-01`)**;API 参考的按包生成机制保留、正文重建另行安排。
2. 站点 `nav`/`sidebar` 为手写(config.ts:53-156);`docs/api/` 侧边栏由 `generateSidebar()` 扫描生成,正文为已检入静态文件,仓库无 typedoc/api-extractor 脚本。
3. 验收命令:先 `tsx docs/.vitepress/init.ts`(生成 gitignored 的 `apiSidebar.ts`,当前工作区缺失),再 `pnpm docs:build`;预览用 `pnpm docs:dev`。
4. 24 篇的源码入口已逐篇核实到 `file:line`(见「24 篇文档的源码入口」表)。

View File

@ -23,7 +23,7 @@ created: "2026-10-16"
| **Full suite command** | `tsx docs/.vitepress/init.ts` then `pnpm docs:build` (build success == D-09 "构建通过") |
| **Estimated runtime** | ~20–60 seconds (VitePress build; `init.ts` a few seconds) |
> NOTE: `docs/.vitepress/config.ts` statically imports `./apiSidebar`; that file is gitignored and currently absent, so `docs:build` fails until `tsx docs/.vitepress/init.ts` regenerates it. Wave 0 must run it first.
> NOTE: `10-01`(Wave 0)已删除旧内容并解除 `docs/.vitepress/config.ts` 对生成物 `./apiSidebar` 的静态依赖,给 `docs/.vitepress/api.ts` 的 `generateSidebar()` 加了缺失目录守卫。旧内容删除后 **`docs:build` 必须转绿、dead link = 0**(此前的死链失败源 `docs/dev/store/tile-store.md` 已被删除)。`tsx docs/.vitepress/init.ts` 仍在构建前运行,但在 `docs/api/` 缺失时会安全跳过、不再抛 ENOENT。
---
@ -31,7 +31,7 @@ created: "2026-10-16"
- **After every task commit:** `pnpm docs:dev` preview (or at minimum one `docs:build`)
- **After every plan wave:** `tsx docs/.vitepress/init.ts && pnpm docs:build`
- **Before `/gsd-verify-work`:** Full build must pass and the user must have reviewed (D-09)
- **Before `/gsd-verify-work`:** Full build must pass with **dead link = 0**(旧内容已删除,构建必须转绿,不再有「既有死链不可避免」的豁免)and the user must have reviewed (D-09)
- **Max feedback latency:** ~60 seconds
---
@ -40,20 +40,34 @@ created: "2026-10-16"
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 10-W0-CFG | W0 | 1 | D-02/D-20 | — | Sidebar/nav edits are same-dir docs config only; no raw HTML/script | smoke | `tsx docs/.vitepress/init.ts && pnpm docs:build` | ❌ W0 | ⬜ pending |
| 10-TBD | TBD | TBD | D-09 (links) | T-10-info | Doc pages contain no secrets/internal paths; only the single approved external link | integration (build link resolution) + manual | `pnpm docs:build` | ❌ | ⬜ pending |
| 10-01-T1 | 10-01 | 1 | D-04 / D-14 | T-10-01 / T-10-02 | config.ts / api.ts 同目录编辑;删除限于白名单;无原始 HTML/script | smoke(静态 + 构建) | `tsx docs/.vitepress/init.ts && pnpm docs:build`(+ 删除 / 配置 / 守卫断言) | ❌ W0 | ⬜ pending |
| 10-01-T2 | 10-01 | 1 | D-13 / D-20 | T-10-01 | 侧边栏注册仅改同目录 config;无原始 HTML/script | smoke(静态 + 构建) | 同一构建 + 24 链接 / 4 分组断言 | ❌ W0 | ⬜ pending |
| 10-02-T1 | 10-02 | 2 | D-21 / D-22 | T-10-04 | 2 篇 markdown:无原始 `<script>` / 内联 HTML | static structure | CRLF / 单 H1 / mermaid 断言 | ❌ | ⬜ pending |
| 10-02-T2 | 10-02 | 2 | D-23 / D-24 | T-10-05 | 唯一外链为 D-23 批准链接 | static structure | CRLF / 单 H1 / 外链 / addHook 断言 | ❌ | ⬜ pending |
| 10-03-T1 | 10-03 | 3 | D-25 / D-26 / D-27 | T-10-07 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / 修饰器 / 类别断言 | ❌ | ⬜ pending |
| 10-03-T2 | 10-03 | 3 | D-28 / D-29 / D-30 | T-10-07 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / autoUnload / eventLayer 断言 | ❌ | ⬜ pending |
| 10-03-T3 | 10-03 | 3 | D-31 / D-32 | T-10-07 | 2 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / iterateBlocks / getOrInsertComputed 断言 | ❌ | ⬜ pending |
| 10-04-T1 | 10-04 | 4 | D-33 / D-34 / D-35 | T-10-10 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / mermaid / applySpecial 断言 | ❌ | ⬜ pending |
| 10-04-T2 | 10-04 | 4 | D-36 / D-37 / D-38 | T-10-10 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / register / getCriticalLimit 断言 | ❌ | ⬜ pending |
| 10-04-T3 | 10-04 | 4 | D-39 / D-40 / D-41 | T-10-10 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / useReducer / onAfterTeleportTo 断言 | ❌ | ⬜ pending |
| 10-05-T1 | 10-05 | 5 | D-42 / D-43 / D-44 | T-10-13 | 3 篇 markdown:无原始 `<script>` | static structure | CRLF / 单 H1 / createEventRegistrations / @shouldReplay / afterLoad 断言 | ❌ | ⬜ pending |
| 10-06-T1 | 10-06 | 6 | D-09 | T-10-16 / T-10-17 | 全批验收:仅白名单外链;无原始 `<script>` | smoke(构建)+ 静态 | 全量构建 + 24 文件 / sidebar / 外链断言 | ❌ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Rows are seeded from RESEARCH.md; the planner must replace the generic rows with the concrete task IDs it defines.*
*Rows map to the six plans' concrete task IDs(`10-01`..`10-06`)。每计划的 Task 0 检查点,以及 `10-06` Task 2,为人工门禁,见下方 Manual-Only Verifications。*
---
## Wave 0 Requirements
- [ ] Run `tsx docs/.vitepress/init.ts` — regenerate gitignored `docs/.vitepress/apiSidebar.ts` (build prerequisite)
- [ ] Create directory `docs/intermediate/`
- [ ] Edit `docs/.vitepress/config.ts` — add `nav` entry + `sidebar['/intermediate/']` for the 24 pages
> `10-01`(Wave 0)已扩展为「旧内容清空 + 站点注册」,下列为其实际范围(与 `10-01-PLAN.md` 一致):
- [ ] 删除旧内容 — `docs/index.md` + `docs/guide/`、`docs/dev/`、`docs/logger/`、`docs/api/`(D-04 已并入本批 Wave 0 / 10-01)
- [ ] 清理 `docs/.vitepress/config.ts` 的旧 `nav`/`sidebar` 键 — 移除指向已删内容的旧 `nav` 入口(主页 / 指南 / API / 错误代码)与旧 sidebar 键(guide / logger / api 三个路径前缀)
- [ ] 解除 `config.ts` 对 `./apiSidebar` 的静态依赖,并给 `docs/.vitepress/api.ts` 的 `generateSidebar()` 加缺失目录守卫(`node:fs` 的 `existsSync`)— 使 `tsx docs/.vitepress/init.ts` 与 `docs:dev` 在 `docs/api/` 缺失时安全跳过、不再抛 ENOENT
- [ ] 注册 `nav` 入口与 `sidebar['/intermediate/']`(4 组 / 24 条,顺序 4/8/9/3)并创建 `docs/intermediate/` 目录 — 供后续写作计划落点
- [ ] 完成后运行 `tsx docs/.vitepress/init.ts && pnpm docs:build` — 必须打印 `build complete`、**dead link = 0、无 import 失败**;否则视为 Wave 0 未通过
*No unit-test framework install needed — this batch has no test requirements.*

View File

@ -0,0 +1,20 @@
/**
* 切换全屏状态
* @param full 是否全屏
*/
export async function triggerFullscreen(full: boolean) {
if (document.fullscreenElement && !full) {
if (window.jsinterface) {
window.jsinterface.requestPortrait();
return;
}
await document.exitFullscreen();
}
if (full && !document.fullscreenElement) {
if (window.jsinterface) {
window.jsinterface.requestLandscape();
return;
}
await document.body.requestFullscreen();
}
}

View File

@ -1,3 +1,2 @@
export * from './patch';
export * from './eventEmitter';
export * from './utils';

View File

@ -1,131 +0,0 @@
import { logger } from '@motajs/common';
export const enum PatchClass {
Actions,
Control,
Core,
Data,
Enemys,
Events,
Icons,
Items,
Loader,
Maps,
UI,
Utils
}
interface PatchList {
[PatchClass.Actions]: Actions;
[PatchClass.Control]: Control;
[PatchClass.Core]: Core;
[PatchClass.Data]: Omit<DataCore, 'main'>;
[PatchClass.Enemys]: Enemys;
[PatchClass.Events]: Events;
[PatchClass.Icons]: Icons;
[PatchClass.Items]: Items;
[PatchClass.Loader]: Loader;
[PatchClass.Maps]: Maps;
[PatchClass.UI]: Ui;
[PatchClass.Utils]: Utils;
}
const patchName = {
[PatchClass.Actions]: 'actions',
[PatchClass.Control]: 'control',
[PatchClass.Core]: 'core',
[PatchClass.Data]: 'data',
[PatchClass.Enemys]: 'enemys',
[PatchClass.Events]: 'events',
[PatchClass.Icons]: 'icons',
[PatchClass.Items]: 'items',
[PatchClass.Loader]: 'loader',
[PatchClass.Maps]: 'maps',
[PatchClass.UI]: 'ui',
[PatchClass.Utils]: 'utils'
};
export class Patch<T extends PatchClass> {
private static patchList: Set<Patch<PatchClass>> = new Set();
private static patched: Partial<Record<PatchClass, Set<string>>> = {};
private patches: Map<string, (...params: any[]) => any> = new Map();
constructor(public readonly patchClass: T) {
Patch.patchList.add(this);
}
/**
* 添加函数修改
* @param key 要修改的函数名
* @param patch 修改为的函数内容
*/
add<
K extends Exclude<
SelectKey<PatchList[T], (...params: any[]) => any>,
symbol | number
>
>(key: K, patch: PatchList[T][K]) {
if (this.patches.has(key)) {
logger.warn(49, patchName[this.patchClass], key);
}
this.patches.set(key, patch);
}
private static getPatchClass(patch: PatchClass): any {
switch (patch) {
case PatchClass.Actions:
return actions.prototype;
case PatchClass.Control:
return control.prototype;
case PatchClass.Core:
return core;
case PatchClass.Data:
return data.prototype;
case PatchClass.Enemys:
return enemys.prototype;
case PatchClass.Events:
return events.prototype;
case PatchClass.Icons:
return icons.prototype;
case PatchClass.Items:
return items.prototype;
case PatchClass.Loader:
return loader.prototype;
case PatchClass.Maps:
return maps.prototype;
case PatchClass.UI:
return ui.prototype;
case PatchClass.Utils:
return utils.prototype;
}
}
/**
* 修改添加的所有函数
*/
static patchAll() {
this.patchList.forEach(v => this.patch(v));
}
/**
* 修改某个实例添加的所有函数
* @param patch 要修改的函数实例
*/
static patch(patch: Patch<PatchClass>) {
const patchClass = patch.patchClass;
this.patched[patchClass] ??= new Set();
const set = this.patched[patchClass];
const obj = this.getPatchClass(patchClass);
for (const [key, func] of patch.patches) {
// console.log(key);
if (set.has(key)) {
logger.warn(49, patchName[patchClass], key);
}
set.add(key);
obj[key] = func;
}
this.patchList.delete(patch);
}
}

View File

@ -1,32 +0,0 @@
const animation: ((time: number) => void)[] = [];
let animateTime = 0;
export default function init() {
core.registerAnimationFrame('animateController', true, time => {
if (time - animateTime <= core.values.animateSpeed) return;
for (const fn of animation) {
fn(time);
}
animateTime = core.animateFrame.animateTime;
});
return { addAnimate, removeAnimate };
}
/**
* 添加一个动画
* @param fn 要添加的函数
*/
export function addAnimate(fn: (time: number) => void) {
animation.push(fn);
}
/**
* 移除一个动画
* @param fn 要移除的函数
*/
export function removeAnimate(fn: (time: number) => void) {
const index = animation.findIndex(v => v === fn);
if (index === -1) return;
animation.splice(index, 1);
}

View File

@ -1,418 +0,0 @@
<template>
<div :id="`box-${id}`" class="box">
<div :id="`box-main-${id}`" class="box-main" @click="click">
<slot></slot>
</div>
<div
v-if="dragable"
:id="`box-move-${id}`"
class="box-move"
:selected="moveSelected"
>
<drag-outlined :id="`box-drag-${id}`" class="box-drag" />
</div>
<div
v-if="resizable"
:id="`box-size-${id}`"
class="box-size"
:selected="moveSelected"
>
<ArrowsAltOutlined :id="`box-resize-${id}`" class="box-resize" />
</div>
<div
class="border border-vertical border-left"
:id="`border-left-${id}`"
:selected="moveSelected && resizable"
:selectable="resizable"
></div>
<div
class="border border-horizontal border-top"
:id="`border-top-${id}`"
:selected="moveSelected && resizable"
:selectable="resizable"
></div>
<div
class="border border-vertical border-right"
:id="`border-right-${id}`"
:selected="moveSelected && resizable"
:selectable="resizable"
></div>
<div
class="border border-horizontal border-bottom"
:id="`border-bottom-${id}`"
:selected="moveSelected && resizable"
:selectable="resizable"
></div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, onUpdated, ref, watch } from 'vue';
import { ArrowsAltOutlined, DragOutlined } from '@ant-design/icons-vue';
import {
isMobile,
useDrag,
cancelGlobalDrag,
requireUniqueSymbol
} from '../use';
import { isNil } from 'lodash-es';
// todo: 重写
const props = defineProps<{
dragable?: boolean;
resizable?: boolean;
left?: number;
top?: number;
width?: number;
height?: number;
}>();
const emits = defineEmits<{
(e: 'update:left', data: number): void;
(e: 'update:top', data: number): void;
(e: 'update:width', data: number): void;
(e: 'update:height', data: number): void;
}>();
const id = requireUniqueSymbol().toFixed(0);
const moveSelected = ref(false);
let moveTimeout = 0;
let main: HTMLDivElement;
let leftB: HTMLDivElement;
let rightB: HTMLDivElement;
let topB: HTMLDivElement;
let bottomB: HTMLDivElement;
let drag: HTMLElement;
let size: HTMLElement;
const width = ref(
isMobile ? window.innerWidth - 100 : window.innerHeight * 0.175
);
const height = ref(isMobile ? 250 : window.innerHeight - 100);
const left = ref(isMobile ? 30 : 50);
const top = ref(isMobile ? 30 : 50);
watch(left, n => emits('update:left', n));
watch(top, n => emits('update:top', n));
watch(width, n => emits('update:width', n));
watch(height, n => emits('update:height', n));
async function click() {
moveSelected.value = true;
moveTimeout = window.setTimeout(() => {
moveSelected.value = false;
}, 4000);
}
let lastX = 0;
let lastY = 0;
function clampX(x: number) {
if (x < 16) x = 16;
const mx = window.innerWidth - 16 - width.value;
if (x > mx) x = mx;
return x;
}
function clampY(y: number) {
if (y < 16) y = 16;
const my = window.innerHeight - 16 - height.value;
if (y > my) y = my;
return y;
}
function clampPos(x: number, y: number) {
return { x: clampX(x), y: clampY(y) };
}
function clampWidth(w: number) {
if (w < 16) w = 16;
const mw = window.innerWidth - 16 - left.value;
if (w > mw) w = mw;
return w;
}
function clampHeight(h: number) {
if (h < 16) h = 16;
const mh = window.innerHeight - 16 - top.value;
if (h > mh) h = mh;
return h;
}
function clampSize(w: number, h: number) {
return { w: clampWidth(w), h: clampHeight(h) };
}
function dragFn(x: number, y: number) {
const { x: tx, y: ty } = clampPos(x + 8, y + 8);
left.value = tx;
top.value = ty;
main.style.left = `${tx}px`;
main.style.top = `${ty}px`;
clearTimeout(moveTimeout);
lastX = x;
lastY = y;
}
let right = left.value + width.value;
function leftDrag(x: number, y: number) {
const tx = clampX(x);
main.style.left = `${tx}px`;
width.value = right - tx;
left.value = tx;
main.style.width = `${width.value}px`;
}
let bottom = top.value + height.value;
function topDrag(x: number, y: number) {
const ty = clampY(y);
main.style.top = `${ty}px`;
height.value = bottom - ty;
top.value = ty;
main.style.height = `${height.value}px`;
}
function rightDrag(x: number, y: number) {
const w = clampWidth(x - main.offsetLeft);
width.value = w;
main.style.width = `${w}px`;
}
function bottomDrag(x: number, y: number) {
const h = clampHeight(y - main.offsetTop);
height.value = h;
main.style.height = `${h}px`;
}
function resizeBox(x: number, y: number) {
const { w, h } = clampSize(x - main.offsetLeft - 8, y - main.offsetTop - 8);
width.value = w;
height.value = h;
main.style.width = `${w}px`;
main.style.height = `${h}px`;
}
function resize() {
main = document.getElementById(`box-${id}`) as HTMLDivElement;
leftB = document.getElementById(`border-left-${id}`) as HTMLDivElement;
topB = document.getElementById(`border-top-${id}`) as HTMLDivElement;
rightB = document.getElementById(`border-right-${id}`) as HTMLDivElement;
bottomB = document.getElementById(`border-bottom-${id}`) as HTMLDivElement;
drag = document.getElementById(`box-drag-${id}`) as HTMLElement;
size = document.getElementById(`box-resize-${id}`) as HTMLElement;
if (!main) return;
if (!isNil(props.width)) width.value = props.width;
if (!isNil(props.height)) height.value = props.height;
if (!isNil(props.left)) left.value = props.left;
if (!isNil(props.top)) top.value = props.top;
const beforeWidth = width.value;
const beforeHeight = height.value;
width.value = 16;
height.value = 16;
left.value = clampX(left.value);
top.value = clampY(top.value);
main.style.left = `${left.value}px`;
main.style.top = `${top.value}px`;
width.value = clampWidth(beforeWidth);
height.value = clampHeight(beforeHeight);
main.style.width = `${width.value}px`;
main.style.height = `${height.value}px`;
}
onUpdated(resize);
onMounted(async () => {
resize();
if (!main) return;
if (props.dragable) {
useDrag(
drag,
dragFn,
(x, y) => {
lastX = x;
lastY = y;
},
() => {
moveSelected.value = false;
},
true
);
}
if (props.resizable) {
useDrag(
leftB,
leftDrag,
(x, y) => {
right = left.value + width.value;
},
void 0,
true
);
useDrag(
topB,
topDrag,
(x, y) => {
bottom = top.value + height.value;
},
void 0,
true
);
useDrag(rightB, rightDrag, void 0, void 0, true);
useDrag(bottomB, bottomDrag, void 0, void 0, true);
useDrag(size, resizeBox, void 0, void 0, true);
}
window.addEventListener('resize', resize);
});
onUnmounted(() => {
window.removeEventListener('resize', resize);
if (props.dragable) cancelGlobalDrag(dragFn);
if (props.resizable) {
cancelGlobalDrag(leftDrag);
cancelGlobalDrag(topDrag);
cancelGlobalDrag(rightDrag);
cancelGlobalDrag(bottomDrag);
cancelGlobalDrag(resizeBox);
}
});
</script>
<style lang="less" scoped>
.box {
width: 300px;
height: calc(100vh - 100px);
position: fixed;
left: 50px;
top: 50px;
display: flex;
overflow: visible;
}
.box-main {
width: 100%;
height: 100%;
overflow: hidden;
}
.box-move {
transition: font-size 0.3s ease-out;
position: absolute;
left: -32px;
top: -32px;
width: 32px;
height: 32px;
}
.box-size {
transition: font-size 0.3s ease-out;
position: absolute;
left: 100%;
top: 100%;
}
.box-drag {
cursor: all-scroll;
user-select: none;
right: 0;
bottom: 0;
position: absolute;
}
.box-resize {
left: 0;
top: 0;
position: absolute;
transform: rotateX(180deg);
user-select: none;
cursor: nwse-resize;
}
.box-move[selected='false'],
.box-size[selected='false'] {
font-size: 16px;
}
.box-move[selected='true'],
.box-size[selected='true'] {
font-size: 32px;
}
.border {
margin: 0;
position: absolute;
transition: transform 0.3s ease-out;
}
.border-horizontal {
width: 100%;
height: 0px;
left: 0px;
}
.border-horizontal[selected='true'][selectable='true'] {
transform: scaleY(300%);
cursor: ns-resize;
}
.border-horizontal:hover[selectable='true'],
.border-horizontal:active[selectable='true'] {
transform: scaleY(500%);
cursor: ns-resize;
}
.border-vertical {
width: 0px;
height: 100%;
top: 0px;
}
.border-vertical[selected='true'][selectable='true'] {
transform: scaleX(300%);
cursor: ew-resize;
}
.border-vertical:hover[selectable='true'],
.border-vertical:active[selectable='true'] {
transform: scaleX(500%);
cursor: ew-resize;
}
.border-left {
left: 0;
border-left: 2px solid #ddd9;
}
.border-right {
right: 0;
border-right: 2px solid #ddd9;
}
.border-top {
top: 0;
border-top: 2px solid #ddd9;
}
.border-bottom {
bottom: 0;
border-bottom: 2px solid #ddd9;
}
@media screen and (max-width: 600px) {
.box {
width: calc(100vw - 100px);
height: 250px;
}
}
</style>

View File

@ -1,91 +0,0 @@
<template>
<canvas
:width="width ?? 32"
:height="height ?? 32"
:id="`box-animate-${id}`"
></canvas>
</template>
<script lang="tsx" setup>
import { onMounted, onUnmounted, onUpdated } from 'vue';
import { addAnimate, removeAnimate } from '../animateController';
import { requireUniqueSymbol } from '../use';
import { isNil } from 'lodash-es';
const id = requireUniqueSymbol().toFixed(0);
const props = defineProps<{
id: AllIds | 'hero' | 'none';
noborder?: boolean;
width?: number;
height?: number;
noAnimate?: boolean;
}>();
let c: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let drawFn: () => void;
function draw() {
if (!isNil(drawFn)) removeAnimate(drawFn);
const cls = core.getClsFromId(props.id as AllIds);
const frames = core.getAnimateFrames(cls);
const w = props.width ?? 32;
const h = props.height ?? 32;
if (!props.noborder) {
c.style.border = '1.5px solid #ddd';
c.style.backgroundColor = '#222';
}
const scale = window.devicePixelRatio;
c.style.width = `${w}px`;
c.style.height = `${h}px`;
c.width = scale * w;
c.height = scale * h;
ctx.scale(scale, scale);
ctx.imageSmoothingEnabled = false;
if (props.id === 'none') return;
if (props.id === 'hero') {
const img = core.material.images.hero;
ctx.drawImage(img, 0, 0, img.width / 4, img.height / 4, 0, 0, w, h);
} else if (frames === 1) {
core.drawIcon(ctx, props.id, 0, 0, props.width, props.height);
} else {
drawFn = () => {
core.clearMap(ctx);
const frame = props.noAnimate
? 0
: core.status.globalAnimateStatus % frames;
core.drawIcon(ctx, props.id as AllIds, 0, 0, w, h, frame);
};
drawFn();
if (!props.noAnimate) {
addAnimate(drawFn);
}
}
}
onUnmounted(() => {
removeAnimate(drawFn);
});
onMounted(() => {
c = document.getElementById(`box-animate-${id}`) as HTMLCanvasElement;
ctx = c.getContext('2d')!;
ctx.imageSmoothingEnabled = false;
draw();
});
onUpdated(() => {
draw();
});
</script>
<style lang="less" scoped></style>

View File

@ -1,145 +0,0 @@
<template>
<div class="colomn">
<div class="tools">
<span class="button-text" @click="emits('close')"
><left-outlined /> 返回</span
>
</div>
<div class="column-main" :id="`column-${id}`">
<Scroll class="column-left" :id="`column-left-${id}`">
<slot name="left"></slot>
</Scroll>
<Divider
class="divider"
dashed
style="border-color: #ddd4"
:type="isMobile ? 'horizontal' : 'vertical'"
></Divider>
<Scroll class="column-right" :id="`column-right-${id}`"
><slot name="right"></slot
></Scroll>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUpdated } from 'vue';
import { LeftOutlined } from '@ant-design/icons-vue';
import { Divider } from 'ant-design-vue';
import Scroll from './scroll.vue';
import { isMobile, requireUniqueSymbol } from '../use';
import { isNil } from 'lodash-es';
const emits = defineEmits<{
(e: 'close'): void;
}>();
const props = defineProps<{
width?: number;
height?: number;
left?: number;
right?: number;
}>();
const id = requireUniqueSymbol().toFixed(0);
let main: HTMLDivElement;
let left: HTMLDivElement;
let right: HTMLDivElement;
function resize() {
main = document.getElementById(`column-${id}`) as HTMLDivElement;
left = document.getElementById(`column-left-${id}`) as HTMLDivElement;
right = document.getElementById(`column-right-${id}`) as HTMLDivElement;
if (!isNil(props.width) && !isMobile) main.style.width = `${props.width}%`;
if (!isNil(props.height)) main.style.height = `${props.height}%`;
if (!isNil(props.left)) left.style.flexBasis = `${props.left}%`;
if (!isNil(props.right)) right.style.flexBasis = `${props.right}%`;
}
onMounted(async () => {
resize();
});
onUpdated(resize);
</script>
<style lang="less" scoped>
.colomn {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-family: 'normal';
font-size: 150%;
user-select: none;
}
.column-main {
width: 70%;
height: 70%;
display: flex;
flex-direction: row;
}
.column-list {
display: flex;
flex-direction: column;
}
.column-item {
width: 100%;
padding: 1% 3% 1% 3%;
}
.column-item[selectable='false'] {
color: gray;
}
.column-left {
flex-basis: 40%;
height: 100%;
}
.column-right {
flex-basis: 60%;
height: 100%;
}
.divider {
height: 100%;
}
.tools {
width: 100%;
font-family: 'normal';
font-size: 3.2vh;
height: 5vh;
position: fixed;
left: 10vw;
top: 5vh;
}
@media screen and (max-width: 600px) {
.column-main {
flex-direction: column;
width: 90%;
height: 75%;
font-size: 80%;
}
.divider {
height: auto;
width: 100%;
margin: 5% 0 5% 0;
}
.column-left {
height: 40%;
}
.column-right {
height: 50%;
}
}
</style>

View File

@ -1,232 +0,0 @@
<template>
<div
class="enemy-container"
@click="select"
@mousemove="enter"
:selected="selected"
>
<div class="info">
<div class="leftbar">
<span class="name">{{ enemy.enemy.enemy.name }}</span>
<BoxAnimate
:id="enemy.enemy.id"
:width="isMobile ? 32 : w"
:height="isMobile ? 32 : w"
style="margin: 5%"
></BoxAnimate>
<div
class="special-text"
v-if="!isNil(enemy.special) && enemy.special.length > 0"
>
<template v-for="(text, i) in enemy.showSpecial">
<span v-if="i < (isMobile ? 1 : 2)"
>&nbsp;{{ text[0] }}&nbsp;</span
>
<span v-if="i === (isMobile ? 1 : 2)">...</span>
</template>
</div>
<div class="special-text" v-else>无属性</div>
</div>
<Divider
type="vertical"
dashed
style="height: 100%; margin: 0 3% 0 1%; border-color: #ddd4"
></Divider>
<div class="rightbar">
<div class="detail">
<div class="detail-info">
<span style="color: lightgreen"
>生命&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.enemy.info.hp)
}}</span
>
</div>
<div class="detail-info">
<span style="color: lightcoral"
>攻击&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.enemy.info.atk)
}}</span
>
</div>
<div class="detail-info">
<span style="color: lightblue"
>防御&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.enemy.info.def)
}}</span
>
</div>
<div class="detail-info">
<span style="color: lightyellow"
>金币&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.enemy.enemy.money)
}}</span
>
</div>
<div class="detail-info">
<span style="color: lawngreen"
>经验&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.enemy.enemy.exp)
}}</span
>
</div>
<div class="detail-info">
<span :style="{ color: enemy.damageColor }"
>伤害&nbsp;&nbsp;&nbsp;&nbsp;{{
enemy.damage
}}</span
>
</div>
<div class="detail-info">
<span style="color: lightsalmon"
>临界&nbsp;&nbsp;&nbsp;&nbsp;{{
enemy.critical
}}</span
>
</div>
<div class="detail-info">
<span style="color: lightpink"
>减伤&nbsp;&nbsp;&nbsp;&nbsp;<span
:style="{
color: 'lightpink'
}"
>{{ enemy.criticalDam }}</span
></span
>
</div>
<div class="detail-info">
<span style="color: cyan"
>{{
core.formatBigNumber(core.status.thisMap.ratio)
}}防&nbsp;&nbsp;&nbsp;&nbsp;{{
core.formatBigNumber(enemy.defDam)
}}</span
>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Divider } from 'ant-design-vue';
import BoxAnimate from '../components/boxAnimate.vue';
import { isMobile } from '../use';
import { ToShowEnemy } from '../tools/book';
import { isNil } from 'lodash-es';
const props = defineProps<{
enemy: ToShowEnemy;
selected?: boolean;
}>();
const emits = defineEmits<{
(e: 'select'): void;
(e: 'hover'): void;
}>();
const core = window.core;
const w = window.innerWidth * 0.032;
/**
* 选择这个怪物时
*/
function select(e: MouseEvent) {
emits('select');
}
function enter() {
emits('hover');
}
</script>
<style lang="less" scoped>
.enemy-container {
border: 1.5px solid transparent;
border-radius: 15px;
transition: all 0.2s linear;
height: 100%;
.info {
flex-basis: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: stretch;
height: 100%;
}
}
.enemy-container[selected='true'] {
border: 1.5px solid gold;
}
.leftbar {
width: 15%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 120%;
padding-left: 1%;
}
.name {
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.special-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
flex-direction: row;
align-items: center;
justify-items: space-between;
}
.rightbar {
font-size: 140%;
width: 85%;
height: 100%;
padding: 1.5vh 0 1.5vh 0;
.detail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
.detail-info {
flex-basis: 33.3%;
line-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}
@media screen and (max-width: 600px) {
.rightbar {
width: 80%;
font-size: 110%;
}
.leftbar {
width: 20%;
font-size: 100%;
}
.enemy-container {
border-radius: 20px;
}
}
</style>

View File

@ -1,5 +0,0 @@
export { default as Box } from './box.vue';
export { default as BoxAnimate } from './boxAnimate.vue';
export { default as Column } from './colomn.vue';
export { default as EnemyOne } from './enemyOne.vue';
export { default as Scroll } from './scroll.vue';

View File

@ -1,267 +0,0 @@
<template>
<div :id="`scroll-div-${id}`" class="scroll-main">
<div class="main-div">
<div :id="`content-${id}`" class="content">
<slot></slot>
</div>
</div>
<canvas :id="`scroll-${id}`" class="scroll"></canvas>
</div>
</template>
<script lang="ts" setup>
import { sleep } from 'mutate-animate';
import { onMounted, onUnmounted, onUpdated } from 'vue';
import {
cancelGlobalDrag,
useDrag,
useWheel,
requireUniqueSymbol
} from '../use';
let main: HTMLDivElement;
const props = defineProps<{
now?: number;
type?: 'vertical' | 'horizontal';
drag?: boolean;
width?: number;
update?: boolean;
noScroll?: boolean;
}>();
const emits = defineEmits<{
(e: 'update:now', value: number): void;
(e: 'update:drag', value: boolean): void;
(e: 'update:update'): void;
}>();
let now = 0;
let total = 0;
const id = requireUniqueSymbol().toFixed(0);
const scale = window.devicePixelRatio;
const width = props.width ?? 20;
const cssTarget = props.type === 'horizontal' ? 'left' : 'top';
const canvasAttr = props.type === 'horizontal' ? 'width' : 'height';
let ctx: CanvasRenderingContext2D;
let content: HTMLDivElement;
let fromSelf = false;
const resize = async () => {
await calHeight(false);
draw();
};
/** 绘制 */
function draw() {
if (total === 0) return;
if (total < ctx.canvas[canvasAttr] / scale) {
now = 0;
} else if (now > total - ctx.canvas[canvasAttr] / scale) {
now = total - ctx.canvas[canvasAttr] / scale;
} else if (now < 0) {
now = 0;
}
if (props.noScroll) return;
const w = ctx.canvas.width;
const h = ctx.canvas.height;
emits('update:now', now);
const length =
Math.min(ctx.canvas[canvasAttr] / total / scale, 1) *
ctx.canvas[canvasAttr];
const py = (now / total) * ctx.canvas[canvasAttr];
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.beginPath();
if (props.type === 'horizontal') {
ctx.moveTo(Math.max(py + 5, 5), h / 2);
ctx.lineTo(Math.min(py + length - 5, ctx.canvas.width - 5), h / 2);
} else {
ctx.moveTo(w / 2, Math.max(py + 5, 5));
ctx.lineTo(w / 2, Math.min(py + length - 5, ctx.canvas.height - 5));
}
ctx.lineCap = 'round';
ctx.lineWidth = 6;
ctx.strokeStyle = '#fff';
ctx.stroke();
}
/**
* 计算元素总长度
*/
async function calHeight(first: boolean = false) {
if (!first) {
await sleep(20);
}
const canvas = ctx.canvas;
const style2 = getComputedStyle(canvas);
if (!props.type || props.type === 'vertical') {
if (props.noScroll) {
canvas.style.width = `0px`;
} else {
canvas.style.width = `${width}px`;
canvas.width = width * scale;
canvas.height = parseFloat(style2.height) * scale;
}
} else {
main.style.flexDirection = 'column';
if (props.noScroll) {
canvas.style.height = `0px`;
} else {
canvas.style.height = `${width}px`;
canvas.style.width = '98%';
canvas.style.margin = '0 1% 0 1%';
canvas.width = parseFloat(style2.width) * scale;
canvas.height = width * scale;
}
}
await new Promise<void>(res => {
requestAnimationFrame(() => {
total =
props.type === 'horizontal'
? content.scrollWidth
: content.scrollHeight;
res();
});
});
}
function scroll() {
draw();
content.style[cssTarget] = `${-now}px`;
}
onUpdated(async () => {
if (fromSelf) return;
now = props.now ?? now;
content.style.transition = `${cssTarget} 0.2s ease-out`;
await calHeight(false);
scroll();
});
let last: number;
let contentLast: number;
function canvasDrag(x: number, y: number) {
emits('update:drag', true);
const d = props.type === 'horizontal' ? x : y;
const dy = d - last;
last = d;
if (ctx.canvas[canvasAttr] < total * scale)
now += ((dy * total) / ctx.canvas[canvasAttr]) * scale;
scroll();
}
function contentDrag(x: number, y: number) {
emits('update:drag', true);
const d = props.type === 'horizontal' ? x : y;
const dy = d - contentLast;
contentLast = d;
if (ctx.canvas[canvasAttr] < total * scale) now -= dy;
scroll();
}
onMounted(async () => {
main = document.getElementById(`scroll-div-${id}`) as HTMLDivElement;
const d = document.getElementById(`content-${id}`) as HTMLDivElement;
content = d;
const canvas = document.getElementById(`scroll-${id}`) as HTMLCanvasElement;
ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
if (!props.noScroll) {
// 绑定滚动条拖拽事件
useDrag(
canvas,
canvasDrag,
(x, y) => {
fromSelf = true;
last = props.type === 'horizontal' ? x : y;
content.style.transition = '';
},
() => {
setTimeout(() => emits('update:drag', false));
fromSelf = false;
},
true
);
}
// 绑定文本拖拽事件
useDrag(
content,
contentDrag,
(x, y) => {
fromSelf = true;
contentLast = props.type === 'horizontal' ? x : y;
content.style.transition = '';
},
() => {
setTimeout(() => emits('update:drag', false));
fromSelf = false;
},
true
);
useWheel(content, (x, y) => {
fromSelf = true;
const d = x !== 0 ? x : y;
if (Math.abs(d) > 30) {
content.style.transition = `${cssTarget} 0.2s ease-out`;
} else {
content.style.transition = '';
}
now += d;
scroll();
fromSelf = false;
});
window.addEventListener('resize', resize);
await calHeight(true);
await calHeight();
draw();
});
onUnmounted(() => {
window.removeEventListener('resize', resize);
cancelGlobalDrag(canvasDrag);
cancelGlobalDrag(contentDrag);
});
</script>
<style lang="less" scoped>
.scroll {
opacity: 0.2;
transition: opacity 0.2s linear;
}
.scroll:hover {
opacity: 0.4;
}
.scroll:active {
opacity: 0.6;
}
.scroll-main {
display: flex;
flex-direction: row;
max-width: 100%;
max-height: 100%;
justify-content: stretch;
}
.content {
width: 100%;
position: relative;
}
.main-div {
flex-basis: 100%;
overflow: hidden;
}
</style>

View File

@ -1,340 +0,0 @@
import { Component, shallowReactive } from 'vue';
import { EventEmitter } from '@motajs/legacy-common';
import { IGameUi, IUiController } from './interface';
interface FocusEvent<T> {
focus: (before: T | null, after: T) => void;
unfocus: (before: T | null) => void;
add: (item: T) => void;
pop: (item: T | null) => void;
splice: (spliced: T[]) => void;
}
export class Focus<T = any> extends EventEmitter<FocusEvent<T>> {
/** 显示列表 */
stack: T[];
focused: T | null = null;
/** 聚焦目标是否平等,在平等时,关闭聚焦目标不再会将其之后的目标全部删除,而是保留 */
readonly equal: boolean;
constructor(react: boolean = false, equal: boolean = false) {
super();
this.stack = react ? shallowReactive([]) : [];
this.equal = equal;
}
/**
* 聚焦于一个目标
* @param target 聚焦目标
* @param add 如果聚焦目标不在显示列表里面,是否自动追加
*/
focus(target: T, add: boolean = false) {
if (target === this.focused) return;
const before = this.focused;
if (!this.stack.includes(target)) {
if (add) {
this.add(target);
this.focused = target;
} else {
console.warn(
`聚焦于一个不存在的目标,同时没有传入自动追加的参数`,
`聚焦目标:${target}`
);
return;
}
} else {
this.focused = target;
}
this.emit('focus', before, this.focused);
}
/**
* 取消聚焦
*/
unfocus() {
const before = this.focused;
this.focused = null;
this.emit('unfocus', before);
}
/**
* 向显示列表中添加物品
* @param item 添加的物品
*/
add(item: T) {
this.stack.push(item);
this.emit('add', item);
}
/**
* 弹出显示列表中的最后一个物品
*/
pop() {
const item = this.stack.pop() ?? null;
const last = this.stack.at(-1) ?? null;
if (!last) this.unfocus();
else this.focus(last);
this.emit('pop', item);
return item;
}
/**
* 从一个位置开始删除显示列表,如果平等,则只会删除一个,否则会将其之后的所有的目标全部删除
* @param item 从哪开始删除,包括此项
*/
splice(item: T) {
const index = this.stack.indexOf(item);
this.spliceIndex(index);
}
/**
* 根据索引在显示列表中删除一项
* @param index 要删除的项的索引
*/
spliceIndex(index: number) {
if (index === -1) {
this.emit('splice', []);
return;
}
if (!this.stack[index]) return;
const last = this.stack.at(-1) ?? null;
if (!last) this.unfocus();
else this.focus(last);
this.emit(
'splice',
this.stack.splice(index, this.equal ? 1 : Infinity)
);
}
}
interface GameUiEvent {
close: () => void;
open: () => void;
}
interface ShowableGameUi {
ui: GameUi;
vOn?: UiVOn;
vBind?: UiVBind;
}
type UiVOn = Record<string, (param?: any) => void>;
type UiVBind = Record<string, any>;
interface MountedVBind {
num: number;
ui: GameUi;
controller: UiController;
[x: string]: any;
}
export class GameUi extends EventEmitter<GameUiEvent> implements IGameUi {
static uiList: GameUi[] = [];
component: Component;
id: string;
symbol: symbol = Symbol();
constructor(id: string, component: Component) {
super();
this.component = component;
this.id = id;
GameUi.uiList.push(this);
}
/**
* 根据 v-on 和 v-bind 创建可以显示的 ui 组件
* @param vOn 监听的事件
* @param vBind 绑定的数据
*/
with(vBind?: UiVBind, vOn?: UiVOn): ShowableGameUi {
return { ui: this, vOn, vBind };
}
}
interface IndexedGameUi extends ShowableGameUi {
num: number;
vBind?: MountedVBind;
}
interface HoldOnController {
end(noClosePanel?: boolean): void;
}
export class UiController
extends Focus<IndexedGameUi>
implements IUiController
{
static list: UiController[] = [];
list: Record<string, GameUi> = {};
num: number = 0;
show: 'end' | 'all' = 'all';
private hold: boolean = false;
constructor(equal?: boolean) {
super(true, equal);
UiController.list.push(this);
this.on('splice', spliced => {
spliced.forEach(v => {
v.ui.emit('close');
});
if (this.stack.length === 0) {
if (!this.hold) this.emit('end', false);
this.hold = false;
}
});
this.on('add', item => {
if (this.stack.length === 1) {
this.emit('start');
}
item.ui.emit('open');
});
}
/**
* 设置为仅显示最后一个ui
*/
showEnd() {
this.show = 'end';
}
/**
* 设置为显示所有ui
*/
showAll() {
this.show = 'all';
}
/**
* 根据id获取到ui
* @param id ui的id
*/
get(id: string) {
return this.list[id];
}
/**
* 暂时保持下一次删除ui不会导致ui整体被关闭,引起ui背景闪烁。
* 例如可以用于道具栏,打开道具时就应当 holdOn,然后通过道具使用钩子来判断接下来是否要隐藏 app:
* ```txt
* hold on -> close -> use item -> hook -> stack.length === 0 ? end() : no action
* ```
*/
holdOn(): HoldOnController {
this.hold = true;
return {
end: (noClosePanel: boolean = false) => {
this.emit('end', noClosePanel);
}
};
}
/**
* 关闭一个ui,注意如果不是平等模式,在其之后的ui都会同时关闭掉
* @param num 要关闭的ui的唯一标识符
*/
close(num: number) {
const ui = this.stack.findIndex(v => v.num === num);
this.spliceIndex(ui);
}
/**
* 根据id关闭所有同id的ui,注意非平等模式下,会将第一个ui后的所有ui都关闭掉
* @param id 要关闭的ui的id
*/
closeByName(id: string) {
if (!this.equal) {
const ui = this.stack.findIndex(v => v.ui.id === id);
this.spliceIndex(ui);
} else {
let ui;
while ((ui = this.stack.findIndex(v => v.ui.id === id)) !== -1) {
this.spliceIndex(ui);
}
}
}
/**
* 打开一个新的ui
* @param id 要打开的ui的id
* @param vOn 监听的事件
* @param vBind 绑定的数据
* @returns ui的唯一标识符
*/
open(id: string, vBind?: UiVBind, vOn?: UiVOn) {
if (core.isReplaying() && !this.equal) return -1;
const ui = this.get(id);
if (!ui) {
console.warn(`Unknown UI: '${id}'.`);
return -1;
}
const num = this.num++;
const bind = {
num,
ui,
controller: this,
...(vBind ?? {})
};
const sui = ui.with(bind, vOn);
this.add({
num,
...sui
} as IndexedGameUi);
return num;
}
/**
* 注册一个ui
* @param id ui的id
* @param ui 对应的GameUi实例
*/
register(...ui: GameUi[]) {
ui.forEach(v => {
const id = v.id;
if (id in this.list) {
console.warn(`已存在id为'${id}'的ui,已将其覆盖`);
}
this.list[id] = v;
});
}
/**
* 取消注册一个ui
* @param id 要取消注册的ui的id
*/
unregister(...id: string[]) {
id.forEach(v => {
delete this.list[v];
});
return this;
}
/**
* 根据ui的唯一标识符进行聚焦
* @param num 要聚焦于的ui的唯一标识符
*/
focusByNum(num: number) {
const ui = this.getByNum(num);
if (!ui) return;
this.focus(ui);
}
/**
* 根据唯一标识符获取对应的ui
* @param num ui的唯一标识符
*/
getByNum(num: number) {
return this.stack.find(v => v.num === num);
}
/**
* 根据ui的唯一标识符来判断当前是否存在某个ui
* @param id ui的唯一标识符
*/
hasName(id: string) {
return this.stack.some(v => v.ui.id === id);
}
}

View File

@ -1,25 +0,0 @@
{
"screen": {
"fullscreen": [
"是否全屏进行游戏,全屏后按ESC退出全屏,开启后将不能通过按ESC开启系统设置菜单,",
"请按下方的按钮打开。进入或退出全屏后请存读档一下,以解决一部分绘制问题。"
],
"itemDetail": ["是否在地图上显示宝石血瓶装备等增加的属性值"],
"transition": [
"是否展示当一个ui界面,如怪物手册等的打开与关闭时的动画。当此项开启时,",
"所有界面被打开或关闭时都会展示动画,否则会直接展示出来"
],
"fontSize": [
"在各种 ui 界面中显示的文字大小,范围为 2 - 48。注意,字体过大可能会引起 ui 布局发生错误"
],
"criticalGem": ["临界是否显示为在当前地图要吃的宝石数"]
},
"action": {
"hotkey": ["设置游戏中会用到的一些快捷键"]
},
"utils": {
"autoScale": [
"开启后,每次进入游戏时会自动缩放游戏画面至合适值。该项只对电脑端有效。"
]
}
}

View File

@ -1,16 +1 @@
import { createPreset } from './preset';
export function create() {
createPreset();
}
export * as UI from './ui';
export * as Components from './components';
export * from './preset';
export * from './tools';
export * from './animateController';
export * from './controller';
export * from './setting';
export * from './use';
export * from './utils';
export * from './uiUtils';

View File

@ -1,99 +0,0 @@
export interface IGameUi {
id: string;
symbol: symbol;
}
export interface IMountedVBind {
num: number;
ui: IGameUi;
controller: IUiController;
[x: string]: any;
}
interface HoldOnController {
end(noClosePanel?: boolean): void;
}
type UiVOn = Record<string, (param?: any) => void>;
type UiVBind = Record<string, any>;
export interface IUiController {
stack: any[];
/**
* 设置为仅显示最后一个ui
*/
showEnd(): void;
/**
* 设置为显示所有ui
*/
showAll(): void;
/**
* 根据id获取到ui
* @param id ui的id
*/
get(id: string): void;
/**
* 暂时保持下一次删除ui不会导致ui整体被关闭,引起ui背景闪烁。
* 例如可以用于道具栏,打开道具时就应当 holdOn,然后通过道具使用钩子来判断接下来是否要隐藏 app:
* ```txt
* hold on -> close -> use item -> hook -> stack.length === 0 ? end(): no action
* ```
*/
holdOn(): HoldOnController;
/**
* 关闭一个ui,注意如果不是平等模式,在其之后的ui都会同时关闭掉
* @param num 要关闭的ui的唯一标识符
*/
close(num: number): void;
/**
* 根据id关闭所有同id的ui,注意非平等模式下,会将第一个ui后的所有ui都关闭掉
* @param id 要关闭的ui的id
*/
closeByName(id: string): void;
/**
* 打开一个新的ui
* @param id 要打开的ui的id
* @param vOn 监听的事件
* @param vBind 绑定的数据
* @returns ui的唯一标识符
*/
open(id: string, vBind?: UiVBind, vOn?: UiVOn): void;
/**
* 注册一个ui
* @param id ui的id
* @param ui 对应的GameUi实例
*/
register(...ui: IGameUi[]): void;
/**
* 取消注册一个ui
* @param id 要取消注册的ui的id
*/
unregister(...id: string[]): void;
/**
* 根据ui的唯一标识符进行聚焦
* @param num 要聚焦于的ui的唯一标识符
*/
focusByNum(num: number): void;
/**
* 根据唯一标识符获取对应的ui
* @param num ui的唯一标识符
*/
getByNum(num: number): void;
/**
* 根据ui的唯一标识符来判断当前是否存在某个ui
* @param id ui的唯一标识符
*/
hasName(id: string): void;
}

View File

@ -1,309 +0,0 @@
<template>
<div id="critical-main">
<div id="critical">
<div class="des">加攻伤害</div>
<canvas ref="critical" class="chart"></canvas>
<div class="slider-div">
<span>加攻次数&nbsp;&nbsp;&nbsp;&nbsp;{{ addAtk }}</span>
<Slider
class="slider"
v-model:value="addAtk"
:max="ceil((originCri.at(-1)?.[0] ?? 2) / ratio) - 1"
></Slider>
<span
>最大值&nbsp;&nbsp;&nbsp;&nbsp;{{
ceil((originCri.at(-1)?.[0] ?? 2) / ratio) - 1
}}</span
>
</div>
</div>
<Divider
dashed
style="width: 100%; border-color: #ddd4; margin: 1vh 0 1vh 0"
></Divider>
<div id="def">
<div class="des">加防伤害</div>
<canvas ref="def" class="chart"></canvas>
<div class="slider-div">
<span>加防次数&nbsp;&nbsp;&nbsp;&nbsp;{{ addDef }}</span>
<Slider
class="slider"
v-model:value="addDef"
:max="ceil((originDef.at(-1)?.[0] ?? 2) / ratio) - 1"
></Slider>
<span
>最大值&nbsp;&nbsp;&nbsp;&nbsp;{{
ceil((originDef.at(-1)?.[0] ?? 2) / ratio) - 1
}}</span
>
</div>
</div>
<div id="now-damage">
<div>
<span
>当前加攻{{
isMobile ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;'
}}</span
>
<span class="changable" :change="addAtkChangable">{{
format(addAtk * ratio)
}}</span>
</div>
<div>
<span
>当前加防{{
isMobile ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;'
}}</span
>
<span class="changable" :change="addDefChangable">{{
format(addDef * ratio)
}}</span>
</div>
<div>
<span
>当前减伤{{
isMobile ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;'
}}</span
>
<span class="changable" :change="nowDamageChangable"
><span style="font-family: 'FiraCode'">{{
(nowDamage[0] as number) < 0 && isNil(enemy.damage)
? '=>'
: ''
}}</span
>{{
(nowDamage[0] as number) < 0 && isNil(enemy.damage)
? format(-nowDamage[0])
: format(nowDamage[0])
}}</span
>
</div>
<div>
<span
>当前伤害{{
isMobile ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;'
}}</span
>
<span class="changable" :change="nowDamageChangable">{{
format(nowDamage[1])
}}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Slider, Divider } from 'ant-design-vue';
import { computed, onMounted, ref, watch } from 'vue';
import { detailInfo, getCriticalDamage, getDefDamage } from '../tools/book';
import Chart, { ChartConfiguration } from 'chart.js/auto';
import { setCanvasSize } from '../utils';
import { debounce, isNil } from 'lodash-es';
import { isMobile } from '../use';
import { createChangable } from '../tools/common';
const props = defineProps<{
fromBook?: boolean;
}>();
const critical = ref<HTMLCanvasElement>();
const def = ref<HTMLCanvasElement>();
const enemy = detailInfo.enemy!;
const ceil = Math.ceil;
const x = ref<number>();
const y = ref<number>();
x.value = !isNil(x.value)
? Math.round(x.value + core.bigmap.offsetX / 32)
: void 0;
y.value = !isNil(y.value)
? Math.round(y.value + core.bigmap.offsetY / 32)
: void 0;
let originCri = getCriticalDamage(enemy, 0, 0);
let originDef = getDefDamage(enemy, 0, 0);
// 当前数据
const allCri = ref(originCri);
const allDef = ref(originDef);
// 加攻加防数量
const addAtk = ref(0);
const addDef = ref(0);
const addAtkChangable = createChangable(addAtk).change;
const addDefChangable = createChangable(addDef).change;
const originDamage = enemy.enemy.calDamage(core.status.hero).damage;
// 转发core上的内容至当前作用域
const format = core.formatBigNumber;
const ratio = core.status.thisMap.ratio;
const nowDamage = computed(() => {
const dam = enemy.enemy.calDamage({
atk: core.status.hero.atk + addAtk.value * ratio,
def: core.status.hero.def + addDef.value * ratio
}).damage;
if (!isFinite(dam)) return ['???', '???'];
if (!isFinite(originDamage)) return [-dam, dam];
return [originDamage - dam, dam];
});
const nowDamageChangable = createChangable(nowDamage, 1).change;
function generateChart(ele: HTMLCanvasElement, data: [number, number][]) {
Chart.defaults.color = '#aaa';
const config: ChartConfiguration = {
type: 'line',
data: generateData(data),
options: {
elements: {
point: {
radius: 5,
hoverRadius: 7
},
line: {
borderJoinStyle: 'round'
}
},
scales: {
y: {
grid: {
color: '#ddd3'
}
}
},
plugins: {
legend: {
display: false
}
}
}
};
return new Chart(ele, config);
}
/**
* 生成图表数据
* @param data 数据
*/
function generateData(data: [number, number][]): ChartConfiguration['data'] {
return {
datasets: [
{
data: data.map(v => v[1]),
label: '怪物伤害'
}
],
labels: data.map(v => Math.round(v[0] / ratio))
};
}
const update = debounce((atk: Chart, def: Chart) => {
allCri.value = getCriticalDamage(
enemy,
addAtk.value * ratio,
addDef.value * ratio
);
allDef.value = getDefDamage(
enemy,
addDef.value * ratio,
addAtk.value * ratio
);
if (allCri.value.length > originCri.length) originCri = allCri.value;
if (allDef.value.length > originDef.length) originDef = allDef.value;
atk.data = generateData(allCri.value);
def.data = generateData(allDef.value);
atk.update('resize');
def.update('resize');
}, 200);
onMounted(() => {
const div = document.getElementById('critical-main') as HTMLDivElement;
const style = getComputedStyle(div);
const width = parseFloat(style.width);
const height = window.innerHeight / 5;
const c = critical.value!;
const d = def.value!;
setCanvasSize(c, width, height);
setCanvasSize(d, width, height);
const criChart = generateChart(c, allCri.value);
const defChart = generateChart(d, allDef.value);
watch(addAtk, n => {
update(criChart, defChart);
});
watch(addDef, n => {
update(criChart, defChart);
});
});
</script>
<style lang="less" scoped>
#critical-main {
width: 100%;
height: 50vh;
user-select: none;
position: absolute;
top: 20vh;
}
.des {
width: 100%;
text-align: center;
font-size: 140%;
}
.slider-div {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 120%;
line-height: 1;
}
}
.slider {
width: 80%;
}
#now-damage {
display: flex;
flex-direction: row;
justify-content: space-around;
font-size: 160%;
}
@media screen and (max-width: 600px) {
#now-damage {
font-size: 100%;
div {
display: flex;
flex-direction: column;
align-items: center;
}
}
.slider {
width: 60%;
}
#critical-main {
position: absolute;
top: 25vh;
width: 90vw;
}
.slider-div span {
font-size: 70%;
}
}
</style>

View File

@ -1,99 +0,0 @@
<template>
<div id="special-main">
<Scroll id="special-scroll">
<div id="special">
<component :is="info"></component>
</div>
</Scroll>
<Divider
dashed
style="margin: 2vh 0 2vh 0; border-color: #ddd4"
></Divider>
<div id="critical">
<div style="font-size: 2.5vh; width: 100%; text-align: center">
临界表
</div>
<div id="critical-main">
<div id="critical-des">
<span>加攻</span>
<span>减伤</span>
</div>
<div v-for="cri of criticals" class="critical">
<span class="critical-atk">{{ format(cri.atkDelta) }}</span>
<span>{{ format(cri.delta) }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { isMobile } from '../use';
import { detailInfo, getSpecialHint } from '../tools/book';
import Scroll from '../components/scroll.vue';
import { Divider } from 'ant-design-vue';
const props = defineProps<{
fromBook?: boolean;
}>();
const enemy = detailInfo.enemy!;
const info = getSpecialHint(enemy);
const criticals = enemy.enemy.calCritical(isMobile ? 4 : 8);
const format = core.formatBigNumber;
</script>
<style lang="less" scoped>
#special-main {
width: 100%;
user-select: none;
font-size: 160%;
position: absolute;
top: 20vh;
}
#critical-main {
display: flex;
flex-direction: row;
justify-content: space-between;
}
#critical-des,
.critical {
font-size: 110%;
display: flex;
flex-direction: column;
}
.critical-atk {
border-bottom: 1px solid #ddd4;
}
.critical {
border-left: 1px solid #ddd4;
padding-left: 1%;
}
#special-scroll {
height: 40vh;
}
@media screen and (max-width: 600px) {
#critical-des,
.critical {
font-size: 70%;
}
#special {
font-size: 70%;
}
#special-main {
top: 25vh;
width: 90vw;
}
}
</style>

View File

@ -1,12 +1 @@
import { createSetting, createUI } from './ui';
export function createPreset() {
createUI();
createSetting();
}
export * from './ui';
export * from './settings';
export * from './keyboard';
export * from './uiIns';
export * from './settingIns';

View File

@ -1,3 +0,0 @@
import { MotaSetting } from '../setting';
export const mainSetting = new MotaSetting();

View File

@ -1,199 +0,0 @@
import type { SettingComponent, SettingComponentProps } from '../setting';
import { Button, InputNumber, Radio } from 'ant-design-vue';
import { mainUi } from './uiIns';
import { gameKey } from '@motajs/system';
interface Components {
Default: SettingComponent;
Boolean: SettingComponent;
Number: SettingComponent;
HotkeySetting: SettingComponent;
ToolbarEditor: SettingComponent;
Radio: (items: string[]) => SettingComponent;
Performance: SettingComponent;
}
export type { Components as SettingDisplayComponents };
export function createSettingComponents() {
const com: Components = {
Default: DefaultSetting,
Boolean: BooleanSetting,
Number: NumberSetting,
HotkeySetting,
ToolbarEditor,
Radio: RadioSetting,
Performance: PerformanceSetting
};
return com;
}
function DefaultSetting(_props: SettingComponentProps) {
return (
<div>
<span> 未知的设置类型 </span>
</div>
);
}
function BooleanSetting(props: SettingComponentProps) {
const { setting, displayer, item } = props;
const changeValue = () => {
setting.setValue(displayer.selectStack.join('.'), !item.value);
displayer.update();
};
return (
<div class="editor-boolean">
<span>当前 {item.value ? '开启' : '关闭'}</span>
<Button
class="boolean-button"
type="primary"
size="large"
onClick={changeValue}
>
{item.value ? '关闭' : '开启'}设置
</Button>
</div>
);
}
function NumberSetting(props: SettingComponentProps) {
const { setting, displayer, item } = props;
const changeValue = (value: number) => {
if (typeof value !== 'number') return;
if (value < (item.step?.[0] ?? 0) || value > (item.step?.[1] ?? 100)) {
return;
}
setting.setValue(displayer.selectStack.join('.'), Math.round(value));
displayer.update();
};
return (
<div class="editor-number">
<div class="editor-number-input">
<span> 修改设置: </span>
<InputNumber
class="number-input"
size="large"
// keyboard={true}
min={item.step?.[0] ?? 0}
max={item.step?.[1] ?? 100}
step={item.step?.[2] ?? 1}
value={item.value as number}
onChange={value => changeValue(value as number)}
></InputNumber>
</div>
<div class="editor-number-button">
<Button
class="number-button"
type="primary"
onClick={() =>
changeValue(
(item.value as number) - (item.step?.[2] ?? 1)
)
}
>
减少
</Button>
<Button
class="number-button"
type="primary"
onClick={() =>
changeValue(
(item.value as number) + (item.step?.[2] ?? 1)
)
}
>
增加
</Button>
</div>
</div>
);
}
function RadioSetting(items: string[]) {
return (props: SettingComponentProps) => {
const { setting, displayer, item } = props;
const changeValue = (value: number) => {
if (isNaN(value)) return;
setting.setValue(displayer.selectStack.join('.'), value);
displayer.update();
};
return (
<div>
{items.map((v, i) => {
return (
<Radio
value={i}
checked={i === item.value}
onClick={() => changeValue(i)}
>
{v}
</Radio>
);
})}
</div>
);
};
}
function showSpecialSetting(id: string, vBind?: any) {
const ui = mainUi.get(id);
mainUi.showEnd();
ui.once('close', () => {
mainUi.showAll();
});
mainUi.open(id, vBind);
}
function HotkeySetting(_props: SettingComponentProps) {
return (
<div style="display: flex; justify-content: center">
<Button
style="font-size: 75%"
type="primary"
size="large"
onClick={() =>
showSpecialSetting('hotkey', {
hotkey: gameKey
})
}
>
快捷键设置
</Button>
</div>
);
}
function ToolbarEditor(_props: SettingComponentProps) {
return (
<div style="display: flex; justify-content: center">
<Button
style="font-size: 75%"
type="primary"
size="large"
onClick={() => showSpecialSetting('toolEditor')}
>
自定义工具栏
</Button>
</div>
);
}
function PerformanceSetting(_props: SettingComponentProps) {
return (
<div style="display: flex; justify-content: center">
<Button
style="font-size: 75%"
type="primary"
size="large"
onClick={() => showSpecialSetting('performance')}
>
打开性能界面
</Button>
</div>
);
}

View File

@ -1,251 +0,0 @@
import { GameStorage } from '@motajs/legacy-system';
import { createSettingComponents } from './settings';
import { isMobile } from '../use';
import { MotaSetting } from '../setting';
import { triggerFullscreen } from '../utils';
import settingsText from '../data/settings.json';
import { mainUi } from './uiIns';
import { mainSetting } from './settingIns';
//#region legacy-ui
export function createUI() {
const { hook } = Mota.require('@user/data-base');
hook.once('mounted', () => {
const ui = document.getElementById('ui-main')!;
const blur = mainSetting.getSetting('screen.blur');
mainUi.on('start', () => {
ui.style.display = 'flex';
if (blur?.value) {
ui.style.backdropFilter = 'blur(5px)';
ui.style.backgroundColor = 'rgba(0,0,0,0.7333)';
} else {
ui.style.backdropFilter = 'none';
ui.style.backgroundColor = 'rgba(0,0,0,0.85)';
}
core.lockControl();
});
mainUi.on('end', noClosePanel => {
ui.style.display = 'none';
if (!noClosePanel) {
core.closePanel();
}
});
});
}
//#endregion
//#region legacy-setting
const COM = createSettingComponents();
// 添加不参与全局存储的设置
MotaSetting.noStorage.push('action.autoSkill', 'screen.fullscreen');
const storage = new GameStorage(GameStorage.fromAuthor('AncTe', 'setting'));
export { storage as settingStorage };
// ----- 监听设置修改
mainSetting.on('valueChange', (key, n, o) => {
if (!MotaSetting.noStorage.includes(key)) {
storage.setValue(key, n);
}
const [root, setting] = key.split('.');
if (root === 'screen') {
handleScreenSetting(setting, n, o);
} else if (root === 'audio') {
handleAudioSetting(setting, n, o);
} else if (root === 'ui') {
handleUiSetting(setting, n, o);
}
});
const root = document.getElementById('root') as HTMLDivElement;
function handleScreenSetting<T extends number | boolean>(
key: string,
n: T,
_o: T
) {
if (key === 'fullscreen') {
// 全屏
triggerFullscreen(n as boolean);
} else if (key === 'fontSize') {
// 字体大小
root.style.fontSize = `${n}px`;
const absoluteSize = (n as number) * devicePixelRatio;
storage.setValue('@@absoluteFontSize', absoluteSize);
storage.write();
} else if (key === 'scale') {
const { MAIN_HEIGHT, MAIN_WIDTH } = Mota.require(
'@user/client-modules'
);
const max = Math.min(
(window.innerHeight / MAIN_HEIGHT) * 100,
(window.innerWidth / MAIN_WIDTH) * 100,
n as number
);
const scale = Number((Math.floor((max / 100) * 4) / 4).toFixed(2));
// @ts-expect-error 遗留问题
core.domStyle.scale = scale;
Mota.require('@user/client-modules').mainRenderer.setScale(scale);
}
}
function handleAudioSetting<T extends number | boolean>(
key: string,
n: T,
_o: T
) {
const { client } = Mota.require('@user/client-modules');
const { bgmPlayer, soundPlayer } = client;
if (key === 'bgmEnabled') {
bgmPlayer.setEnabled(n as boolean);
core.checkBgm();
} else if (key === 'bgmVolume') {
bgmPlayer.setVolume((n as number) / 100);
} else if (key === 'soundEnabled') {
soundPlayer.setEnabled(n as boolean);
} else if (key === 'soundVolume') {
soundPlayer.setVolume((n as number) / 100);
}
}
function handleUiSetting<T extends number | boolean>(
_key: string,
_n: T,
_o: T
) {}
// ----- 游戏的所有设置项
mainSetting
.register(
'screen',
'显示设置',
new MotaSetting()
.register('fullscreen', '全屏游戏', false, COM.Boolean)
.register('scale', '画面缩放', 100, COM.Number, [50, 500, 25])
.setDisplayFunc('scale', value => `${value}%`)
.register('itemDetail', '宝石血瓶显伤', true, COM.Boolean)
.register('transition', '界面动画', false, COM.Boolean)
.register('fontSize', '字体大小', 16, COM.Number, [2, 48, 1])
.register('criticalGem', '临界显示方式', false, COM.Boolean)
.setDisplayFunc('criticalGem', value => (value ? '宝石数' : '攻击'))
.register('keyScale', '虚拟键盘缩放', 100, COM.Number, [25, 5, 500])
.register('blur', '背景虚化', !isMobile, COM.Boolean)
)
.register(
'action',
'操作设置',
new MotaSetting()
.register('hotkey', '快捷键', false, COM.HotkeySetting)
.setDisplayFunc('hotkey', () => '')
)
.register(
'audio',
'音频设置',
new MotaSetting()
.register('bgmEnabled', '开启音乐', true, COM.Boolean)
.register('bgmVolume', '音乐音量', 80, COM.Number, [0, 100, 5])
.register('soundEnabled', '开启音效', true, COM.Boolean)
.register('soundVolume', '音效音量', 80, COM.Number, [0, 100, 5])
)
.register(
'utils',
'系统设置',
new MotaSetting().register('autoScale', '自动放缩', true, COM.Boolean)
)
.register(
'ui',
'ui设置',
new MotaSetting()
.register(
'bookScale',
'怪物手册缩放',
100,
COM.Number,
[10, 500, 10]
)
.setDisplayFunc('bookScale', value => `${value}%`)
);
interface SettingTextData {
[x: string]: string[] | SettingTextData;
}
mainSetting
.setDescription('audio.bgmEnabled', `是否开启背景音乐`)
.setDescription('audio.bgmVolume', `背景音乐的音量`)
.setDescription('audio.soundEnabled', `是否开启音效`)
.setDescription('audio.soundVolume', `音效的音量`)
.setDescription(
'ui.bookScale',
`怪物手册界面中每个怪物框体的高度缩放,最小值限定为 20% 屏幕高度`
)
.setDescription(
'screen.blur',
'打开任意ui界面时是否有背景虚化效果,移动端打开后可能会有掉帧或者发热现象。关闭ui后生效'
);
function setFontSize() {
const absoluteSize = storage.getValue(
'@@absoluteFontSize',
16 * devicePixelRatio
);
const size = Math.round(absoluteSize / devicePixelRatio);
mainSetting.setValue('screen.fontSize', size);
}
setFontSize();
function getSettingText(obj: SettingTextData, key?: string) {
for (const [k, value] of Object.entries(obj)) {
const setKey = key ? key + '.' + k : k;
if (value instanceof Array) {
mainSetting.setDescription(setKey, value.join('\n'));
} else {
getSettingText(value, setKey);
}
}
}
getSettingText(settingsText);
window.addEventListener('resize', () => {
setFontSize();
});
export function createSetting() {
const { loading } = Mota.require('@user/data-base');
loading.once('coreInit', () => {
mainSetting.reset({
'screen.fullscreen': !!document.fullscreenElement,
'screen.scale': storage.getValue('screen.scale', 100),
'screen.itemDetail': !!storage.getValue('screen.itemDetail', true),
'screen.transition': !!storage.getValue('screen.transition', false),
'screen.fontSize': storage.getValue(
'screen.fontSize',
isMobile ? 9 : 16
),
'screen.criticalGem': !!storage.getValue(
'screen.criticalGem',
false
),
'audio.bgmEnabled': !!storage.getValue('audio.bgmEnabled', true),
'audio.bgmVolume': storage.getValue('audio.bgmVolume', 80),
'audio.soundEnabled': !!storage.getValue(
'audio.soundEnabled',
true
),
'audio.soundVolume': storage.getValue('audio.soundVolume', 80),
'utils.autoScale': !!storage.getValue('utils.autoScale', true),
'ui.bookScale': storage.getValue(
'ui.bookScale',
isMobile ? 100 : 80
)
});
});
}

View File

@ -1,15 +0,0 @@
import { GameUi, UiController } from '../controller';
import * as UI from '../ui';
import { VirtualKey } from '@motajs/legacy-system';
export const mainUi = new UiController();
mainUi.register(
new GameUi('toolbox', UI.Toolbox),
new GameUi('equipbox', UI.Equipbox),
new GameUi('settings', UI.Settings),
new GameUi('fly', UI.Fly),
new GameUi('shop', UI.Shop),
new GameUi('hotkey', UI.Hotkey),
new GameUi('virtualKey', VirtualKey)
);
mainUi.showAll();

View File

@ -1,305 +0,0 @@
import { FunctionalComponent, reactive } from 'vue';
import { EventEmitter } from '@motajs/legacy-common';
import { isNil } from 'lodash-es';
export interface SettingComponentProps {
item: MotaSettingItem;
setting: MotaSetting;
displayer: SettingDisplayer;
}
export type SettingComponent = FunctionalComponent<SettingComponentProps>;
type MotaSettingType = boolean | number | MotaSetting;
export interface MotaSettingItem<T extends MotaSettingType = MotaSettingType> {
name: string;
key: string;
value: T;
controller: SettingComponent;
description?: string;
defaults?: boolean | number;
step?: [number, number, number];
display?: (value: T) => string;
}
interface SettingEvent {
valueChange: <T extends boolean | number>(
key: string,
newValue: T,
oldValue: T
) => void;
}
export type SettingText = {
[key: string]: string[] | SettingText;
};
export interface SettingDisplayInfo {
item: MotaSettingItem | null;
list: Record<string, MotaSettingItem>;
text: string[];
}
export class MotaSetting extends EventEmitter<SettingEvent> {
static noStorage: string[] = [];
readonly list: Record<string, MotaSettingItem> = {};
/**
* 重设设置
* @param setting 设置信息
*/
reset(setting: Record<string, boolean | number>) {
for (const [key, value] of Object.entries(setting)) {
this.setValue(key, value);
}
}
/**
* 注册一个数字型设置
* @param key 设置的键名
* @param value 设置的值
*/
register(
key: string,
name: string,
value: number,
com?: SettingComponent,
step?: [number, number, number]
): this;
/**
* 注册一个非数字型设置
* @param key 设置的键名
* @param value 设置的值
*/
register(
key: string,
name: string,
value: boolean | MotaSetting,
com?: SettingComponent
): this;
register(
key: string,
name: string,
value: MotaSettingType,
com: SettingComponent,
step: [number, number, number] = [0, 100, 1]
) {
const setting: MotaSettingItem = {
name,
value,
key,
controller: com
};
if (!(value instanceof MotaSetting)) setting.defaults = value;
if (typeof value === 'number') setting.step = step;
this.list[key] = setting;
return this;
}
/**
* 获取一个设置信息
* @param key 要获取的设置的键
*/
getSetting(key: string): Readonly<MotaSettingItem | null> {
const list = key.split('.');
return this.getSettingBy(list);
}
/**
* 设置一个设置的值
* @param key 要设置的设置的键
* @param value 要设置的值
*/
setValue(key: string, value: boolean | number) {
const setting = this.getSettingBy(key.split('.'));
if (typeof setting.value !== typeof value) {
throw new Error(
`Setting type mismatch on setting '${key}'.` +
`Expected: ${typeof setting.value}. Recieve: ${typeof value}`
);
}
const old = setting.value as boolean | number;
setting.value = value;
this.emit('valueChange', key, value, old);
}
/**
* 增加一个设置的值
* @param key 要改变的设置的值
* @param value 值的增量
*/
addValue(key: string, value: number) {
const setting = this.getSettingBy(key.split('.'));
if (typeof setting.value !== 'number') {
throw new Error(
`Cannot execute addValue method on a non-number setting.` +
`Type expected: number. See: ${typeof setting.value}`
);
}
const old = setting.value as boolean | number;
setting.value += value;
this.emit('valueChange', key, old, value);
}
/**
* 获取一个设置的值,如果获取到的是一个MotaSetting实例,那么返回undefined
* @param key 要获取的设置
*/
getValue(key: string): boolean | number | undefined;
/**
* 获取一个设置的值,如果获取到的是一个MotaSetting实例,那么返回defaultValue
* @param key 要获取的设置
* @param defaultValue 设置的默认值
*/
getValue<T extends boolean | number>(key: string, defaultValue: T): T;
getValue<T extends boolean | number>(
key: string,
defaultValue?: T
): T | undefined {
const setting = this.getSetting(key);
if (isNil(setting) && isNil(defaultValue)) return void 0;
if (setting instanceof MotaSetting) {
if (!isNil(setting)) return defaultValue;
return void 0;
} else {
return !isNil(setting) ? (setting.value as T) : (defaultValue as T);
}
}
/**
* 设置一个设置的值显示函数
* @param key 要设置的设置的键
* @param func 显示函数
*/
setDisplayFunc(key: string, func: (value: MotaSettingType) => string) {
const setting = this.getSettingBy(key.split('.'));
setting.display = func;
return this;
}
/**
* 设置一个设置的修改部分组件
* @param key 要设置的设置的键
* @param com 设置修改部分的组件
*/
setValueController(key: string, com: SettingComponent) {
const setting = this.getSettingBy(key.split('.'));
setting.controller = com;
return this;
}
/**
* 设置一个设置的说明
* @param key 要设置的设置的id
* @param desc 设置的说明
*/
setDescription(key: string, desc: string) {
const setting = this.getSettingBy(key.split('.'));
setting.description = desc;
return this;
}
private getSettingBy(list: string[]) {
let now: MotaSetting = this;
for (let i = 0; i < list.length - 1; i++) {
const item = now.list[list[i]].value;
if (!(item instanceof MotaSetting)) {
throw new Error(
`Cannot get setting. The parent isn't a MotaSetting instance.` +
`Key: '${list.join('.')}'. Reading: '${list[i]}'`
);
}
now = item;
}
return now.list[list.at(-1)!] ?? null;
}
}
interface SettingDisplayerEvent {
update: (stack: string[], display: SettingDisplayInfo[]) => void;
}
export class SettingDisplayer extends EventEmitter<SettingDisplayerEvent> {
setting: MotaSetting;
/** 选项选中栈 */
selectStack: string[] = [];
displayInfo: SettingDisplayInfo[] = reactive([]);
constructor(setting: MotaSetting) {
super();
this.setting = setting;
this.update();
}
/**
* 添加选择项
* @param key 下一个选择项
*/
add(key: string) {
this.selectStack.push(...key.split('.'));
this.update();
}
/**
* 剪切后面的选择项
* @param index 从哪开始剪切
*/
cut(index: number, noUpdate: boolean = false) {
this.selectStack.splice(index, Infinity);
if (!noUpdate) this.update();
}
update() {
const list = this.selectStack;
let now = this.setting;
this.displayInfo = [];
for (let i = 0; i < list.length - 1; i++) {
const item = now.list[list[i]].value;
if (!(item instanceof MotaSetting)) {
throw new Error(
`Cannot get setting. The parent isn't a MotaSetting instance.` +
`Key: '${list.join('.')}'. Reading: '${list[i + 1]}'`
);
}
this.displayInfo.push({
item: now.list[list[i]],
text: [],
list: now.list
});
now = item;
}
const last = now.list[list.at(-1)!];
if (last) {
const desc = last.description;
const text = desc ? desc.split('\n') : ['请选择设置'];
this.displayInfo.push({
item: last,
text,
list: now.list
});
if (last.value instanceof MotaSetting) {
this.displayInfo.push({
item: null,
text: ['请选择设置'],
list: (last.value as MotaSetting).list
});
}
} else {
this.displayInfo.push({
item: null,
text: ['请选择设置'],
list: this.setting.list
});
}
this.emit('update', this.selectStack, this.displayInfo);
}
}

View File

@ -1,129 +0,0 @@
import { isNil } from 'lodash-es';
import { IDamageEnemy } from '@motajs/types';
export interface CurrentEnemy {
enemy: IDamageEnemy;
// 这个是干啥的?
onMapEnemy: IDamageEnemy[];
}
export interface ToShowEnemy extends CurrentEnemy {
critical: string;
criticalDam: string;
defDam: string;
/** [名称, 描述, 颜色] */
special: [string, string, string][];
damageColor: string;
showSpecial: [string, string, string][];
damage: string;
}
interface BookDetailInfo {
/** 怪物手册详细信息展示的怪物 */
enemy?: ToShowEnemy;
/** 怪物手册的怪物详细信息的初始位置 */
pos?: number;
}
export const detailInfo: BookDetailInfo = {};
/**
* 获取怪物的特殊技能描述
* @param enemy 怪物实例
*/
export function getSpecialHint(enemy: ToShowEnemy) {
return (
<div>
{enemy.special.map((v, i) => {
return (
<div class="special">
<span style={{ color: v[2] }}>
&nbsp;&nbsp;&nbsp;&nbsp;{v[0]}:
</span>
<span innerHTML={v[1]}></span>
</div>
);
})}
</div>
);
}
/**
* 获得怪物的最近100个加防减伤
* @param enemy 怪物实例
*/
export function getDefDamage(
enemy: ToShowEnemy,
addDef: number = 0,
addAtk: number = 0
) {
const ratio = core.status.thisMap.ratio;
const res: [number, number][] = [];
let origin: number | undefined;
let last = 0;
const max = 100 - Math.floor(addDef / ratio);
for (let i = 0; i <= max; i++) {
const dam = enemy.enemy.calDamage({
atk: core.status.hero.atk + addAtk,
def: core.status.hero.def + addDef + i * ratio
});
if (res.length === 0) {
origin = dam.damage;
if (!isNil(origin)) {
res.push([addDef + i * ratio, origin]);
last = origin;
}
continue;
}
if (!isFinite(dam.damage)) continue;
if (dam.damage === res.at(-1)?.[1]) continue;
last = dam.damage;
res.push([ratio * i + addDef, dam.damage]);
}
return res;
}
/**
* 获取怪物的临界信息
* @param enemy 怪物实例
*/
export function getCriticalDamage(
enemy: ToShowEnemy,
addAtk: number = 0,
addDef: number = 0
): [number, number][] {
const ratio = core.status.thisMap.ratio;
const res: [number, number][] = [];
let origin: number | undefined;
let last = 0;
const max = 100 - Math.floor(addAtk / ratio);
for (let i = 0; i <= max; i++) {
const dam = enemy.enemy.calDamage({
atk: core.status.hero.atk + addAtk + i * ratio,
def: core.status.hero.def + addDef
});
if (res.length === 0) {
origin = dam.damage;
if (!isNil(origin)) {
res.push([addAtk + i * ratio, origin]);
last = origin;
}
continue;
}
if (!isFinite(dam.damage)) continue;
if (dam.damage === res.at(-1)?.[1]) continue;
last = dam.damage;
res.push([ratio * i + addAtk, dam.damage]);
}
return res;
}

View File

@ -1,36 +0,0 @@
import { Ref, ref, watch } from 'vue';
import { nextFrame } from '../utils';
interface ChangableValue<T> {
change: Ref<boolean>;
value: Ref<T>;
stop: () => void;
}
/**
* 创建一个监听响应式变量更改的,可以用于Changable的监听器
* @param value 要监听的值
*/
export function createChangable<T>(
value: Ref<T>,
key?: keyof T
): ChangableValue<T> {
const change = ref(false);
const stop = watch(value, (n, o) => {
if (key) {
if (n[key] === o[key]) return;
}
if (change.value) {
change.value = false;
nextFrame(() => (change.value = true));
} else {
change.value = true;
}
});
return {
change,
value,
stop
};
}

View File

@ -1,113 +0,0 @@
import { isNil } from 'lodash-es';
import { getStatusLabel } from '../utils';
/**
* 获取所有装备
*/
export function getEquips(): [ItemIdOf<'equips'>, number][] {
return Object.entries(core.status.hero.items.equips) as [
ItemIdOf<'equips'>,
number
][];
}
/**
* 获取装备增加的属性
* @param equip 装备
*/
export function getAddStatus(equip: Equip) {
const toGet = Object.assign({}, equip.value, equip.percentage);
const keys = Object.keys(toGet) as (keyof typeof toGet)[];
return (
<div class="equip-add-detail">
{keys.map(v => {
const value = Math.floor(
(equip.value[v] ?? 0) * core.getBuff(v) +
(core.status.hero[v as keyof HeroStatus] *
(equip.percentage?.[v] ?? 0)) /
100
);
return (
<span style="display: flex">
<span style="flex-basis: 50%">{getStatusLabel(v)}</span>
&nbsp;&nbsp;&nbsp;&nbsp;
<span
style={{
color: value > 0 ? 'lightgreen' : 'lightsalmon'
}}
>
{value > 0 ? `+${value}` : value}
</span>
</span>
);
})}
</div>
);
}
/**
* 获取当前勇士属性,如果有选中的装备,会在后面追加显示增加量
* @param nowEquip 当前选中的装备
*/
export function getNowStatus(nowEquip?: Equip, onCol: boolean = false) {
const toShow = [
'hp',
'lv',
'atk',
'def',
'mdef',
'mana',
'hpmax',
'money'
] as (keyof SelectType<HeroStatus, number>)[];
// @ts-expect-error 需要重构
const { getHeroStatusOn } = Mota.require('@user/data-state');
return (
<div id="hero-status">
{toShow.map(v => {
let status: string;
if (v === 'lv') status = core.getLvName() ?? '';
else status = getHeroStatusOn(v)?.toString();
let add = 0;
if (!isNil(nowEquip)) {
add += Math.floor(
(nowEquip.value[v] ?? 0) * core.getBuff(v)
);
const per = Math.floor(
((nowEquip.percentage?.[v] ?? 0) * core.getStatus(v)) /
100
);
add += isNaN(per) ? 0 : per;
}
if (onCol) add = -add;
return (
<div class="hero-status-one">
<span class="hero-status-label">
{getStatusLabel(v)}
</span>
<div class="hero-status-value">
<span style="margin-right: 20%">{status}</span>
{add !== 0 && (
<span
style={{
color:
add > 0
? 'lightgreen'
: 'lightsalmon'
}}
>
{add > 0 ? '+' + add : '-' + -add}
</span>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@ -1,63 +0,0 @@
import { getDamageColor } from '../utils';
import { ToShowEnemy } from './book';
import { isMobile } from '../use';
import { EnemyInfo, IDamageEnemy } from '@motajs/types';
export function getLocFromMouseLoc(x: number, y: number): LocArr {
const mx = Math.round(x + core.bigmap.offsetX / 32);
const my = Math.round(y + core.bigmap.offsetY / 32);
return [mx, my];
}
export function getDetailedEnemy(
enemy: IDamageEnemy,
floorId: FloorIds = core.status.floorId
): ToShowEnemy {
const ratio = core.status.maps[floorId].ratio;
const dam = enemy.calDamage().damage;
const cri = enemy.calCritical(1)[0];
const critical = core.formatBigNumber(cri?.atkDelta);
const criticalDam = core.formatBigNumber(cri?.delta);
const defDam = core.formatBigNumber(enemy.calDefDamage(ratio).delta);
const damage = core.formatBigNumber(dam);
const fromFunc = (
func: string | ((enemy: EnemyInfo) => string),
enemy: EnemyInfo
) => {
return typeof func === 'string' ? func : func(enemy);
};
const special: [string, string, string][] = [...enemy.info.special]
// @ts-expect-error 之后修
.filter(v => !enemy.info.specialHalo?.includes(v))
.map(vv => {
// @ts-expect-error 之后修
const s = Mota.require('@user/data-state').specials[vv];
return [
fromFunc(s.name, enemy.info),
fromFunc(s.desc, enemy.info),
s.color as string
];
});
const l = isMobile ? 1 : 2;
const showSpecial =
special.length > l
? special.slice(0, l).concat([['...', '', '#fff']])
: special.slice();
const damageColor = getDamageColor(dam) as string;
const detail: ToShowEnemy = {
enemy,
onMapEnemy: [enemy],
critical,
criticalDam,
defDam,
damageColor,
special,
showSpecial,
damage
};
return detail;
}

View File

@ -1,568 +0,0 @@
import { isNil } from 'lodash-es';
import { mainSetting } from '../preset/settingIns';
import { tip } from '../use';
import { downloadCanvasImage } from '../utils';
type BFSFromString = `${FloorIds},${number},${number},${Dir}`;
type BFSToString = `${FloorIds},${number},${number}`;
interface MapBFSResult {
maps: FloorIds[];
link: Record<BFSFromString, BFSToString>;
}
interface MapDrawData {
locs: Partial<Record<FloorIds, LocArr>>;
line: [number, number, number, number][];
width: number;
height: number;
}
let area: Record<string, FloorIds[]> = {};
const bfsCache: Partial<Record<FloorIds, MapBFSResult>> = {};
/**
* 键的格式:FloorIds,interval,border
*/
const drawCache: Record<string, MapDrawData> = {};
const arrow: Partial<Record<AllNumbers, Dir>> = {
92: 'left',
94: 'right',
91: 'up',
93: 'down'
};
/**
* 切分地图区域
*/
export function splitArea() {
area = {};
const used: FloorIds[] = [];
for (const id of core.floorIds) {
if (used.includes(id) || core.status.maps[id].deleted) continue;
const data = getMapData(id, true);
used.push(...data.maps);
if (data.maps.length > 1) {
const title = core.status.maps[id].title;
area[title] = data.maps;
}
}
}
export function getArea() {
return area;
}
/**
* 获取地图绘制信息
* @param floorId 中心楼层
* @param interval 地图间距
* @param border 边框宽度
* @param noCache 是否不使用缓存
*/
export function getMapDrawData(
floorId: FloorIds,
interval: number = 5,
border: number = 1,
noCache: boolean = false
): MapDrawData {
const id = `${floorId},${interval},${border}`;
if (!floorId) return { locs: {}, line: [], width: 1, height: 1 };
if (drawCache[id] && !noCache) return drawCache[id];
const { link, maps } = getMapData(floorId, noCache);
const locs: Partial<Record<FloorIds, LocArr>> = {};
const line: [number, number, number, number][] = [];
const center = core.status.maps[floorId];
let left = -center.width / 2,
right = center.width / 2,
top = -center.height / 2,
bottom = center.height / 2;
for (const [from, to] of Object.entries(link)) {
const [fromId, fxs, fys, dir] = from.split(',') as [
FloorIds,
string,
string,
Dir
];
const [toId, txs, tys] = to.split(',') as [FloorIds, string, string];
const fromMap = core.status.maps[fromId];
const toMap = core.status.maps[toId];
const fx = parseInt(fxs),
fy = parseInt(fys),
tx = parseInt(txs),
ty = parseInt(tys);
const fw = fromMap.width,
fh = fromMap.height;
const tw = toMap.width,
th = toMap.height;
locs[fromId] ??= [0, 0];
const [fromX, fromY] = locs[fromId]!;
if (!locs[toId]) {
const dx = core.utils.scan[dir].x,
dy = core.utils.scan[dir].y;
const toX =
fromX +
(fx - fw / 2) -
(tx - tw / 2) +
(border * 2 + interval) * dx,
toY =
fromY +
(fy - fh / 2) -
(ty - th / 2) +
(border * 2 + interval) * dy;
// 地图位置和连线位置
locs[toId] = [toX, toY];
}
const [toX, toY] = locs[toId]!;
line.push([
fromX + (fx - fw / 2 + 0.5),
fromY + (fy - fh / 2 + 0.5),
toX + (tx - tw / 2 + 0.5),
toY + (ty - th / 2 + 0.5)
]);
// 计算地图总长宽
const l = toX - tw / 2,
r = toX + tw / 2,
t = toY - th / 2,
b = toY + th / 2;
if (l < left) left = l;
if (r > right) right = r;
if (t < top) top = t;
if (b > bottom) bottom = b;
}
// 移动位置,居中
Object.values(locs).forEach(v => {
v[0] -= left;
v[1] -= top;
});
line.forEach(v => {
v[0] -= left;
v[2] -= left;
v[1] -= top;
v[3] -= top;
});
left -= 5;
right += 5;
top -= 5;
bottom += 5;
const res = { locs, line, width: right - left, height: bottom - top };
return (drawCache[id] = res);
}
/**
* 广度优先搜索地图信息
* @param floorId 中心楼层id
* @param noCache 是否不使用缓存
*/
export function getMapData(
floorId: FloorIds,
noCache: boolean = false
): MapBFSResult {
if (!floorId) return { maps: [], link: {} };
if (!isNil(bfsCache[floorId]) && !noCache) return bfsCache[floorId]!;
const queue = [floorId];
const used: Partial<Record<FloorIds, boolean>> = {
[floorId]: true
};
const floors = [floorId];
const link: Record<BFSFromString, BFSToString> = {};
while (queue.length > 0) {
const now = queue.shift()!;
const floor = core.floors[now];
const change = floor.changeFloor;
for (const [loc, ev] of Object.entries(change)) {
if (!ev) continue;
const target = ev.floorId as FloorIds;
if (target.startsWith(':')) continue;
const [x, y] = loc.split(',').map(v => parseInt(v));
const id = floor.map[y][x] as AllNumbers;
if (id in arrow) {
if (!used[target]) {
const from = `${now},${loc},${arrow[id]}` as BFSFromString;
const to = `${target},${ev.loc![0]},${
ev.loc![1]
}` as BFSToString;
link[from] = to;
queue.push(target);
floors.push(target);
}
}
}
used[now] = true;
}
const res = {
maps: floors,
link
};
return (bfsCache[floorId] = res);
}
type Loc2 = [number, number, number, number];
export class MinimapDrawer {
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
scale: number = 1;
nowFloor: FloorIds = core.status.floorId;
nowArea: string = '';
// position & config
ox: number = 0;
oy: number = 0;
noBorder: boolean = false;
showInfo: boolean = false;
// cache
drawedThumbnail: Partial<Record<FloorIds, boolean>> = {};
thumbnailLoc: Partial<Record<FloorIds, Loc2>> = {};
// temp
private tempCanvas: HTMLCanvasElement = document.createElement('canvas');
private tempCtx: CanvasRenderingContext2D;
private downloadMode: boolean = false;
private innerRatio: number = 1;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.tempCtx = this.tempCanvas.getContext('2d')!;
this.innerRatio = mainSetting.getValue('ui.mapScale', 100) / 100;
this.scale *= this.innerRatio;
}
link(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
}
clearCache() {
this.drawedThumbnail = {};
this.thumbnailLoc = {};
}
/**
* 绘制小地图
* @param noCache 是否不使用缓存
*/
drawMap(noCache: boolean = false) {
const border = this.noBorder ? 0.5 : 1;
const data = getMapDrawData(
this.nowFloor,
this.noBorder ? 0 : 5,
border,
noCache
);
const temp = this.tempCanvas;
const ctx = this.tempCtx;
const s = this.scale * devicePixelRatio;
temp.width = data.width * s;
temp.height = data.height * s;
ctx.lineWidth = (border * devicePixelRatio) / 2;
ctx.strokeStyle = '#fff';
ctx.scale(s, s);
ctx.translate(5, 5);
if (!this.noBorder) {
// 绘制连线
data.line.forEach(([x1, y1, x2, y2]) => {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
});
}
// 绘制地图及缩略图
for (const [id, [x, y]] of Object.entries(data.locs) as [
FloorIds,
LocArr
][]) {
if (!this.noBorder) this.drawBorder(id, x, y);
this.drawThumbnail(id, x, y);
}
this.drawToTarget();
}
/**
* 绘制一个楼层的边框
*/
drawBorder(id: FloorIds, x: number, y: number) {
const border = this.noBorder ? 0.5 : 1;
const ctx = this.tempCtx;
ctx.lineWidth = border * devicePixelRatio;
const map = core.status.maps[id];
if (!core.hasVisitedFloor(id)) {
ctx.fillStyle = '#d0d';
} else {
ctx.fillStyle = '#000';
}
if (id === this.nowFloor) {
ctx.strokeStyle = 'gold';
} else {
ctx.strokeStyle = '#fff';
}
ctx.strokeRect(
x - map.width / 2,
y - map.height / 2,
map.width,
map.height
);
ctx.fillRect(
x - map.width / 2,
y - map.height / 2,
map.width,
map.height
);
if (id === this.nowFloor) {
ctx.fillStyle = '#ff04';
ctx.fillRect(
x - map.width / 2,
y - map.height / 2,
map.width,
map.height
);
}
if (!this.noBorder && this.scale * this.innerRatio <= 7)
this.drawEnemy(ctx, id, x, y);
}
/**
* 将临时画布的内容绘制到目标画布上
*/
drawToTarget() {
const mapCtx = this.ctx;
const map = this.canvas;
const temp = this.tempCanvas;
mapCtx.clearRect(0, 0, map.width, map.height);
mapCtx.drawImage(
temp,
0,
0,
temp.width,
temp.height,
this.ox * devicePixelRatio + (map.width - temp.width) / 2,
this.oy * devicePixelRatio + (map.height - temp.height) / 2,
temp.width,
temp.height
);
}
/**
* 检查是否应该绘制缩略图
*/
checkThumbnail(floorId: FloorIds, x: number, y: number) {
const scale = this.scale;
const ox = this.ox;
const oy = this.oy;
const map = this.canvas;
const temp = this.tempCanvas;
const floor = core.status.maps[floorId];
const s = scale * devicePixelRatio;
const px = ox * devicePixelRatio + (map.width - temp.width) / 2 + 5 * s;
const py =
oy * devicePixelRatio + (map.height - temp.height) / 2 + 5 * s;
const left = px + (x - floor.width / 2) * s;
const top = py + (y - floor.height / 2) * s;
const right = left + floor.width * s;
const bottom = top + floor.height * s;
this.thumbnailLoc[floorId] = [left, top, right, bottom];
if (
this.drawedThumbnail[floorId] ||
(!this.noBorder && scale * this.innerRatio <= 7) ||
right < 0 ||
bottom < 0 ||
left > map.width ||
top > map.height
)
return false;
return true;
}
/**
* 绘制缩略图
*/
drawThumbnail(
floorId: FloorIds,
x: number,
y: number,
noCheck: boolean = false
) {
if (
!this.downloadMode &&
!noCheck &&
!this.checkThumbnail(floorId, x, y)
)
return;
const floor = core.status.maps[floorId];
this.drawedThumbnail[floorId] = true;
// 绘制缩略图
const ctx = this.tempCtx;
core.drawThumbnail(floorId, void 0, {
all: true,
inFlyMap: true,
x: x - floor.width / 2,
y: y - floor.height / 2,
w: floor.width,
h: floor.height,
ctx,
damage: this.scale * this.innerRatio > 15
});
if (!this.downloadMode) {
if (!core.hasVisitedFloor(floorId)) {
ctx.fillStyle = '#d0d6';
ctx.fillRect(
x - floor.width / 2,
y - floor.height / 2,
floor.width,
floor.height
);
ctx.fillStyle = '#000';
}
if (this.nowFloor === floorId) {
ctx.fillStyle = '#ff04';
ctx.fillRect(
x - floor.width / 2,
y - floor.height / 2,
floor.width,
floor.height
);
ctx.fillStyle = '#000';
}
}
this.drawEnemy(ctx, floorId, x, y);
}
/**
* 当移动时检查是否应该绘制缩略图
*/
checkMoveThumbnail() {
const border = this.noBorder ? 0.5 : 1;
const data = getMapDrawData(
this.nowFloor,
this.noBorder ? 0 : 5,
border
);
for (const [id, [x, y]] of Object.entries(data.locs) as [
FloorIds,
LocArr
][]) {
if (this.checkThumbnail(id, x, y)) {
this.drawThumbnail(id, x, y, true);
}
}
}
download() {
if (this.nowArea === '') {
tip('error', '当前地图不在任意一个区域内!');
return;
}
this.downloadMode = true;
const before = this.scale;
this.scale = 32;
this.drawMap();
downloadCanvasImage(this.tempCanvas, this.nowArea);
this.scale = before;
this.downloadMode = false;
tip('success', '图片下载成功!');
}
/**
* 居中地图
* @param id 楼层id
*/
locateMap(id: FloorIds) {
const data = getMapDrawData(
id,
this.noBorder ? 0 : 5, // 可恶的0和5,写反了找一个多小时
this.noBorder ? 0.5 : 1
);
if (!data.locs[id]) return;
const [x, y] = data.locs[id]!;
this.ox = (-x + data.width / 2 - 5) * this.scale;
this.oy = (-y + data.height / 2 - 5) * this.scale;
}
private drawEnemy(
ctx: CanvasRenderingContext2D,
floorId: FloorIds,
x: number,
y: number
) {
if (
this.scale * this.innerRatio > 2 &&
this.scale * this.innerRatio < 40 &&
!this.downloadMode &&
this.showInfo
) {
ctx.save();
ctx.lineWidth = 0.5;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `3px "normal"`;
ctx.strokeStyle = 'black';
// Mota.require('@user/data-state').ensureFloorDamage(floorId);
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(x - 6, y - 2, 12, 4);
ctx.fillStyle = 'white';
// const enemy = core.status.maps[floorId].enemy.list;
// if (enemy.size === 0) {
// ctx.strokeStyle = 'lightgreen';
// ctx.lineCap = 'round';
// ctx.lineJoin = 'round';
// ctx.beginPath();
// ctx.moveTo(x - 1.5, y);
// ctx.lineTo(x - 0.5, y + 1);
// ctx.lineTo(x + 1.5, y - 1);
// ctx.stroke();
// } else if (enemy.size <= 2) {
// const idSet = new Set<EnemyIds>();
// enemy.forEach(v => {
// idSet.add(v.id);
// });
// const ids: EnemyIds[] = [...idSet];
// if (ids.length === 1) {
// core.drawIcon(ctx, ids[0], x - 2, y - 2, 4, 4);
// } else if (ids.length === 2) {
// core.drawIcon(ctx, ids[0], x - 4, y - 2, 4, 4);
// core.drawIcon(ctx, ids[1], x, y - 2, 4, 4);
// } else {
// core.drawIcon(ctx, ids[0], x - 5, y - 2, 4, 4);
// core.drawIcon(ctx, ids[1], x - 1, y - 2, 4, 4);
// ctx.fillStyle = 'white';
// ctx.strokeStyle = 'black';
// ctx.strokeText('…', x + 4, y);
// ctx.fillText('…', x + 4, y);
// }
// } else {
// ctx.fillStyle = 'white';
// ctx.textAlign = 'center';
// ctx.textBaseline = 'middle';
// ctx.fillText(`+${enemy.size}`, x, y);
// }
ctx.restore();
}
}
}

View File

@ -1,5 +0,0 @@
export * from './book';
export * from './common';
export * from './equipbox';
export * from './fixed';
export * from './fly';

View File

@ -1,46 +0,0 @@
interface ClsMap {
tools: '消耗道具';
constants: '永久道具';
all: '全部道具';
}
interface AllItem {
tools: [ItemIdOf<'tools'>, number][];
constants: [ItemIdOf<'constants'>, number][];
}
const clsMap: ClsMap = {
tools: '消耗道具',
constants: '永久道具',
all: '全部道具'
};
/**
* 根据道具的cls获取中文类型
* @param cls 道具的cls
*/
export function getClsName<T extends keyof ClsMap | 'all'>(cls: T): ClsMap[T] {
return clsMap[cls];
}
/**
* 获取某个类型的所有道具
* @param cls 道具类型
*/
export function getItems(cls: 'all'): AllItem;
export function getItems(cls: keyof ClsMap): [string, number][];
export function getItems(cls: keyof ClsMap | 'all') {
const i = core.status.hero.items;
const ignore = ['book'];
if (cls === 'all') {
return {
tools: Object.entries(i.tools).filter(v => !ignore.includes(v[0])),
constants: Object.entries(i.constants).filter(
v => !ignore.includes(v[0])
)
};
}
const items = i[cls];
const data = Object.entries(items);
return data;
}

View File

@ -1,235 +0,0 @@
<!-- 怪物详细信息 -->
<template>
<div id="detail">
<div id="tools">
<span id="back" class="button-text tools" @click="close"
><left-outlined />返回</span
>
</div>
<div id="info" :style="{ top: `${top}px` }">
<EnemyOne :enemy="enemy!"></EnemyOne>
<Divider
dashed
style="margin: 2vh 0 2vh 0; border-color: #ddd4; width: 100%"
></Divider>
</div>
<Transition name="detail">
<EnemySpecial
:from-book="fromBook"
v-if="panel === 'special'"
></EnemySpecial>
<EnemyCritical
:from-book="fromBook"
v-else-if="panel === 'critical'"
></EnemyCritical>
<!-- <EnemyTarget v-else-if="panel === 'target'"></EnemyTarget> -->
</Transition>
<div id="detail-more">
<Transition name="detail">
<div
id="special-more"
class="detial-more"
v-if="panel === 'special'"
>
<span id="enemy-target" class="button-text more"> </span>
<span
id="critical-more"
class="button-text more"
@click="changePanel($event, 'critical')"
>详细临界信息 <right-outlined
/></span>
</div>
<div
id="special-more"
class="detial-more"
v-else-if="panel === 'critical'"
>
<span
id="enemy-pos"
class="button-text more"
@click="changePanel($event, 'special')"
><left-outlined /> 怪物特殊属性</span
>
</div>
<div
id="special-more"
class="detial-more"
v-else-if="panel === 'target'"
>
<span></span>
<span
id="enemy-pos"
class="button-text more"
@click="changePanel($event, 'special')"
>怪物特殊属性 <RightOutlined
/></span>
</div>
</Transition>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import EnemyOne from '../components/enemyOne.vue';
import { useDrag } from '../use';
import EnemySpecial from '../panel/enemySpecial.vue';
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import EnemyCritical from '../panel/enemyCritical.vue';
import { detailInfo } from '../tools/book';
import { gameKey } from '@motajs/system';
import { Divider } from 'ant-design-vue';
const props = defineProps<{
fromBook?: boolean;
defaultPanel?: 'special' | 'critical' | 'target';
}>();
const emits = defineEmits<{
(e: 'close'): void;
}>();
const enemy = detailInfo.enemy;
const top = ref(detailInfo.pos);
const panel = ref<string>(props.defaultPanel ?? 'special');
const symbol = Symbol();
let detail: HTMLDivElement;
function changePanel(e: MouseEvent, to: string) {
e.stopPropagation();
panel.value = to;
}
function close() {
top.value = detailInfo.pos;
detail.style.opacity = '0';
emits('close');
}
gameKey.use(symbol);
gameKey
.realize('exit', () => {
close();
})
.realize('confirm', () => {
close();
});
onMounted(async () => {
top.value = 0;
detail = document.getElementById('detail') as HTMLDivElement;
detail.style.opacity = '1';
const style = getComputedStyle(detail);
let moved = false;
let pos = [0, 0];
useDrag(
detail,
(x, y) => {
if ((x - pos[0]) ** 2 + (y - pos[1]) ** 2 >= 100) moved = true;
},
(x, y) => {
pos = [x, y];
if (y > (parseFloat(style.height) * 4) / 5) moved = true;
},
() => {
if (moved === false && panel.value === 'special') {
close();
}
moved = false;
}
);
});
onUnmounted(() => {
gameKey.dispose(symbol);
});
</script>
<style lang="less" scoped>
#info {
width: 100%;
position: relative;
transition: all 0.6s ease;
height: 20vh;
padding: 0 1% 0 1%;
display: flex;
flex-direction: column;
}
#detail {
opacity: 0;
position: absolute;
left: 14%;
font-family: 'normal';
display: flex;
flex-direction: column;
align-items: center;
width: 72%;
height: 100%;
user-select: none;
}
#detail-more {
position: absolute;
margin-top: 3%;
width: 100%;
font-size: 3vh;
bottom: 0;
}
.detial-more {
position: absolute;
width: 100%;
bottom: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.more {
user-select: none;
}
.detail-enter-active,
.detail-leave-active {
transition: all 0.6s ease;
}
.detail-enter-from,
.detail-leave-to {
opacity: 0;
}
#tools {
position: fixed;
height: 6%;
font-size: 3.2vh;
width: 100%;
left: 5%;
top: 5%;
}
@media screen and (max-width: 600px) {
#detail {
width: 100%;
height: 100%;
padding: 5%;
left: 0%;
}
#detail-more {
font-size: 4vw;
bottom: 5%;
left: 5vw;
width: 80vw;
}
#info {
transform: translateY(10%);
}
}
</style>

View File

@ -1,717 +0,0 @@
<template>
<div id="equipbox">
<div id="tools">
<span class="button-text tools" @click="exit"
><left-outlined /> 返回游戏</span
>
<span class="button-text tools" @click="toTool"
>道具栏 <right-outlined
/></span>
</div>
<div id="equipbox-main">
<div id="equip-list">
<div id="filter">
<Select v-model:value="norm" class="select">
<SelectOption v-for="t of normList" :value="t">{{
t === 'none' ? '所有' : getStatusLabel(t)
}}</SelectOption>
</Select>
<Divider type="vertical" class="divider"></Divider>
<Select v-model:value="sType" class="select">
<SelectOption value="value">数值</SelectOption>
<SelectOption value="percentage">百分比</SelectOption>
</Select>
<Divider type="vertical" class="divider"></Divider>
<span
@click="changeSort()"
class="button-text"
id="sort-type"
>
<span v-if="sort === 'down'">
<sort-ascending-outlined />
</span>
<span v-else><sort-descending-outlined /></span>
</span>
</div>
<Divider
dashed
style="border-color: #ddd4; margin: 1vh 0 1vh 0"
></Divider>
<Scroll id="equip-scroll"
><div
class="equip selectable"
v-for="([id, num], i) of toShow"
:selected="selected === i && !isCol"
@mousedown="select(i)"
@touchstart="select(i)"
@click="clickList(i)"
>
<div class="equip-icon">
<BoxAnimate
:id="id ?? 'none'"
:width="32"
:height="32"
:noborder="true"
></BoxAnimate>
<span class="equip-name">{{ all[id].name }}</span>
</div>
<span>×&nbsp;{{ num }}</span>
</div></Scroll
>
</div>
<div id="equip-status">
<Divider
class="divider"
:type="isMobile ? 'horizontal' : 'vertical'"
dashed
style="border-color: #ddd4"
></Divider>
<div id="equip-status-main">
<div id="equip-now">
<Scroll
:style="{
height: isMobile ? '10vh' : '30vh'
}"
>
<div id="equip-now-div">
<div
v-for="(name, i) of equipCol"
class="equip-now-one draginable selectable"
:draged="draged"
:access="canDragin(i)"
:selected="isCol && selected === i"
@mouseenter="dragin($event, i)"
@mouseleave="dragout"
@click="select(i, true)"
>
<BoxAnimate
:id="equiped[i] ?? 'none'"
></BoxAnimate>
<span>{{ name }}</span>
</div>
</div>
</Scroll>
</div>
<Divider
dashed
style="border-color: #ddd4; margin: 1vh 0 1vh 0"
></Divider>
<div id="equip-hero" v-if="!isMobile">
<div id="hero-icon">
<BoxAnimate
id="hero"
:width="48"
:height="64"
noborder
></BoxAnimate>
</div>
<div id="hero-status">
<component :is="nowStatus"></component>
</div>
</div>
</div>
<Divider
v-if="!isMobile"
class="divider"
type="vertical"
dashed
style="border-color: #ddd4"
></Divider>
</div>
<div id="equip-desc">
<div id="equip-icon">
<BoxAnimate
:id="
isCol
? (equiped[selected] ?? 'none')
: (toShow[selected]?.[0] ?? 'none')
"
></BoxAnimate>
<span>{{ equip.name }}</span>
</div>
<div id="equip-type">
<span>装备孔:{{ equip.equip?.type }}</span>
</div>
<Divider
dashed
style="border-color: #ddd4; margin: 1vh 0 1vh 0"
></Divider>
<div id="equip-add">
<span id="title">增减属性</span>
<Scroll style="width: 100%; height: 100%">
<component :is="addStatus"></component>
</Scroll>
</div>
<Divider
dashed
style="border-color: #ddd4; margin: 1vh 0 1vh 0"
></Divider>
<div id="equip-desc-text">
<span id="title">装备介绍</span>
<Scroll id="desc-text" style="height: 100%; width: 100%">
<div v-if="!descText.value!.startsWith('!!html')">
{{ descText.value }}
</div>
<div v-else v-html="descText.value!.slice(6)"></div>
</Scroll>
</div>
</div>
</div>
</div>
<div id="icon-drag">
<BoxAnimate
class="drag-icon"
v-if="draged"
:id="toShow[selected]?.[0] ?? 'none'"
:width="48"
:height="48"
noborder
></BoxAnimate>
</div>
</template>
<script lang="ts" setup>
import {
LeftOutlined,
RightOutlined,
SortAscendingOutlined,
SortDescendingOutlined
} from '@ant-design/icons-vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import Scroll from '../components/scroll.vue';
import { getAddStatus, getEquips, getNowStatus } from '../tools/equipbox';
import BoxAnimate from '../components/boxAnimate.vue';
import { type, getStatusLabel } from '../utils';
import { cancelGlobalDrag, isMobile, tip, useDrag } from '../use';
import { hyper } from 'mutate-animate';
import { gameKey } from '@motajs/system';
import { IMountedVBind } from '../interface';
import { isNil } from 'lodash-es';
import { Divider, Select, SelectOption } from 'ant-design-vue';
const props = defineProps<IMountedVBind>();
const equips = ref(getEquips());
const col = ref('all');
const all = core.material.items;
const selected = ref(0);
const isCol = ref(false); // 是否是选中的已装备的装备
const equipCol = core.status.globalAttribute.equipName;
const equiped = ref(core.status.hero.equipment);
const listClicked = ref(false);
const draged = ref(false);
const toEquipType = ref(-1); // 要穿至的装备孔,-1表示不穿
/** 排序方式,down表示下面大上面小 */
const sort = ref<'up' | 'down'>('down');
const norm = ref<keyof SelectType<HeroStatus, number> | 'none'>('none');
const sType = ref<'value' | 'percentage'>('value');
// 攻击 防御 回血 额外攻击
const normList = ['none', 'atk', 'def', 'hpmax', 'mana'];
watch(sort, n => {
selected.value = toShow.value.length - selected.value - 1;
});
watch(norm, n => {
selected.value = 0;
});
watch(sType, n => {
selected.value = 0;
});
/**
* 当前装备
*/
const equip = computed(() => {
const index = toShow.value[selected.value];
const none = {
name: '没有选择装备',
cls: 'equip',
text: '没有选择装备',
equip: { type: '无', value: {}, percentage: {}, animate: '' }
};
if (isCol.value) {
const id = equiped.value[selected.value];
const e = core.material.items[id];
if (isNil(e)) return none;
return e;
}
if (isNil(index)) return none;
return all[index[0]];
});
const addStatus = computed(() => {
// @ts-ignore
return getAddStatus(equip.value.equip!, isCol.value);
});
const descText = computed(() => {
if (equip.value.text!.startsWith('!!html')) return ref(equip.value.text);
return type(equip.value.text!, 25, hyper('sin', 'out'), true);
});
const nowStatus = computed(() => {
// @ts-ignore
return getNowStatus(equip.value.equip, isCol.value);
});
/**
* 需要展示的装备,已进行排序等操作
*/
const toShow = computed(() => {
const sortType = sort.value;
const sortNorm = norm.value;
const sortBy = sType.value;
const res = equips.value.filter(v => {
const e = all[v[0]].equip!;
const t = e.type;
if (sortNorm !== 'none') {
if (isNil(e[sortBy][sortNorm])) return false;
}
if (col.value === 'all') return true;
if (typeof t === 'string') return t === col.value;
else return core.status.globalAttribute.equipName[t] === col.value;
});
if (sortNorm === 'none') return res;
else {
if (sortType === 'down') {
return res.sort((a, b) => {
const ea = all[a[0]].equip!;
const eb = all[b[0]].equip!;
return ea[sortBy][sortNorm] - eb[sortBy][sortNorm];
});
} else {
return res.sort((a, b) => {
const ea = all[a[0]].equip!;
const eb = all[b[0]].equip!;
return eb[sortBy][sortNorm] - ea[sortBy][sortNorm];
});
}
}
});
function changeSort() {
if (sort.value === 'down') {
sort.value = 'up';
} else {
sort.value = 'down';
}
}
function exit() {
props.controller.close(props.num);
}
function clickList(i: number) {
if (i === selected.value && listClicked.value) {
const id = toShow.value[selected.value]?.[0];
if (!core.canEquip(id)) {
tip('warn', '无法装备!');
return;
}
core.loadEquip(id);
update();
listClicked.value = false;
}
listClicked.value = true;
}
/**
* 选择某个装备
*/
function select(i: number, col: boolean = false) {
if (i !== selected.value && !col) {
listClicked.value = false;
}
if (col) listClicked.value = false;
if (col && isCol.value === col && selected.value === i) {
core.unloadEquip(i);
update();
}
isCol.value = col;
selected.value = i;
}
/**
* 是否可以将当前装备拖入该栏
* @param type 装备栏
*/
function canDragin(type: number) {
if (type < 0) return false;
const et = equip.value.equip?.type;
if (!core.canEquip(toShow.value[selected.value]?.[0])) return false;
if (isNil(et)) return false;
if (typeof et === 'number') return type === et;
return equipCol[type] === et;
}
/**
* 穿上某个装备
*/
function loadEquip() {
const num = toEquipType.value;
if (num < 0) return;
if (!canDragin(num)) {
tip('warn', '无法装备!');
return;
}
const now = equiped.value[num];
const to = toShow.value[selected.value]?.[0];
core.items._realLoadEquip(num, to, now);
update();
}
function update() {
equiped.value = core.status.hero.equipment;
equips.value = getEquips();
requestAnimationFrame(() => {
bind();
});
}
// ----- 绑定函数
function bind() {
const equips = Array.from(
document.querySelectorAll('.equip')
) as HTMLDivElement[];
// 绑定武器拖拽事件,使武器可以拖拽穿脱
useDrag(
equips,
dragEquip,
(x, y) => {
fx = x;
fy = y;
},
() => {
if (draged.value) {
draged.value = false;
loadEquip();
}
},
true
);
}
// ----- 交互函数
let [fx, fy] = [0, 0];
function dragEquip(x: number, y: number, e: TouchEvent | MouseEvent) {
if ((x - fx) ** 2 + (y - fy) ** 2 > 10 ** 2 && !draged.value) {
draged.value = true;
}
if (draged.value) {
const target = document.getElementById('icon-drag') as HTMLDivElement;
target.style.left = `${x - 24}px`;
target.style.top = `${y - 24}px`;
}
}
function dragin(e: Event, type: number) {
e.stopPropagation();
toEquipType.value = type;
}
function dragout(e: Event) {
e.stopPropagation();
toEquipType.value = -1;
}
function toTool() {
props.controller.holdOn();
exit();
nextTick(() => {
props.controller.open('toolbox');
});
}
gameKey.use(props.ui.symbol);
gameKey
.realize('exit', () => {
exit();
})
.realize('equipbox', () => {
exit();
})
.realize('quickEquip', id => {
const num = parseInt(id.split('_').at(-1)!);
core.quickSaveEquip(num);
tip('success', `已保存至${num}号套装`);
});
watch(toShow, n => {
bind();
});
onMounted(async () => {
bind();
});
onUnmounted(() => {
cancelGlobalDrag(dragEquip);
gameKey.dispose(props.ui.symbol);
});
</script>
<style lang="less" scoped>
#equipbox {
width: 100%;
height: 100%;
user-select: none;
}
#tools {
width: 100%;
display: flex;
flex-direction: row;
font-size: 3.2vh;
height: 5vh;
justify-content: space-between;
font-family: 'normal';
.tools {
white-space: nowrap;
}
}
#equipbox-main {
height: 85vh;
width: 100%;
display: flex;
flex-direction: row;
font-family: 'normal';
font-size: 150%;
}
.divider {
height: 100%;
}
#equip-list {
display: flex;
flex-direction: column;
flex-basis: 25%;
#filter {
margin-top: 2vh;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
.select {
width: 100%;
font-family: 'normal';
font-size: 75%;
}
#sort-type {
font-size: 75%;
white-space: nowrap;
}
}
#equip-scroll {
height: 100%;
}
}
#equip-status {
display: flex;
flex-basis: 50%;
flex-direction: row;
justify-content: space-between;
#equip-status-main {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}
}
#equip-desc {
display: flex;
flex-basis: 25%;
flex-direction: column;
padding-top: 2vh;
align-items: center;
#equip-icon {
width: 80%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
border-bottom: 1px solid #ddd4;
padding-bottom: 1%;
span {
margin-left: 5%;
}
}
#equip-add {
height: 50%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
#title {
width: 100%;
text-align: center;
}
.equip-add-detail {
display: flex;
flex-direction: column;
width: 100%;
}
}
#equip-desc-text {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
#title {
width: 100%;
text-align: center;
}
}
}
#equip-now {
width: 100%;
#equip-now-div {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
padding-left: 5%;
.equip-now-one {
flex-basis: 30%;
display: flex;
flex-direction: row;
align-items: center;
margin: 3% 3.3% 3% 0;
padding-left: 0.5%;
span {
margin-left: 10%;
}
}
}
}
#equip-hero {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
#hero-icon {
display: flex;
align-items: center;
justify-content: center;
margin: 10% 0 10% 0;
}
#hero-status {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
.hero-status-one {
display: flex;
flex-direction: row;
flex-basis: 50%;
width: 100%;
text-align: right;
font-size: 110%;
white-space: nowrap;
.hero-status-label {
width: 100%;
margin-right: 5%;
}
.hero-status-value {
display: flex;
flex-direction: row;
width: 100%;
}
}
}
.equip {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0.5vh 0.5vw;
border: #0000 0.5px solid;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.equip-icon {
display: flex;
flex-direction: row;
align-items: center;
.equip-name {
margin-left: 5%;
}
}
}
#icon-drag {
position: fixed;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
#equipbox-main {
flex-direction: column-reverse;
font-size: 225%;
}
#equip-now-div {
flex-wrap: nowrap;
}
#equip-status {
flex-direction: column;
flex-basis: auto;
}
#equip-list {
flex-basis: 45%;
#filter #sort-type {
font-size: 150%;
}
}
.divider {
margin: 1% 0 1% 0;
}
}
</style>

View File

@ -1,638 +0,0 @@
<template>
<div id="fly">
<div id="tools">
<span class="button-text" @click="exit"
><left-outlined /> 返回游戏</span
>
</div>
<div id="fly-settings">
<div id="fly-border">
<span>无边框模式</span>
<Switch
class="fly-settings"
v-model:checked="noBorder"
checked-children="ON"
un-checked-children="OFF"
></Switch>
</div>
<div v-if="!isMobile" id="fly-tradition">
<span>传统按键模式</span>
<Switch
class="fly-settings"
v-model:checked="tradition"
checked-children="ON"
un-checked-children="OFF"
></Switch>
</div>
<span
v-if="!isMobile"
class="button-text"
id="fly-download"
@click="download"
>下载地图图片</span
>
</div>
<div id="fly-main">
<div id="fly-left">
<Scroll id="fly-area"
><div id="area-list">
<span
v-for="(v, k) in area"
:selected="nowArea === k"
class="selectable"
@click="nowArea = k"
>{{ k }}</span
>
</div></Scroll
>
<Divider type="vertical" dashed id="divider-left"></Divider>
<div id="fly-map-div">
<canvas id="fly-map" @click="click"></canvas>
</div>
</div>
<Divider
id="divider-right"
dashed
:type="isMobile ? 'horizontal' : 'vertical'"
></Divider>
<div id="fly-right">
<canvas id="fly-thumbnail" @click="fly" @wheel="wheel"></canvas>
<div id="fly-tools">
<double-left-outlined
@click="changeFloorByDelta(-10)"
class="button-text fly-button"
/>
<left-outlined
@click="changeFloorByDelta(-1)"
class="button-text fly-button"
/>
<span
class="changable"
id="fly-now"
:change="titleChange"
>{{ title }}</span
>
<right-outlined
@click="changeFloorByDelta(1)"
class="button-text fly-button"
/>
<double-right-outlined
@click="changeFloorByDelta(10)"
class="button-text fly-button"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import Scroll from '../components/scroll.vue';
import { getArea, getMapData, MinimapDrawer } from '../tools/fly';
import { cancelGlobalDrag, isMobile, useDrag, useWheel } from '../use';
import {
LeftOutlined,
DoubleLeftOutlined,
RightOutlined,
DoubleRightOutlined
} from '@ant-design/icons-vue';
import { debounce } from 'lodash-es';
import { tip } from '../use';
import { gameKey } from '@motajs/system';
import { createChangable } from '../tools/common';
import { mainSetting } from '../preset/settingIns';
import { GameStorage } from '@motajs/legacy-system';
import { IMountedVBind } from '../interface';
import { Divider, Switch } from 'ant-design-vue';
const props = defineProps<IMountedVBind>();
type Loc2 = [number, number, number, number];
const area = getArea();
const nowArea = ref(
Object.keys(area).find(v => area[v].includes(core.status.floorId)) ?? ''
);
const nowFloor = ref(core.status.floorId);
const noBorder = ref(true);
const tradition = ref(false);
const settingScale = mainSetting.getValue('ui.mapScale', 100) / 100;
let scale = (isMobile ? 1.5 : 3) * settingScale;
const storage = GameStorage.for(GameStorage.fromAuthor('AncTe', 'flyConfig'));
noBorder.value = storage.getValue('noBorder', true);
tradition.value = storage.getValue('flyTradition', false);
const floor = computed(() => {
return core.status.maps[nowFloor.value];
});
watch(nowFloor, n => {
drawer.nowFloor = n;
draw();
});
watch(nowArea, n => {
scale = 3 * settingScale;
lastScale = 3 * settingScale;
drawer.nowArea = n;
drawer.scale = 3 * settingScale;
drawer.ox = 0;
drawer.oy = 0;
if (area[n] && !area[n].includes(nowFloor.value))
nowFloor.value =
area[n].find(v => v === core.status.floorId) ?? area[n][0];
});
watch(noBorder, n => {
storage.setValue('noBorder', n);
drawer.noBorder = n;
drawer.drawedThumbnail = {};
drawer.drawMap();
});
watch(tradition, n => {
storage.setValue('flyTradition', n);
});
let map: HTMLCanvasElement;
let thumb: HTMLCanvasElement;
let thumbCtx: CanvasRenderingContext2D;
function exit() {
props.controller.close(props.num);
}
const title = computed(() => {
return core.status.maps[nowFloor.value].title;
});
const titleChange = createChangable(title).change;
let drawer: MinimapDrawer;
function drawRight() {
let w = thumb.width;
let h = thumb.height;
let x = 0;
let y = 0;
const ratio = floor.value.width / floor.value.height;
if (ratio > 1) {
h = w / ratio;
y = thumb.height / 2 - h / 2;
}
if (ratio < 1) {
w = h * ratio;
x = thumb.width / 2 - w / 2;
}
thumbCtx.fillStyle = '#000';
thumbCtx.fillRect(0, 0, thumb.width, thumb.height);
core.drawThumbnail(nowFloor.value, void 0, {
ctx: thumbCtx,
all: true,
damage: true,
inFlyMap: true,
x,
y,
w,
h
});
}
/**
* 绘制所有内容
*/
function draw() {
drawer.clearCache();
drawer.drawMap();
drawRight();
}
function download() {
drawer.download();
draw();
}
function fly() {
if (core.flyTo(nowFloor.value)) exit();
else tip('error', `无法飞往${floor.value.title}`);
}
let lastScale = scale;
const changeScale = debounce((s: number) => {
map.style.transform = '';
drawer.drawedThumbnail = {};
drawer.scale = s;
drawer.drawMap();
lastScale = s;
}, 200);
function resize(delta: number) {
drawer.ox *= delta;
drawer.oy *= delta;
scale = delta * scale;
changeScale(scale);
map.style.transform = `scale(${scale / lastScale})`;
drawer.thumbnailLoc = {};
}
let lastX = 0;
let lastY = 0;
let moved = false;
let startX = 0;
let startY = 0;
// -------------------- 点击事件
function drag(x: number, y: number) {
if (touchScale) return;
const dx = x - lastX;
const dy = y - lastY;
drawer.ox += dx;
drawer.oy += dy;
lastX = x;
lastY = y;
drawer.checkMoveThumbnail();
drawer.drawToTarget();
if (Math.abs(x - startX) > 10 || Math.abs(y - startY) > 10) moved = true;
}
function click(e: MouseEvent) {
if (moved) return;
const x = e.offsetX * devicePixelRatio;
const y = e.offsetY * devicePixelRatio;
for (const [id, [left, top, right, bottom]] of Object.entries(
drawer.thumbnailLoc
) as [FloorIds, Loc2][]) {
if (x >= left && x <= right && y >= top && y <= bottom) {
if (id === nowFloor.value) {
fly();
} else {
nowFloor.value = id;
}
}
}
}
function wheel(ev: WheelEvent) {
changeFloorByDelta(-Math.sign(ev.deltaY));
}
function changeAreaByFloor(id: FloorIds) {
nowArea.value = Object.keys(area).find(v => area[v].includes(id))!;
}
function changeFloorByDelta(delta: number) {
const now = core.floorIds.indexOf(nowFloor.value);
let to = now + delta;
if (to < 0) to = 0;
if (to >= core.floorIds.length) to = core.floorIds.length - 1;
const floor = core.status.maps[core.floorIds[to]];
if (floor.deleted || floor.forceDelete) {
while (to !== now) {
to += Math.sign(delta);
const floor = core.status.maps[core.floorIds[to]];
if (floor.cannotViewMap) continue;
if (!floor.deleted && !floor.forceDelete) break;
if (to < 0 || to >= core.floorIds.length) break;
}
}
nowFloor.value = core.floorIds[to];
changeAreaByFloor(nowFloor.value);
drawer.locateMap(nowFloor.value);
}
function changeFloorByDir(dir: Dir) {
const data = getMapData(nowFloor.value);
for (const [from, to] of Object.entries(data.link)) {
if (!from.startsWith(nowFloor.value)) continue;
const d = from.split(',')[3] as Dir;
if (d === dir) {
const target = to.split(',')[0] as FloorIds;
drawer.locateMap(target);
nowFloor.value = target;
return;
}
}
}
// -------------------- 键盘事件
gameKey.use(props.ui.symbol);
gameKey
.realize('@fly_left', () => {
if (!tradition.value) changeFloorByDir('left');
})
.realize('@fly_right', () => {
if (!tradition.value) changeFloorByDir('right');
})
.realize('@fly_up', () => {
if (!tradition.value) changeFloorByDir('up');
})
.realize('@fly_down', () => {
if (!tradition.value) changeFloorByDir('down');
})
.realize('@fly_last', () => {
if (!tradition.value) changeFloorByDelta(-1);
})
.realize('@fly_next', () => {
if (!tradition.value) changeFloorByDelta(1);
})
.realize('@fly_down_t', () => {
if (tradition.value) changeFloorByDelta(-1);
})
.realize('@fly_up_t', () => {
if (tradition.value) changeFloorByDelta(1);
})
.realize('@fly_left_t', () => {
if (tradition.value) changeFloorByDelta(-10);
})
.realize('@fly_right_t', () => {
if (tradition.value) changeFloorByDelta(10);
})
.realize('exit', () => {
exit();
})
.realize('confirm', () => {
fly();
})
.realize('fly', () => {
exit();
});
// -------------------- 触摸事件
let touchScale = false;
let lastDis = 0;
function touchdown(e: TouchEvent) {
if (e.touches.length >= 2) {
touchScale = true;
lastDis = Math.sqrt(
(e.touches[0].clientX - e.touches[1].clientX) ** 2 +
(e.touches[0].clientY - e.touches[1].clientY) ** 2
);
}
}
function touchup(e: TouchEvent) {
if (e.touches.length < 2) touchScale = false;
}
function touchmove(e: TouchEvent) {
if (!touchScale) return;
const dis = Math.sqrt(
(e.touches[0].clientX - e.touches[1].clientX) ** 2 +
(e.touches[0].clientY - e.touches[1].clientY) ** 2
);
resize(dis / lastDis);
lastDis = dis;
}
onMounted(async () => {
map = document.getElementById('fly-map') as HTMLCanvasElement;
thumb = document.getElementById('fly-thumbnail') as HTMLCanvasElement;
thumbCtx = thumb.getContext('2d')!;
drawer = new MinimapDrawer(map);
drawer.scale = scale;
drawer.noBorder = noBorder.value;
drawer.showInfo = true;
drawer.nowArea = nowArea.value;
const mapStyle = getComputedStyle(map);
const thumbStyle = getComputedStyle(thumb);
map.width = parseFloat(mapStyle.width) * devicePixelRatio;
map.height = parseFloat(mapStyle.height) * devicePixelRatio;
thumb.width = parseFloat(thumbStyle.width) * devicePixelRatio;
thumb.height = parseFloat(thumbStyle.width) * devicePixelRatio;
Array.from(document.getElementsByClassName('fly-settings')).forEach(v => {
v.addEventListener('click', e => (v as HTMLElement).blur());
});
drawer.locateMap(nowFloor.value);
draw();
useDrag(
map,
drag,
(x, y) => {
lastX = x;
lastY = y;
startX = x;
startY = y;
},
() => {
setTimeout(() => {
moved = false;
}, 50);
},
true
);
useWheel(map, (x, y) => {
const delta = -Math.sign(y) * 0.1 + 1;
resize(delta);
});
map.addEventListener('touchstart', touchdown);
map.addEventListener('touchend', touchup);
map.addEventListener('touchmove', touchmove);
});
onUnmounted(() => {
cancelGlobalDrag(drag);
gameKey.dispose(props.ui.symbol);
});
</script>
<style lang="less" scoped>
#fly {
width: 100%;
height: 100%;
font-size: 150%;
font-family: 'normal';
display: flex;
align-items: center;
user-select: none;
}
#tools {
width: 100%;
font-family: 'normal';
font-size: 3.2vh;
height: 5vh;
position: fixed;
left: 5vw;
top: 5vh;
}
#fly-main {
display: flex;
height: 80%;
width: 100%;
flex-direction: row;
}
#fly-left {
width: 50vw;
display: flex;
flex-direction: row;
align-items: center;
}
#fly-area {
height: 100%;
width: 15vw;
}
#area-list {
height: 100%;
display: flex;
flex-direction: column;
}
#divider-left {
margin: 0;
height: 100%;
border-color: #ddd4;
}
#fly-map-div,
#fly-map {
width: 35vw;
height: 72vh;
overflow: hidden;
}
#divider-right {
height: 100%;
border-color: #ddd4;
margin: 0;
}
#fly-right {
width: 40vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
position: relative;
}
#fly-tools {
margin: 0;
width: 80%;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
position: absolute;
bottom: 0;
width: 100%;
background-color: #0004;
}
#fly-thumbnail {
width: 35vw;
height: 35vw;
max-height: 75vh;
max-width: 75vh;
border: 0.1vw solid #ddd4;
}
#fly-settings {
position: fixed;
bottom: 5vh;
left: 10vw;
width: 80vw;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
div {
display: flex;
align-items: center;
span {
margin-right: 5vw;
}
}
}
.fly-settings[aria-checked='false'] {
background-color: #ddd4;
}
.fly-button {
padding: 3%;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.301);
border: 1px dashed #ddda;
filter: drop-shadow(0px 0px 16px black);
}
#fly-now {
text-wrap: nowrap;
white-space: nowrap;
max-width: 50%;
text-overflow: ellipsis;
overflow: hidden;
text-shadow:
1px 1px 1px black,
1px -1px 1px black,
-1px 1px 1px black,
-1px -1px 1px black;
}
@media screen and (max-width: 600px) {
#fly {
font-size: 225%;
}
#fly-main {
flex-direction: column;
height: 90%;
}
#fly-map-div,
#fly-map {
width: 60vw;
height: 30vh;
}
#fly-area {
width: 30vw;
height: 30vh;
}
#fly-left {
width: 90vw;
}
#divider-right {
height: 0;
}
#fly-right {
width: 90vw;
height: 60vh;
}
#fly-thumbnail {
width: 80vw;
height: 80vw;
}
#tools {
top: 2vh;
}
#fly-settings {
bottom: 2%;
}
}
</style>

View File

@ -1,255 +0,0 @@
<template>
<Column @close="close" :left="30">
<template #left>
<div id="hotkey-group">
<span
class="hotkey-group-one selectable"
v-for="(name, group) of groupName"
:selected="selectedGroup === group"
@click="selectedGroup = group"
>
{{ name }}
</span>
</div>
</template>
<template #right>
<div id="hotkey-data">
<div class="hotkey-one" v-for="(data, id) of show">
<span class="hotkey-one-name"> {{ data.name }} </span>
<div class="hotkey-one-set">
<span
v-for="key of data.keys"
class="hotkey-one-set-item"
:selected="
data.id === selectedKey.id &&
key.index === selectedKey.index
"
@click="select(data.id, key.index)"
>
{{ getKeyShow(key.key, key.assist) }}
</span>
</div>
</div>
</div>
</template>
</Column>
</template>
<script lang="ts" setup>
import { Hotkey, gameKey } from '@motajs/system';
import Column from '../components/colomn.vue';
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { KeyCode, KeyCodeUtils } from '@motajs/client-base';
import { cloneDeep } from 'lodash-es';
import { generateBinary, keycode } from '@motajs/legacy-common';
import { IMountedVBind } from '../interface';
interface HotkeyKeys {
index: number;
key: KeyCode;
/** 从低到高依次为 `ctrl` `shift` `alt` */
assist: number;
}
interface HotkeySetting {
id: string;
name: string;
keys: HotkeyKeys[];
}
interface SelectedKey {
id: string;
index: number;
}
const props = defineProps<IMountedVBind & { hotkey: Hotkey }>();
const hotkey = props.hotkey;
function close() {
props.controller.close(props.num);
}
const selectedGroup = ref('ui');
const keyData = generateData();
const show = computed(() => {
return keyData[selectedGroup.value];
});
const groupName = cloneDeep(hotkey.groupName);
delete groupName.none;
const selectedKey: SelectedKey = reactive({
id: 'none',
index: -1
});
function generateData() {
const res: Record<string, Record<string, HotkeySetting>> = {};
for (const [group, data] of Object.entries(hotkey.groups)) {
if (group === 'none') continue;
res[group] = {};
const d = res[group];
data.forEach(v => {
const split = v.split('_');
const isMulti = /^\d+$/.test(split.at(-1)!);
const id = isMulti ? split.slice(0, -1).join('_') : v;
const index = isMulti ? parseInt(split.at(-1)!) : -1;
const key = hotkey.data[v];
d[id] ??= {
id,
name: isMulti
? key.name.split('_').slice(0, -1).join('_')
: key.name,
keys: reactive([])
};
d[id].keys.push({
index,
key: key.key,
assist: generateBinary([key.ctrl, key.shift, key.alt])
});
});
}
return res;
}
function unwarpAssist(assist: number) {
let res = '';
if (assist & (1 << 0)) {
res += 'Ctrl + ';
}
if (assist & (1 << 1)) {
res += 'Shift + ';
}
if (assist & (1 << 2)) {
res += 'Alt + ';
}
return res;
}
function getKeyShow(key: KeyCode, assist: number) {
return (
unwarpAssist(assist) +
(key === KeyCode.Unknown ? '' : KeyCodeUtils.toString(key))
);
}
function select(id: string, index: number) {
selectedKey.id = id;
selectedKey.index = index;
}
function keyup(e: KeyboardEvent) {
if (selectedKey.id === 'none') return;
e.preventDefault();
const code = keycode(e.keyCode);
const assist = generateBinary([e.ctrlKey, e.shiftKey, e.altKey]);
const id =
selectedKey.index === -1
? selectedKey.id
: `${selectedKey.id}_${selectedKey.index}`;
const key = keyData[selectedGroup.value][selectedKey.id].keys.find(
v => v.index === selectedKey.index
);
if (key) {
if (
code === KeyCode.Ctrl ||
code === KeyCode.Shift ||
code === KeyCode.Alt ||
code === KeyCode.Unknown
) {
const data = hotkey.data[id];
key.assist = generateBinary([data.ctrl, data.shift, data.alt]);
key.key = data.key;
} else {
key.key = code;
key.assist = assist;
hotkey.set(id, code, assist);
}
}
selectedKey.id = 'none';
selectedKey.index = -1;
}
function keydown(e: KeyboardEvent) {
if (selectedKey.id === 'none') return;
e.preventDefault();
const code = keycode(e.keyCode);
const assist = generateBinary([e.ctrlKey, e.shiftKey, e.altKey]);
const key = keyData[selectedGroup.value][selectedKey.id].keys.find(
v => v.index === selectedKey.index
);
if (!key) return;
key.assist = assist;
key.key = KeyCode.Unknown;
if (
code !== KeyCode.Ctrl &&
code !== KeyCode.Shift &&
code !== KeyCode.Alt
) {
key.key = code;
}
}
// ban other keys
gameKey.disable();
onMounted(() => {
document.addEventListener('keyup', keyup);
document.addEventListener('keydown', keydown);
});
onUnmounted(() => {
gameKey.enable();
document.removeEventListener('keyup', keyup);
document.removeEventListener('keydown', keydown);
});
</script>
<style lang="less" scoped>
#hotkey-group {
display: flex;
flex-direction: column;
}
.hotkey-group-one {
margin: 0 0 4px 0;
}
.hotkey-one {
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0 3% 2% 3%;
white-space: nowrap;
}
.hotkey-one-set {
display: flex;
flex-direction: column;
width: 50%;
text-overflow: clip;
align-items: flex-end;
text-align: end;
.hotkey-one-set-item {
background-color: #4446;
padding: 0 5%;
margin: 1% 0;
width: 100%;
border: 1px solid transparent;
}
.hotkey-one-set-item[selected='true'] {
color: gold;
border: 1px solid gold;
}
}
</style>

View File

@ -1,7 +0,0 @@
export { default as BookDetail } from './bookDetail.vue';
export { default as Equipbox } from './equipbox.vue';
export { default as Fly } from './fly.vue';
export { default as Settings } from './settings.vue';
export { default as Shop } from './shop.vue';
export { default as Toolbox } from './toolbox.vue';
export { default as Hotkey } from './hotkey.vue';

View File

@ -1,366 +0,0 @@
<template>
<div class="setting-main">
<div id="tools">
<span class="button-text" @click="exit"
><left-outlined /> 返回游戏</span
>
</div>
<div class="setting-container">
<div class="setting-select">
<TransitionGroup name="list">
<div
v-for="(info, i) of display"
:key="i"
class="setting-display"
>
<Scroll class="setting-scroll">
<div class="setting-list">
<div
v-for="item of info.list"
class="setting-item selectable"
:selected="item === info.item"
@click="click(item.key, i, item)"
>
<span class="setting-name">{{
item.name
}}</span>
<span
:selected="item === info.item"
class="setting-cascade"
v-if="isCascade(item)"
>
<RightOutlined />
</span>
<span v-else class="setting-value">
{{ getItemValue(item) }}
</span>
</div>
</div>
</Scroll>
<Divider
class="display-divider"
:type="isMobile ? 'horizontal' : 'vertical'"
dashed
></Divider>
</div>
</TransitionGroup>
</div>
<div class="setting-info">
<Scroll class="info-text-scroll">
<div
class="info-text"
v-html="
splitText(display.at(-1)?.text ?? ['请选择设置'])
"
></div>
</Scroll>
<Divider class="info-divider" dashed></Divider>
<div class="info-editor" v-if="!!selectedItem">
<div class="editor-custom">
<component
:is="selectedItem.controller as any"
:item="selectedItem"
:displayer="displayer"
:setting="setting"
:update="update"
></component>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref, shallowRef } from 'vue';
import { mainSetting } from '../preset/settingIns';
import {
MotaSetting,
MotaSettingItem,
SettingDisplayer,
SettingDisplayInfo
} from '../setting';
import { RightOutlined, LeftOutlined } from '@ant-design/icons-vue';
import { splitText } from '../utils';
import Scroll from '../components/scroll.vue';
import { isMobile } from '../use';
import { gameKey } from '@motajs/system';
import { IMountedVBind } from '../interface';
import { Divider } from 'ant-design-vue';
const props = defineProps<IMountedVBind>();
const setting = props.info ?? mainSetting;
const display = shallowRef<SettingDisplayInfo[]>([]);
const selectedItem = computed(() => display.value.at(-1)?.item);
const update = ref(false);
const displayer = new SettingDisplayer(setting);
displayer.on('update', (stack, dis) => {
display.value = dis;
update.value = !update.value;
});
display.value = displayer.displayInfo;
function getItemValue(item: MotaSettingItem) {
if (item.value instanceof MotaSetting) {
return '';
} else {
if (item.display) {
return item.display(item.value);
} else {
if (typeof item.value === 'number') {
return item.value.toString();
} else {
return item.value ? 'ON' : 'OFF';
}
}
}
}
function isCascade(item: MotaSettingItem) {
return item.value instanceof MotaSetting;
}
function click(key: string, index: number, item: MotaSettingItem) {
if (item.value instanceof MotaSetting) {
if (index === display.value.length - 1) {
displayer.add(key);
} else {
if (displayer.selectStack.includes(key)) {
displayer.cut(index);
} else {
displayer.cut(index, true);
displayer.add(key);
}
}
} else {
displayer.cut(index, true);
displayer.add(key);
}
}
function exit() {
props.controller.close(props.num);
}
gameKey.use(props.ui.symbol);
gameKey.realize('exit', () => {
exit();
});
onUnmounted(() => {
gameKey.dispose(props.ui.symbol);
});
</script>
<style lang="less" scoped>
#tools {
width: 100%;
font-family: 'normal';
font-size: 3.2vh;
height: 5vh;
position: fixed;
left: 10vw;
top: 10vh;
}
.setting-main {
user-select: none;
display: flex;
flex-direction: column;
justify-content: center;
font-family: normal;
font-size: 150%;
.setting-container {
height: 60%;
display: flex;
flex-direction: row;
}
}
.setting-select {
display: flex;
flex-direction: row;
transition: all 0.5s ease;
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-50px);
}
.list-leave-active {
position: absolute;
pointer-events: none;
}
.setting-display {
display: flex;
flex-direction: row;
height: 100%;
.setting-list {
display: flex;
flex-direction: column;
}
.display-divider {
border-color: #fff4;
height: auto;
}
.setting-item {
padding: 2%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden;
}
.setting-cascade {
font-size: 75%;
margin-right: 40px;
transition: all 0.5s ease;
}
.setting-cascade[selected='true'] {
font-size: 90%;
margin-right: 10px;
color: cyan;
}
.setting-name {
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
overflow: hidden;
}
.setting-value {
margin-right: 10px;
color: rgb(242, 255, 101);
white-space: nowrap;
}
}
.setting-scroll {
width: 20vw;
height: 100%;
}
.setting-info::v-deep(.editor-boolean) {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 0 10% 0 5%;
.boolean-button {
font-size: 75%;
}
}
.setting-info::v-deep(.editor-number) {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 0 10% 0 5%;
.number-input,
.number-button {
font-size: 80%;
width: 40%;
}
.number-button {
height: 100%;
}
.editor-number-button {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-around;
margin-top: 10px;
}
}
.setting-info {
width: 25vw;
.info-divider {
border-color: #fff4;
margin: 2% 0;
}
.info-text {
font-size: 85%;
min-height: 30%;
max-height: 50%;
}
.info-text-scroll {
max-height: 50%;
}
}
@media screen and (max-width: 600px) {
#tools {
top: 2vh;
}
.setting-main {
font-size: 225%;
.setting-container {
flex-direction: column;
height: auto;
}
.setting-select {
flex-direction: column;
width: 90vw;
}
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-50px);
}
.setting-scroll {
width: 90vw;
height: 20vh;
}
.setting-display {
width: 90vw;
flex-direction: column;
height: 22vh;
.display-divider {
width: 100%;
height: auto;
margin: 1vh 0;
}
}
.setting-info {
width: 90vw;
height: 30vh;
}
}
</style>

View File

@ -1,484 +0,0 @@
<template>
<div id="shop">
<div id="tools">
<span class="button-text" @click="exit"
><left-outlined /> 返回游戏</span
>
</div>
<span id="item-name">{{ info.name }}</span>
<Divider dashed style="border-color: #ddd4" id="divider"></Divider>
<div id="item-info">
<Scroll id="item-desc" :no-scroll="true">
<span v-html="desc"></span>
</Scroll>
</div>
<Divider
dashed
style="border-color: #ddd4"
id="divider-split"
></Divider>
<div id="shop-bottom">
<div id="item-list">
<Scroll style="width: 100%; height: 100%">
<div
class="selectable item-one"
v-for="(i, index) of choices"
:selected="index === selected"
@click="selected = index"
>
<div class="item-icon">
<BoxAnimate
:id="i.id"
:noborder="true"
></BoxAnimate>
<span>{{ all[i.id].name }}</span>
</div>
<span
>×&nbsp;{{
(mode === 'buy'
? i.number - (f.itemShop[id]?.[i.id] ?? 0)
: cnt(i.id)) + (update ? 0 : 0)
}}</span
>
</div>
</Scroll>
</div>
<Divider
dashed
style="border-color: #ddd4"
type="vertical"
id="divider-vertical"
></Divider>
<div id="item-sell-info">
<div id="shop-mode">
<span
class="button-text mode-button"
:active="mode === 'buy'"
@click="mode = 'buy'"
>购买</span
>
<Divider
dashed
type="vertical"
id="divider-vertical"
style="border-color: #ddd4"
></Divider>
<span
class="button-text mode-button"
:active="mode === 'sell'"
danger="true"
@click="mode = 'sell'"
>售出</span
>
</div>
<Divider
dashed
style="border-color: #ddd4"
id="divider-mode"
></Divider>
<div
class="item-sell-info"
:style="{
color:
mode === 'buy'
? nowMoney >= parseInt(item.money)
? 'lightgreen'
: 'lightcoral'
: 'white'
}"
>
<span>买价</span>
<span>{{ item.money }}</span>
</div>
<div
class="item-sell-info"
:style="{ color: mode === 'sell' ? 'lightcoral' : 'white' }"
>
<span>卖价</span>
<span>{{ item.sell }}</span>
</div>
<div class="item-sell-info">
<span>存货</span>
<span>{{ remain }}</span>
</div>
<div class="item-sell-info">
<span>拥有</span>
<span>{{ cnt(item.id) }}</span>
</div>
<Divider
dashed
style="border-color: #ddd4"
id="divider-mode"
></Divider>
<div id="sell-count">
<span>{{ mode === 'buy' ? '购买' : '售出' }}数量</span>
<div id="sell-count-select">
<double-left-outlined
class="button-text"
@click="count -= 10"
/>
<left-outlined class="button-text" @click="count--" />
<span id="fly-now">{{ count }}</span>
<right-outlined class="button-text" @click="count++" />
<double-right-outlined
class="button-text"
@click="count += 10"
/>
</div>
</div>
<Divider
dashed
style="border-color: #ddd4; margin: 2vh 0 2vh 0"
></Divider>
<div id="sell-confirm">
<span
id="sell-total"
:style="{
color:
mode === 'buy'
? nowMoney > cost
? 'lightgreen'
: 'lightcoral'
: 'lightcoral'
}"
>总价:{{
count *
parseInt(mode === 'buy' ? item.money : item.sell)
}}</span
>
<span
id="sell-button"
class="button-text"
:danger="mode === 'sell'"
danger-display="true"
active="true"
@click="confirm"
>确认{{ mode === 'buy' ? '购买' : '售出' }}</span
>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
LeftOutlined,
DoubleLeftOutlined,
RightOutlined,
DoubleRightOutlined
} from '@ant-design/icons-vue';
import { splitText } from '../utils';
import Scroll from '../components/scroll.vue';
import BoxAnimate from '../components/boxAnimate.vue';
import { gameKey } from '@motajs/system';
import { IMountedVBind } from '../interface';
import { tip } from '../use';
import { Divider } from 'ant-design-vue';
const props = defineProps<IMountedVBind>();
const id = props.shopId;
const shop = core.status.shops[id] as ItemShopEvent;
if (!shop.item) {
throw new TypeError(
`Wrong global shop type delivered in opening item shop.`
);
}
flags.itemShop ??= {};
const f = flags;
const choices = shop.choices;
const selected = ref(0);
const mode = ref<'sell' | 'buy'>('buy');
const all = core.material.items;
const cnt = core.itemCount;
const count = ref(0);
const nowMoney = ref(core.status.hero.money);
const update = ref(false);
let bought = false;
watch(count, n => {
if (n < 0) n = 0;
if (mode.value === 'buy') {
if (n > remain.value) n = remain.value;
} else {
const c = cnt(item.value.id);
if (n > c) n = c;
}
count.value = n;
});
const item = computed(() => {
return choices[selected.value];
});
const remain = computed(() => {
update.value;
return item.value.number - (flags.itemShop[id]?.[item.value.id] ?? 0);
});
const info = computed(() => {
return core.material.items[item.value.id];
});
const desc = computed(() => {
const text = info.value.text!;
const res = splitText([text.startsWith('!!html') ? text.slice(6) : text]);
return res;
});
const cost = computed(() => {
return (
count.value *
parseInt(mode.value === 'buy' ? item.value.money : item.value.sell)
);
});
watch(remain, n => {
if (n < count.value) {
count.value = n;
}
});
function confirm() {
if (count.value === 0) {
return;
}
const money = core.status.hero.money;
bought = true;
if (mode.value === 'buy') {
if (cost.value <= money) {
core.getItem(item.value.id, count.value);
core.status.hero.money -= cost.value;
nowMoney.value -= cost.value;
flags.itemShop[id] ??= {};
flags.itemShop[id][item.value.id] ??= 0;
flags.itemShop[id][item.value.id] += count.value;
tip('success', `成功购买${count.value}个${info.value.name}!`);
core.status.route.push(`buy:${item.value.id}:${count.value}`);
} else {
tip('error', '你的金币不够!');
}
} else {
core.addItem(item.value.id, -count.value);
core.status.hero.money += cost.value;
nowMoney.value += cost.value;
flags.itemShop[id] ??= {};
flags.itemShop[id][item.value.id] ??= 0;
flags.itemShop[id][item.value.id] -= count.value;
tip('success', `成功卖出${count.value}个${info.value.name}!`);
core.status.route.push(`sell:${item.value.id}:${count.value}`);
}
count.value = 0;
update.value = !update.value;
}
gameKey.use(props.ui.symbol);
gameKey
.realize('@shop_up', () => {
if (selected.value >= 1) {
selected.value--;
}
})
.realize('@shop_down', () => {
if (selected.value <= choices.length - 2) {
selected.value++;
}
})
.realize(
'@shop_add',
() => {
count.value++;
},
{ type: 'down-repeat' }
)
.realize(
'@shop_min',
() => {
count.value--;
},
{ type: 'down-repeat' }
)
.realize('exit', () => {
exit();
})
.realize('confirm', () => {
confirm();
})
.realize('shop', () => {
exit();
});
function exit() {
if (bought) core.status.route.push('closeShop');
props.controller.close(props.num);
}
onMounted(async () => {
core.lockControl();
});
onUnmounted(() => {
core.unlockControl();
gameKey.dispose(props.ui.symbol);
});
</script>
<style lang="less" scoped>
#shop {
width: 90vh;
height: 90vh;
font-family: 'normal';
font-size: 150%;
display: flex;
flex-direction: column;
user-select: none;
}
#tools {
height: 5vh;
font-size: 3.2vh;
}
#item-name {
width: 100%;
text-align: center;
font-size: 140%;
height: 5vh;
line-height: 1;
}
#item-info {
height: 24vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
#divider {
width: 100%;
margin: 1vh 0 1vh 0;
}
#divider-split {
margin: 1vh 0 0 0;
}
#divider-vertical {
height: 100%;
margin: 0;
}
#shop-bottom {
height: 53vh;
width: 100%;
display: flex;
flex-direction: row;
}
#item-list {
height: 53vh;
width: 53vh;
padding: 1vh 0 1vh 0;
}
#item-sell-info {
width: 47vh;
display: flex;
flex-direction: column;
align-items: center;
}
.item-icon {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
span {
margin-left: 5%;
}
}
.item-one {
display: flex;
justify-content: space-between;
flex-direction: row;
margin-bottom: 1vh;
}
#shop-mode {
width: 100%;
display: flex;
justify-content: space-around;
align-items: center;
height: 5vh;
}
.mode-button {
width: 100%;
text-align: center;
}
#divider-mode {
margin: 0;
}
.item-sell-info {
width: 60%;
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 1vh 0;
transition: color 0.2s linear;
}
#sell-count {
width: 100%;
padding-top: 1vh;
display: flex;
flex-direction: column;
align-items: center;
}
#sell-count-select {
margin-top: 1vh;
width: 90%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
}
#sell-confirm {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-around;
}
#sell-total {
transition: color 0.2s linear;
}
@media screen and (max-width: 600px) {
#shop {
width: 90vw;
padding-top: 5vh;
font-size: 225%;
}
#item-list {
width: 40vw;
}
#shop-bottom {
width: 90vw;
}
#item-sell-info {
width: 50vw;
}
}
</style>

View File

@ -1,437 +0,0 @@
<template>
<div id="toolbox">
<div id="tools">
<span class="button-text tools" @click="exit"
><left-outlined /> 返回游戏</span
>
<span class="button-text tools" @click="toEquip"
>装备栏 <right-outlined
/></span>
</div>
<div id="toolbox-main">
<div v-for="cls of toShow" class="item-main">
<div class="item-info">
<div class="item-type" v-if="!isMobile">
{{ getClsName(cls) }}
</div>
<div v-else id="item-type-mobile">
<span
class="button-text"
@click="mode = 'tools'"
:selected="mode === 'tools'"
>消耗道具</span
>
<Divider
dashed
style="border-color: #ddd4; height: 100%"
type="vertical"
></Divider>
<span
class="button-text"
@click="mode = 'constants'"
:selected="mode === 'constants'"
>永久道具</span
>
</div>
<Divider
dashed
style="margin: 1vh 0 1vh 0; border-color: #ddd4"
></Divider>
<Scroll class="item-list">
<div
class="item selectable"
v-for="[id, num] of items[cls]"
:selected="selected === id"
@click="
mode = cls;
select(id);
"
>
<div class="item-icon">
<BoxAnimate
:id="id"
:width="32"
:height="32"
:noborder="true"
></BoxAnimate>
<span class="item-name">{{
all[id].name
}}</span>
</div>
<span>×&nbsp;{{ num }}</span>
</div>
</Scroll>
</div>
<Divider
dashed
:type="isMobile ? 'horizontal' : 'vertical'"
class="divider"
style="border-color: #ddd4; margin: 1%"
></Divider>
</div>
<div id="detail">
<div id="info">
<BoxAnimate
:id="selected"
:width="32"
:height="32"
></BoxAnimate>
<div id="basic-info">
<span style="border-bottom: 1px solid #ddd4">{{
selected === 'none'
? '没有道具'
: all[selected].name
}}</span>
<span>{{
selected === 'none'
? '永久道具'
: (getClsName(all[selected].cls as ItemMode) ??
'永久道具')
}}</span>
</div>
</div>
<span style="margin-top: 2vh">点击该物品以使用</span>
<Divider dashed style="border-color: #ddd4"></Divider>
<div id="desc">
<span>道具描述</span>
<Scroll id="desc-text">
<div v-if="!descText.value!.startsWith('!!html')">
{{ descText.value }}
</div>
<div v-else v-html="descText.value!.slice(6)"></div>
</Scroll>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import Scroll from '../components/scroll.vue';
import BoxAnimate from '../components/boxAnimate.vue';
import { getClsName, getItems } from '../tools/toolbox';
import { isMobile } from '../use';
import { type } from '../utils';
import { hyper } from 'mutate-animate';
import { Divider, message } from 'ant-design-vue';
import { gameKey } from '@motajs/system';
import { IMountedVBind } from '../interface';
import { isNil } from 'lodash-es';
const props = defineProps<IMountedVBind>();
type ItemMode = 'tools' | 'constants';
type ShowItemIds = ItemIdOf<'constants' | 'tools'> | 'none';
const mode = ref<ItemMode>('tools');
const items = getItems('all');
const toShow = computed<ItemMode[]>(() => {
return isMobile ? [mode.value] : ['tools', 'constants'];
});
const all = core.material.items;
const selected = ref<ShowItemIds>(items[toShow.value[0]][0]?.[0] ?? 'none');
const index = ref(0);
watch(index, n => {
select(items[mode.value][n][0], true);
});
watch(mode, n => {
if (isNil(items[n][index.value])) {
selected.value = 'none';
return;
}
select(items[n][index.value][0], true);
});
const descText = computed(() => {
const s = selected.value;
if (s === 'none') return ref('没有选择道具');
return type(all[s].text!, 25, hyper('sin', 'out'), true);
});
/**
* 选择一个道具时
* @param id 道具id
*/
async function select(id: ShowItemIds, nouse: boolean = false) {
if (selected.value === id && !nouse) {
use(id);
}
index.value = items[mode.value].findIndex(v => v[0] === id);
selected.value = id;
}
function exit() {
props.controller.close(props.num);
}
function use(id: ShowItemIds) {
if (id === 'none') return;
if (core.canUseItem(id)) {
const hold = props.controller.holdOn();
exit();
nextTick(() => {
core.tryUseItem(id, false, () => {
if (props.controller.stack.length === 0) {
hold.end(core.status.event.id !== 'toolbox');
}
});
});
} else {
message.warn({
content: '当前无法使用该道具!',
class: 'antdv-message'
});
}
}
async function toEquip() {
props.controller.holdOn();
exit();
nextTick(() => {
props.controller.open('equipbox');
});
}
gameKey.use(props.ui.symbol);
gameKey
.realize(
'@toolbox_right',
() => {
const constants = items.constants.length;
if (mode.value === 'tools') {
if (index.value >= constants) {
index.value = constants - 1;
}
mode.value = 'constants';
}
},
{ type: 'down-repeat' }
)
.realize(
'@toolbox_left',
() => {
const constants = items.tools.length;
if (mode.value === 'constants') {
if (index.value >= constants) {
index.value = constants - 1;
}
mode.value = 'tools';
}
},
{ type: 'down-repeat' }
)
.realize(
'@toolbox_up',
() => {
if (index.value > 0) {
index.value--;
}
},
{ type: 'down-repeat' }
)
.realize(
'@toolbox_down',
() => {
const total = items[mode.value].length;
if (index.value < total - 1) {
index.value++;
}
},
{ type: 'down-repeat' }
)
.realize('exit', () => {
exit();
})
.realize('confirm', () => {
use(selected.value);
})
.realize('toolbox', () => {
exit();
});
onUnmounted(() => {
gameKey.dispose(props.ui.symbol);
});
</script>
<style lang="less" scoped>
#toolbox {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
#toolbox-main {
width: 100%;
height: 85vh;
display: flex;
flex-direction: row;
justify-content: space-between;
font-family: 'normal';
font-size: 150%;
user-select: none;
}
#tools {
width: 100%;
display: flex;
flex-direction: row;
font-family: 'normal';
font-size: 3.2vh;
height: 5vh;
justify-content: space-between;
.tools {
white-space: nowrap;
}
}
.item-main {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
.item-info {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
}
.item-list {
width: 100%;
height: 88vh;
}
.item-type {
width: 100%;
text-align: center;
}
#item-type-mobile {
width: 80%;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.button-text[selected='true'] {
color: aqua;
}
.item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 1vh 0 0 0;
padding: 0.5vh 0.5vw;
border: #0000 0.5px solid;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.item-icon {
display: flex;
flex-direction: row;
align-items: center;
.item-name {
margin-left: 5%;
}
}
}
.divider {
height: 100%;
}
#detail {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
#info {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
white-space: nowrap;
text-overflow: ellipsis;
#basic-info {
display: flex;
flex-direction: column;
border-left: 1px solid #ddd4;
padding-left: 5%;
margin-left: 10%;
}
}
#desc {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
flex-basis: 50%;
#desc-text {
margin-top: 2vh;
margin-left: 0.5vw;
width: 100%;
max-height: 100%;
}
}
}
@media screen and (max-width: 600px) {
#toolbox-main {
flex-direction: column-reverse;
height: 90%;
font-size: 225%;
margin-top: 10%;
}
.item-list {
width: 100%;
height: 40vh;
}
.divider {
height: auto;
width: 100%;
}
.item-main {
display: flex;
flex-direction: column-reverse;
}
#detail {
flex-basis: 30%;
#desc {
#desc-text {
max-height: 10vh;
height: 10vh;
}
}
}
}
</style>

View File

@ -1,41 +0,0 @@
import { KeyCode } from '@motajs/client-base';
import { KeyboardEmits, Keyboard, isAssist } from '@motajs/system';
import { mainUi } from './preset/uiIns';
/**
* 唤起虚拟键盘,并获取到一次按键操作
* @param emitAssist 是否可以获取辅助按键,为true时,如果按下辅助按键,那么会立刻返回该按键,
* 否则会视为开关辅助按键
* @param assist 初始化的辅助按键
*/
export function getVitualKeyOnce(
emitAssist: boolean = false,
assist: number = 0,
emittable: KeyCode[] = []
): Promise<KeyboardEmits> {
// todo: 正确触发后删除监听器
return new Promise(res => {
const key = Keyboard.get('full')!;
key.withAssist(assist);
const id = mainUi.open('virtualKey', { keyboard: key });
key.on('emit', (item, assist, _index, ev) => {
ev.preventDefault();
if (emitAssist) {
if (emittable.length === 0 || emittable.includes(item.key)) {
res({ key: item.key, assist: 0 });
key.disposeScope();
mainUi.close(id);
}
} else {
if (
!isAssist(item.key) &&
(emittable.length === 0 || emittable.includes(item.key))
) {
res({ key: item.key, assist });
key.disposeScope();
mainUi.close(id);
}
}
});
});
}

View File

@ -1,191 +0,0 @@
import { sleep } from 'mutate-animate';
import { message } from 'ant-design-vue';
import { MessageApi } from 'ant-design-vue/lib/message';
message.config({
maxCount: 3
});
export function tip(
type: Exclude<keyof MessageApi, 'open' | 'config' | 'destroy'>,
text: string
) {
message[type]({
content: text,
class: 'antdv-message'
});
}
let num = 0;
export function requireUniqueSymbol() {
return num++;
}
type DragFn = (x: number, y: number, e: MouseEvent | TouchEvent) => void;
type DragMap = [
(e: MouseEvent) => void,
(e: TouchEvent) => void,
((e: MouseEvent) => void)?,
((e: TouchEvent) => void)?
];
const dragFnMap = new Map<DragFn, DragMap>();
/**
* 是否是移动设备
*/
export let isMobile = matchMedia('(max-width: 600px)').matches;
let alerted = false;
window.addEventListener('resize', () => {
requestAnimationFrame(() => {
isMobile = matchMedia('(max-width: 600px)').matches;
checkMobile();
});
});
checkMobile();
sleep(2000).then(() => {
tip('info', `2.B 暂不支持竖屏游玩,手机端请考虑横屏游玩`);
});
function checkMobile() {
if (isMobile && !alerted) {
tip(
'info',
'手机端建议使用新版APP或者自带的浏览器进行游玩,并在进入游戏后开启游戏内的全屏设置游玩'
);
alerted = true;
}
}
/**
* 向一个元素添加拖拽事件
* @param ele 目标元素,当为全局拖拽时,传入数组表示所有元素共用一个全局拖拽函数
* @param fn 拖拽时触发的函数,传入x y和鼠标事件或点击事件
* @param ondown 鼠标按下时执行的函数
* @param global 是否全局拖拽,即拖拽后鼠标或手指离开元素后是否依然视为正在拖拽
*/
export function useDrag(
ele: HTMLElement | HTMLElement[],
fn: DragFn,
ondown?: DragFn,
onup?: (e: MouseEvent | TouchEvent) => void,
global: boolean = false
) {
const touchFn = (e: TouchEvent) => {
fn(e.touches[0].clientX, e.touches[0].clientY, e);
};
const mouseFn = (e: MouseEvent) => {
fn(e.clientX, e.clientY, e);
};
const mouseUp = (e: MouseEvent) => {
const ele = global ? document : e.target;
if (ele) {
(ele as HTMLElement).removeEventListener('mousemove', mouseFn);
}
onup?.(e);
};
const touchUp = (e: TouchEvent) => {
const ele = global ? document : e.target;
if (ele) {
(ele as HTMLElement).removeEventListener('touchmove', touchFn);
}
onup?.(e);
};
const md = (e: MouseEvent) => {
const ele = global ? document : e.target;
if (ele) {
(ele as HTMLElement).addEventListener('mousemove', mouseFn);
}
if (ondown) ondown(e.clientX, e.clientY, e);
};
const td = (e: TouchEvent) => {
const ele = global ? document : e.target;
if (ele) {
(ele as HTMLElement).addEventListener('touchmove', touchFn);
}
if (ondown) ondown(e.touches[0].clientX, e.touches[0].clientY, e);
};
if (ele instanceof Array) {
ele.forEach(v => {
v.addEventListener('mousedown', md);
v.addEventListener('touchstart', td);
});
} else {
ele.addEventListener('mousedown', md);
ele.addEventListener('touchstart', td);
}
const target = global ? document : ele;
if (target instanceof Array) {
target.forEach(v => {
v.addEventListener('mouseup', mouseUp);
v.addEventListener('touchend', touchUp);
});
} else {
target.addEventListener('mouseup', mouseUp as EventListener);
target.addEventListener('touchend', touchUp as EventListener);
}
dragFnMap.set(fn, [mouseUp, touchUp]);
}
/**
* 去除一个全局拖拽函数
* @param fn 要去除的函数
*/
export function cancelGlobalDrag(fn: DragFn): void {
const fns = dragFnMap.get(fn);
dragFnMap.delete(fn);
if (!fns) return;
document.removeEventListener('mouseup', fns[0]);
document.removeEventListener('touchend', fns[1]);
}
/**
* 当触发滚轮时执行函数
* @param ele 目标元素
* @param fn 当滚轮触发时执行的函数
*/
export function useWheel(
ele: HTMLElement,
fn: (x: number, y: number, z: number, e: WheelEvent) => void
): void {
ele.addEventListener('wheel', e => {
fn(e.deltaX, e.deltaY, e.deltaZ, e);
});
}
/**
* 当鼠标或手指松开时执行函数
* @param ele 目标元素
* @param fn 当鼠标或手指松开时执行的函数
*/
export function useUp(ele: HTMLElement, fn: DragFn): void {
ele.addEventListener('mouseup', e => {
fn(e.clientX, e.clientY, e);
});
ele.addEventListener('touchend', e => {
fn(e.touches[0].clientX, e.touches[0].clientY, e);
});
}
/**
* 当鼠标或手指按下时执行函数
* @param ele 目标元素
* @param fn 当鼠标或手指按下时执行的函数
*/
export function useDown(ele: HTMLElement, fn: DragFn): void {
ele.addEventListener('mousedown', e => {
fn(e.clientX, e.clientY, e);
});
ele.addEventListener('touchstart', e => {
fn(e.touches[0].clientX, e.touches[0].clientY, e);
});
}

View File

@ -1,386 +0,0 @@
import { isNil } from 'lodash-es';
import { Animation, sleep, TimingFn } from 'mutate-animate';
import { Ref, ref } from 'vue';
import axios from 'axios';
import { decompressFromBase64 } from 'lz-string';
import { logger } from '@motajs/common';
type CanParseCss = keyof {
[P in keyof CSSStyleDeclaration as CSSStyleDeclaration[P] extends string
? P extends string
? P
: never
: never]: CSSStyleDeclaration[P];
};
/**
* 根据伤害大小获取颜色
* @param damage 伤害大小
*/
export function getDamageColor(damage: number): string {
if (typeof damage !== 'number') return '#f00';
if (damage === 0) return '#2f2';
if (damage < 0) return '#7f7';
if (damage < core.status.hero.hp / 3) return '#fff';
if (damage < (core.status.hero.hp * 2) / 3) return '#ff4';
if (damage < core.status.hero.hp) return '#f93';
return '#f22';
}
/**
* 设置画布的长宽
* @param canvas 画布
* @param w 宽度
* @param h 高度
*/
export function setCanvasSize(
canvas: HTMLCanvasElement,
w: number,
h: number
): void {
canvas.width = w;
canvas.height = h;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
}
/**
* 解析css字符串为CSSStyleDeclaration对象
* @param css 要解析的css字符串
*/
export function parseCss(css: string): Partial<Record<CanParseCss, string>> {
if (css.length === 0) return {};
let pointer = -1;
let inProp = true;
let prop = '';
let value = '';
let upper = false;
const res: Partial<Record<CanParseCss, string>> = {};
while (++pointer < css.length) {
const char = css[pointer];
if ((char === ' ' || char === '\n' || char === '\r') && inProp) {
continue;
}
if (char === '-' && inProp) {
if (prop.length !== 0) {
upper = true;
}
continue;
}
if (char === ':') {
if (!inProp) {
logger.error(3, pointer.toString(), css);
return res;
}
inProp = false;
continue;
}
if (char === ';') {
if (prop.length === 0) continue;
if (inProp) {
logger.error(4, pointer.toString(), css);
return res;
}
res[prop as CanParseCss] = value.trim();
inProp = true;
prop = '';
value = '';
continue;
}
if (upper) {
if (!inProp) {
logger.error(5, pointer.toString(), css);
}
prop += char.toUpperCase();
upper = false;
} else {
if (inProp) prop += char;
else value += char;
}
}
if (inProp && prop.length > 0) {
logger.error(6, pointer.toString(), css);
return res;
}
if (!inProp && value.trim().length === 0) {
logger.error(7, pointer.toString(), css);
return res;
}
if (prop.length > 0) res[prop as CanParseCss] = value.trim();
return res;
}
export function stringifyCSS(css: Partial<Record<CanParseCss, string>>) {
let str = '';
for (const [key, value] of Object.entries(css)) {
let pointer = -1;
let prop = '';
while (++pointer < key.length) {
const char = key[pointer];
if (char.toLowerCase() === char) {
prop += char;
} else {
prop += `-${char.toLowerCase()}`;
}
}
str += `${prop}:${value};`;
}
return str;
}
/**
* 使用打字机效果显示一段文字
* @param str 要打出的字符串
* @param time 打出总用时,默认1秒,当第四个参数是true时,该项为每个字的平均时间
* @param timing 打出时的速率曲线,默认为线性变化
* @param avr 是否将第二个参数视为每个字的平均时间
* @returns 文字的响应式变量
*/
export function type(
str: string,
time: number = 1000,
timing: TimingFn = n => n,
avr: boolean = false
): Ref<string> {
const toShow = eval('`' + str + '`') as string;
if (typeof toShow !== 'string') {
throw new TypeError('Error str type in typing!');
}
if (toShow.startsWith('!!html')) return ref(toShow);
if (avr) time *= toShow.length;
const ani = new Animation();
const content = ref('');
const all = toShow.length;
const fn = (time: number) => {
if (isNil(time)) return;
const now = ani.x;
content.value = toShow.slice(0, Math.floor(now));
if (Math.floor(now) === all) {
ani.ticker.destroy();
content.value = toShow;
}
};
ani.ticker.add(fn);
ani.mode(timing).time(time).move(all, 0);
setTimeout(() => ani.ticker.destroy(), time + 100);
return content;
}
/**
* 设置文字分段换行等
* @param str 文字
*/
export function splitText(str: string[]) {
return str
.map((v, i, a) => {
if (/^\d+\./.test(v)) return `${'&nbsp;'.repeat(12)}${v}`;
else if (
(!isNil(a[i - 1]) && v !== '<br>' && a[i - 1] === '<br>') ||
i === 0
) {
return `${'&nbsp;'.repeat(8)}${v}`;
} else return v;
})
.join('');
}
/**
* 在下一帧执行某个函数
* @param cb 执行的函数
*/
export function nextFrame(cb: (time: number) => void) {
requestAnimationFrame(() => {
requestAnimationFrame(cb);
});
}
/**
* 下载一个画布对应的图片
* @param canvas 画布
* @param name 图片名称
*/
export function downloadCanvasImage(
canvas: HTMLCanvasElement,
name: string
): void {
const data = canvas.toDataURL('image/png');
download(data, name);
}
/**
* 下载一个文件
* @param content 下载的内容
* @param name 文件名称
*/
export function download(content: string, name: string) {
const a = document.createElement('a');
a.download = `${name}.png`;
a.href = content;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/**
* 间隔一段时间调用一个函数
* @param funcs 函数列表
* @param interval 调用间隔
*/
export async function doByInterval(
funcs: (() => void)[],
interval: number,
awaitFirst: boolean = false
) {
if (awaitFirst) {
await sleep(interval);
}
for await (const fn of funcs) {
fn();
await sleep(interval);
}
}
/**
* 更改一个本地存储
* @deprecated
* @param name 要更改的信息
* @param fn 更改时执行的函数
* @param defaultValue 如果不存在时获取的默认值
*/
export function changeLocalStorage<T>(
name: string,
fn: (data: T) => T,
defaultValue?: T
) {
const now = core.getLocalStorage(name, defaultValue);
const to = fn(now);
core.setLocalStorage(name, to);
}
export async function swapChapter(chapter: number, hard: number) {
const h = hard === 2 ? 'hard' : 'easy';
const save = await axios.get(
`${import.meta.env.BASE_URL}swap/${chapter}.${h}.h5save`,
{
responseType: 'text',
responseEncoding: 'utf-8'
}
);
const data = JSON.parse(decompressFromBase64(save.data));
core.loadData(data.data, () => {
core.removeFlag('__fromLoad__');
core.drawTip('读档成功');
});
}
export function ensureArray<T>(arr: T): T extends any[] ? T : T[] {
// @ts-expect-error 暂时无法推导
return arr instanceof Array ? arr : [arr];
}
export async function triggerFullscreen(full: boolean) {
if (!!document.fullscreenElement && !full) {
if (window.jsinterface) {
window.jsinterface.requestPortrait();
return;
}
await document.exitFullscreen();
}
if (full && !document.fullscreenElement) {
if (window.jsinterface) {
window.jsinterface.requestLandscape();
return;
}
await document.body.requestFullscreen();
}
}
/**
* 获得某个状态的中文名
* @param name 要获取的属性名
*/
export function getStatusLabel(name: string) {
return (
{
name: '名称',
lv: '等级',
hpmax: '生命回复',
hp: '生命',
manamax: '魔力上限',
mana: '额外攻击',
atk: '攻击',
def: '防御',
mdef: '智慧',
money: '金币',
exp: '经验',
point: '加点',
steps: '步数',
up: '升级',
magicDef: '魔法防御',
none: '无'
}[name] || name
);
}
export function formatSize(size: number) {
return size < 1 << 10
? `${size.toFixed(2)}B`
: size < 1 << 20
? `${(size / (1 << 10)).toFixed(2)}KB`
: size < 1 << 30
? `${(size / (1 << 20)).toFixed(2)}MB`
: `${(size / (1 << 30)).toFixed(2)}GB`;
}
export function getIconHeight(icon: AllIds | 'hero') {
if (icon === 'hero') {
if (core.isPlaying()) {
return (
core.material.images.images[core.status.hero.image].height / 4
);
} else {
return 48;
}
}
return core.getBlockInfo(icon)?.height ?? 32;
}
const ascii = 16 ** 2;
const other = 16 ** 4;
const emoji = 16 ** 5;
export function calStringSize(str: string) {
let size = 0;
for (const char of str) {
const num = char.charCodeAt(0);
if (num < ascii) size += 1;
else if (num < other) size += 2;
else if (num < emoji) size += 2.5;
}
return size;
}
export function clamp(num: number, start: number, end: number) {
const s = Math.min(start, end);
const e = Math.max(start, end);
if (num < s) return s;
else if (num > e) return e;
return num;
}