diff --git a/packages-user/client-base/src/layout/layout.ts b/packages-user/client-base/src/layout/layout.ts index aa15c52..3ddfa8c 100644 --- a/packages-user/client-base/src/layout/layout.ts +++ b/packages-user/client-base/src/layout/layout.ts @@ -16,7 +16,7 @@ export interface IGridLayoutData { } /** - * 网格布局 + * 网格布局,如果是指定元素的数量,那么用 `adjustGrid2` * @param width 总宽度 * @param height 总高度 * @param itemWidth 每个元素的宽度 @@ -58,6 +58,49 @@ export function adjustGrid( }; } +/** + * 网格布局,如果是指定元素宽高,那么用 `adjustGrid` + * @param width 总宽度 + * @param height 总高度 + * @param xCount 横向有多少个元素 + * @param yCount 纵向有多少个元素 + * @param intervalX 元素的横向间隔 + * @param intervalY 元素的纵向间隔,不填时等于横向间隔 + * @returns 网格布局信息 + */ +export function adjustGrid2( + width: number, + height: number, + xCount: number, + yCount: number, + intervalX: number, + intervalY: number = intervalX +) { + const itemWidth = (width - intervalX * (xCount - 1)) / xCount; + const itemHeight = (height - intervalY * (yCount - 1)) / yCount; + const rawWidth = (itemWidth + intervalX) * xCount - intervalX; + const rawHeight = (itemHeight + intervalY) * yCount - intervalY; + + const locs: ElementLocator[] = []; + + for (let y = 0; y < yCount; y++) { + const iy = (intervalY + itemHeight) * y; + for (let x = 0; x < xCount; x++) { + const ix = (intervalX + itemWidth) * x; + locs.push([ix, iy, itemWidth, itemHeight, 0, 0]); + } + } + + return { + cols: xCount, + rows: yCount, + count: xCount * yCount, + width: rawWidth, + height: rawHeight, + locs + }; +} + /** * 适配为覆盖模式,类似于 `object-fit: cover` * @param itemWidth 元素宽度 diff --git a/packages-user/client-base/src/types.ts b/packages-user/client-base/src/types.ts index cdf7224..4b5fb53 100644 --- a/packages-user/client-base/src/types.ts +++ b/packages-user/client-base/src/types.ts @@ -15,7 +15,7 @@ import { IClientHotkey } from './hotkey'; export interface IGameUIPropsBase< T extends UIComponent = UIComponent -> extends IUIDefaultPropsBase { +> extends IUIDefaultPropsBase> { readonly state: IClientBase; } diff --git a/packages-user/client-modules/src/ui/index.ts b/packages-user/client-modules/src/ui/index.ts index 00b35c5..c06f7c0 100644 --- a/packages-user/client-modules/src/ui/index.ts +++ b/packages-user/client-modules/src/ui/index.ts @@ -1,2 +1 @@ -export * from './types'; export * from './func'; diff --git a/packages-user/client-modules/src/ui/types.ts b/packages-user/client-modules/src/ui/types.ts deleted file mode 100644 index 8a92889..0000000 --- a/packages-user/client-modules/src/ui/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { IUIDefaultPropsBase } from '@motajs/system'; -import { IClientBaseExtended } from '@user/client-base'; - -export interface IUIPropsBase - extends IUIDefaultPropsBase, IClientBaseExtended {} diff --git a/packages-user/data-system/src/combat/types.ts b/packages-user/data-system/src/combat/types.ts index c85cecc..b3944e3 100644 --- a/packages-user/data-system/src/combat/types.ts +++ b/packages-user/data-system/src/combat/types.ts @@ -427,7 +427,7 @@ export interface IDamageCalculator { export interface IDamageContext extends IDataBaseExtended { /** - * 获取战斗伤害信息 + * 获取战斗伤害信息,会使用怪物的计算后属性计算伤害,等价于 `getDamageInfoByComputed(view.getComputedEnemy)` * @param enemy 怪物视图 */ getDamageInfo( diff --git a/packages/system/src/ui/types.ts b/packages/system/src/ui/types.ts index 2a24550..355dfc4 100644 --- a/packages/system/src/ui/types.ts +++ b/packages/system/src/ui/types.ts @@ -24,11 +24,14 @@ export type SetupComponentOptions< export type UIComponent = DefineSetupFnComponent | DefineComponent; -export interface IUIDefaultPropsBase { +export interface IUIDefaultPropsBase< + T extends UIComponent = UIComponent, + PB extends IUIDefaultPropsBase = IUIDefaultPropsBase +> { /** 当前打开的 UI 所属的控制器 */ - readonly controller: IUIController; + readonly controller: IUIController; /** 当前 UI 实例对象 */ - readonly instance: IUIInstance; + readonly instance: IUIInstance; } export interface IGameUI { diff --git a/src/client/plugin/shop/shop.tsx b/src/client/plugin/shop/shop.tsx index 5b3aff7..c15dc56 100644 --- a/src/client/plugin/shop/shop.tsx +++ b/src/client/plugin/shop/shop.tsx @@ -4,11 +4,12 @@ import { Font } from '@motajs/render'; import { Scroll, TextContent } from '@user/client-base'; import { IHeroState } from '@user/data-base'; import { IHeroAttr } from '@user/data-common'; -import { IUIPropsBase } from '@user/client-modules'; +import { IGameUIPropsBase } from '@user/client-base'; import { IShopState, IShopSystem } from '@/data/plugin/shop'; import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH, RENDER_WIDTH } from '@/client'; +import { DefaultProps } from '@motajs/render-vue'; -export interface IShopProps extends IUIPropsBase { +export interface IShopProps extends IGameUIPropsBase, DefaultProps { /** 商店状态对象 */ shop: IShopState; /** 勇士状态对象 */ diff --git a/src/client/ui/__test__/hotkeySettingsGate.test.ts b/src/client/ui/__test__/hotkeySettingsGate.test.ts index 014aba8..8bc507b 100644 --- a/src/client/ui/__test__/hotkeySettingsGate.test.ts +++ b/src/client/ui/__test__/hotkeySettingsGate.test.ts @@ -4,6 +4,7 @@ import typesSource from '../../../../packages-user/client-base/src/hotkey/types. import implSource from '../../../../packages-user/client-base/src/hotkey/hotkey.ts?raw'; import settingsSource from '../settings.tsx?raw'; import uiIndexSource from '../index.ts?raw'; +import listSource from '../../../../packages-user/client-base/src/components/list.tsx?raw'; // 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红 function stripComments(source: string): string { @@ -97,9 +98,15 @@ describe('热键设置源码闸门', () => { expect(hotkeySource).toContain('iterateKeyCombinations('); }); - // 关闭路径使用公共退出命令与返回文本 - it('closes through the common exit command and the back text', () => { + // 关闭路径使用公共退出命令,关闭文本由 ListPage 内置的「关闭」提供 + it('closes through the common exit command and the ListPage close text', () => { expect(hotkeySource).toContain('@common/exit'); - expect(hotkeySource).toContain('返回'); + expect(hotkeySource).toContain('ListPage'); + expect(/]*\sclose(?=[\s>])/.test(hotkeySource)).toBe(true); + expect(hotkeySource).toContain('closeLoc={CLOSE_LOC}'); + expect(hotkeySource).toContain('onClose={close}'); + expect(hotkeySource).not.toContain('text="返回"'); + expect(hotkeySource).not.toContain('text="关闭"'); + expect(listSource).toContain('text="关闭"'); }); }); diff --git a/src/client/ui/__test__/legacyUiParityGate.test.ts b/src/client/ui/__test__/legacyUiParityGate.test.ts new file mode 100644 index 0000000..0edbb32 --- /dev/null +++ b/src/client/ui/__test__/legacyUiParityGate.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { adjustGrid } from '@user/client-base/layout/layout'; +import enemyOneSource from '../enemyOne.tsx?raw'; +import bookSource from '../book.tsx?raw'; +import bookDetailSource from '../bookDetail.tsx?raw'; +import toolboxSource from '../toolbox.tsx?raw'; +import equipboxSource from '../equipbox.tsx?raw'; +import hotkeySource from '../../misc/hotkey.ts?raw'; + +// 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红 +function stripComments(source: string): string { + return source + .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .split(/\r?\n/) + .map(line => line.replace(/\/\/.*$/, '')) + .join('\n'); +} + +describe('09-42 旧版 UI 对齐源码闸门', () => { + // EnemyOne 必须导出组件与三个共享辅助函数,并具备选中描边。 + it('exposes the reusable EnemyOne component and its helpers', () => { + expect(enemyOneSource).toContain('export const EnemyOne'); + expect(enemyOneSource).toContain('export function getEnemyDamageData'); + expect(enemyOneSource).toContain('export function getDamageColor'); + expect(enemyOneSource).toContain('export function getSpecialSummary'); + expect(enemyOneSource).toContain('adjustGrid(180, 72, 56, 20, 4, 4)'); + expect(enemyOneSource).toContain(' { + expect(bookSource).toContain('EnemyOne'); + expect(bookSource).toContain('getEnemyDamageData'); + expect(bookSource).toContain('getSpecialSummary'); + expect(bookSource).toContain('useKey'); + expect(bookSource).toContain('@ui/book-up'); + expect(bookSource).toContain('@ui/book-down'); + expect(bookSource).toContain('@ui/book-page-up'); + expect(bookSource).toContain('@ui/book-page-down'); + expect(bookSource).toContain('@common/confirm'); + expect(bookSource).toContain('@common/exit'); + expect(bookSource).toContain('ScrollExpose'); + expect(bookSource).toContain('scrollTo'); + expect(bookSource).toContain('本层无怪物'); + expect(bookSource).toContain('selected'); + expect(bookSource).toContain('openDetail'); + }); + + // bookDetail 必须为单页三区结构,临界表固定 8 行并随 current 重算。 + it('renders the single-page detail with the critical table', () => { + expect(bookDetailSource).toContain('EnemyOne'); + expect(bookDetailSource).toContain( + 'adjustGrid(548, 104, 260, 12, 20, 1)' + ); + expect(bookDetailSource).toContain('criticlas'); + expect(bookDetailSource).toContain('加攻'); + expect(bookDetailSource).toContain('减伤'); + expect(bookDetailSource).toMatch(/CRITICAL_COUNT\s*=\s*8/); + expect(bookDetailSource).toContain('TextContent'); + expect(bookDetailSource).toContain('@common/exit'); + expect(bookDetailSource).toContain('@common/confirm'); + expect(bookDetailSource).toContain('watch(current,'); + expect(bookDetailSource).toContain('{ immediate: true }'); + expect(bookDetailSource).toContain('getDamageSystem()'); + expect(bookDetailSource).toContain('无怪物数据'); + expect(bookDetailSource).toContain('locator'); + }); + + // toolbox 必须具备双列表详情、使用流程、交叉导航与 mode/index 选择模型。 + it('implements the toolbox lists, usage flow and cross navigation', () => { + expect(toolboxSource).toContain('EquipBoxUI'); + expect(toolboxSource).toContain('attributes'); + expect(toolboxSource).toContain('props.attributes ?? []'); + expect(toolboxSource).toContain('keep('); + expect(toolboxSource).toContain('useItem'); + expect(toolboxSource).toContain('当前无法使用该道具'); + expect(toolboxSource).toContain('@ui/toolbox'); + expect(toolboxSource).toContain('@common/select-left'); + expect(toolboxSource).toContain('@common/select-right'); + expect(toolboxSource).toContain('@common/select-up'); + expect(toolboxSource).toContain('@common/select-down'); + expect(toolboxSource).toContain('@common/confirm'); + expect(toolboxSource).toContain('@common/exit'); + expect(toolboxSource).toContain('TextContent'); + expect(toolboxSource).toContain('装备栏'); + expect(toolboxSource).toContain('永久道具'); + expect(toolboxSource).toContain('消耗道具'); + expect(toolboxSource).toContain('点击该物品以使用'); + expect(toolboxSource).toContain("'consumable'"); + expect(toolboxSource).toContain('index = ref(0)'); + expect(toolboxSource).toContain( + "mode.value === 'permanent' && i === index.value" + ); + expect(toolboxSource).not.toContain('selected = ref(-1)'); + }); + + // equipbox 只新增道具栏导航、跨槽拖拽与键盘关闭,保留现有布局。 + it('adds only the three equipbox behaviors without legacy layout', () => { + expect(equipboxSource).toContain('ToolBoxUI'); + expect(equipboxSource).toContain('keep('); + expect(equipboxSource).toContain('@common/exit'); + expect(equipboxSource).toContain('@ui/command'); + expect(equipboxSource).toContain('endPress'); + expect(equipboxSource).toContain('target'); + expect(equipboxSource).toContain('equip('); + expect(equipboxSource).toContain('STATUS_WIDTH'); + expect(equipboxSource).toContain('OWNED_TOP'); + }); + + // 热键命令必须存在且各声明一次,本计划不修改热键文件。 + it('declares each required hotkey command exactly once', () => { + const commands = [ + '@ui/book-up', + '@ui/book-down', + '@ui/book-page-up', + '@ui/book-page-down', + '@ui/toolbox', + '@common/confirm', + '@common/exit' + ]; + for (const command of commands) { + const count = hotkeySource.split(command).length - 1; + expect(count).toBe(1); + } + }); + + // 共享排除项不得出现在五个 UI 源码中。 + it('keeps the shared exclusions out of the ui sources', () => { + const uiSources = [ + enemyOneSource, + bookSource, + bookDetailSource, + toolboxSource, + equipboxSource + ] + .map(stripComments) + .join('\n'); + expect(uiSources).not.toContain('isMobile'); + expect(uiSources).not.toContain('@media'); + expect(uiSources).not.toContain('window.innerWidth'); + expect(uiSources).not.toContain('innerHTML'); + expect(uiSources).not.toContain('v-html'); + expect(uiSources).not.toContain('!!html'); + expect(uiSources).not.toContain('chart.js'); + expect(uiSources).not.toContain('Slider'); + expect(uiSources).not.toContain('EnemyTarget'); + expect(uiSources).not.toContain('bookScale'); + }); + + // adjustGrid 的 3×3 与临界表 8×2 数学结果必须与源码参数一致。 + it('computes the expected grid math for both layouts', () => { + const stats = adjustGrid(180, 72, 56, 20, 4, 4); + expect(stats.cols).toBe(3); + expect(stats.rows).toBe(3); + expect(stats.locs.length).toBe(9); + + const critical = adjustGrid(548, 104, 260, 12, 20, 1); + expect(critical.cols).toBe(2); + expect(critical.rows).toBe(8); + expect(critical.locs.length).toBe(16); + }); +}); diff --git a/src/client/ui/__test__/settingPageGate.test.ts b/src/client/ui/__test__/settingPageGate.test.ts new file mode 100644 index 0000000..2bf9a50 --- /dev/null +++ b/src/client/ui/__test__/settingPageGate.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import settingSource from '../setting.tsx?raw'; +import uiIndexSource from '../index.ts?raw'; +import settingsSource from '../settings.tsx?raw'; +import barrelSource from '../../../../packages-user/client-base/src/setting/index.ts?raw'; +import systemSource from '../../../../packages-user/client-base/src/setting/system.ts?raw'; +import factoriesSource from '../../../../packages-user/client-base/src/setting/factories.ts?raw'; +import controlsSource from '../../../../packages-user/client-base/src/setting/controls.tsx?raw'; + +// 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红 +function stripComments(source: string): string { + return source + .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .split(/\r?\n/) + .map(line => line.replace(/\/\/.*$/, '')) + .join('\n'); +} + +// 事件属性直接写行内匿名函数的形态 +const INLINE_HANDLER = /on[A-Z][A-Za-z]*=\{\s*(\(|[A-Za-z_$][\w$]*\s*=>)/; +// 在 JSX 属性中直接展开多行 loc 数组的形态 +const MULTILINE_LOC = /loc=\{\[\s*\r?\n/; + +describe('设置页面源码闸门', () => { + // 界面导出、props 声明与界面对象都必须存在 + it('declares the page props and exports the page ui', () => { + const source = stripComments(settingSource); + expect(source).toContain('SettingPageProps extends IGameUIPropsBase'); + expect(source).toContain('readonly system: ISettingSystem'); + expect(source).toContain('SettingPageUI'); + expect(source).toContain("new GameUI('setting-page'"); + }); + + // 左栏使用 ListPage 并同时提供热键与内置关闭文本两条关闭路径 + it('uses ListPage and closes through the common exit command', () => { + const source = stripComments(settingSource); + expect(source).toMatch(/ { + const source = stripComments(settingSource); + expect(source).toContain('getSettingComponent()'); + expect(source).toContain('setting={item}'); + expect(source).toContain('controller={props.controller}'); + expect(source).toContain('{{ default: renderGroup }}'); + }); + + // 界面与控件都不得出现行内匿名事件处理函数 + it('uses named handlers instead of inline handlers', () => { + expect(INLINE_HANDLER.test(stripComments(settingSource))).toBe(false); + expect(INLINE_HANDLER.test(stripComments(controlsSource))).toBe(false); + }); + + // 界面与控件都不得在 JSX 属性中直接展开多行 loc 数组 + it('keeps multiline loc arrays out of jsx attributes', () => { + expect(MULTILINE_LOC.test(settingSource)).toBe(false); + expect(MULTILINE_LOC.test(controlsSource)).toBe(false); + }); + + // 界面层不得包含任何持久化与渲染端主对象设置字段 + it('keeps persistence and main object fields out of the page', () => { + const source = stripComments(settingSource); + for (const token of ['saveState', 'loadState', 'globalSetting']) { + expect(source).not.toContain(token); + } + expect(source).not.toContain('gameSettings'); + }); + + // client-base 的设置实现不得构造界面对象,也不得引用存档注册与主对象 + it('keeps ui object construction out of client-base', () => { + for (const source of [systemSource, factoriesSource, controlsSource]) { + expect(/new\s+GameUI\s*\(/.test(source)).toBe(false); + } + const system = stripComments(systemSource); + expect(system).not.toContain('IClientBase'); + expect(system).not.toContain('addSaveableContent'); + }); + + // 分层:控件只存在于 controls.tsx,界面文件不定义设置组件 props + it('keeps control definitions inside the controls module', () => { + expect(stripComments(systemSource)).not.toContain('defineComponent'); + expect(stripComments(factoriesSource)).not.toContain('defineComponent'); + expect(settingSource).not.toContain('interface ISettingComponentProps'); + }); + + // 设置桶恰好导出四个同目录模块,界面未接入 UI 桶与设置菜单 + it('leaves the barrel and the settings entry untouched', () => { + const barrel = stripComments(barrelSource) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + expect(barrel).toEqual([ + "export * from './controls';", + "export * from './factories';", + "export * from './system';", + "export * from './types';" + ]); + expect(uiIndexSource).not.toContain("'./setting'"); + expect(settingsSource).not.toContain('SettingPage'); + }); + + // 工厂与预览的源码形状,替代因渲染依赖需要 DOM 而无法运行期测试的工厂测试 + it('declares the four factories with clamped numeric values', () => { + const source = stripComments(factoriesSource); + for (const token of [ + 'export function defineNumberSetting', + 'export function defineBooleanSetting', + 'export function defineRangeSetting', + 'export function defineSubPageSetting', + 'implements ISettingItem', + 'implements ISettingItem', + 'implements ISettingItem', + 'clampSettingValue', + 'Math.round', + 'Math.min', + 'Math.max', + 'toFixed(6)', + 'private readonly control', + 'shallowRef' + ]) { + expect(source).toContain(token); + } + expect(source).toContain("'开'"); + expect(source).toContain("'关'"); + expect(source).toContain('return void 0;'); + }); + + // 四个控件创建函数与区间控件的拖拽事件、子页面打开调用必须存在 + it('declares the four controls with drag and page opening', () => { + const source = stripComments(controlsSource); + for (const token of [ + 'createNumberSettingControl', + 'createBooleanSettingControl', + 'createRangeSettingControl', + 'createSubPageSettingControl', + 'onDown', + 'onMove', + 'onUp', + '.open(' + ]) { + expect(source).toContain(token); + } + }); + + // 空系统只渲染一个空滚动区占位,并以显式渲染函数返回 + it('renders an empty scroll placeholder and an explicit render function', () => { + const source = stripComments(settingSource); + expect(source).toContain(''); + expect(source).toContain('return () => renderPage();'); + }); +}); diff --git a/src/client/ui/__test__/uiContainerGate.test.ts b/src/client/ui/__test__/uiContainerGate.test.ts new file mode 100644 index 0000000..f9f911a --- /dev/null +++ b/src/client/ui/__test__/uiContainerGate.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, it } from 'vitest'; +import bookSource from '../book.tsx?raw'; +import bookDetailSource from '../bookDetail.tsx?raw'; +import equipBoxSource from '../equipbox.tsx?raw'; +import toolBoxSource from '../toolbox.tsx?raw'; +import hotkeySource from '../hotkey.tsx?raw'; +import textBoxSource from '../../../../packages-user/client-base/src/components/textbox.tsx?raw'; +import listSource from '../../../../packages-user/client-base/src/components/list.tsx?raw'; + +// 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红 +function stripComments(source: string): string { + return source + .replace(/\{[ \t]*\/\*[^]*?\*\/[ \t]*\}/g, '') + .replace(/\/\*[^]*?\*\//g, '') + .split(/\r?\n/) + .map(line => line.replace(/\/\/.*$/, '')) + .join('\n'); +} + +// 把跨行书写的表达式压成单行,用于肯定断言 +function flatten(source: string): string { + return source.replace(/\s+/g, ' ').trim(); +} + +// 逐段取出每个 Scroll 块,用于按位置断言其内部结构 +function getScrollBlocks(source: string): string[] { + const blocks: string[] = []; + let index = 0; + while (true) { + const start = source.indexOf('', start); + if (end < 0) break; + blocks.push(source.slice(start, end)); + index = end + ''.length; + } + return blocks; +} + +// 取出包含 needle 的那个自闭合元素标签原文,用于把 noevent 配对变成逐元素断言 +function tagAround(source: string, tag: string, needle: string): string { + const i = source.indexOf(needle); + if (i < 0) return ''; + const start = source.lastIndexOf('<' + tag, i); + const end = source.indexOf('/>', i); + if (start < 0 || end < 0) return ''; + return source.slice(start, end + 2); +} + +describe('列表平铺源码闸门', () => { + // Scroll 内必须平铺:块内既不能有 container,也不能有 hidden + it('keeps every Scroll child flat and free of hidden toggles', () => { + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource, + hotkeySource + ]; + for (const raw of sources) { + const blocks = getScrollBlocks(stripComments(raw)); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(block).not.toMatch(/])/); + expect(block).not.toMatch(/hidden\s*=/); + } + } + }); + + // 四个列表文件各保留一个根 container,hotkey 改由 ListPage 承载后不再自带 container + it('keeps only the root container in each list file', () => { + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource + ]; + for (const raw of sources) { + const source = stripComments(raw); + expect(source.match(/])/g)).toHaveLength(1); + } + const hotkey = stripComments(hotkeySource); + expect(hotkey).not.toMatch(/])/); + expect(hotkey.match(/])/g)).toHaveLength(1); + expect(hotkey.match(/])/g)).toHaveLength(1); + const equipFlat = flatten(stripComments(equipBoxSource)); + expect(equipFlat).not.toContain( + 'RIGHT_LEFT, DESC_TOP, RIGHT_WIDTH, DESC_HEIGHT' + ); + expect(equipFlat).not.toContain( + 'RIGHT_LEFT, OWNED_TOP, RIGHT_WIDTH, OWNED_HEIGHT' + ); + }); + + // hotkey 的分组改由 ListPage 承载,右栏只剩一条 Scroll,选中态仍用按键框边框 + it('renders the hotkey groups through ListPage and keeps the key box border', () => { + const source = stripComments(hotkeySource); + expect(getScrollBlocks(source)).toHaveLength(1); + const flat = flatten(source); + expect(source).toMatch( + /import\s*\{[^}]*ListPage[^}]*\}\s*from\s*'@user\/client-base'/ + ); + expect(flat).toContain('ListPage'); + expect(flat).toContain('basis={GROUP_BASIS}'); + expect(flat).toContain('list={groupItems}'); + expect(flat).toContain('selected={selectedGroup.value}'); + expect(flat).toContain('lineHeight={GROUP_ROW_HEIGHT}'); + expect(flat).toContain('font={CONTENT_FONT}'); + expect(source).toMatch(/]*\sclose(?=[\s>])/); + expect(flat).toContain('closeLoc={CLOSE_LOC}'); + expect(flat).toContain('onUpdate={selectGroup}'); + expect(flat).toContain('onClose={close}'); + expect(flat).toContain('default:'); + expect(flat).toContain('COMMAND_SCROLL_LOC'); + expect(flat).toContain('commandRows'); + expect(flat).toContain('ICommandRow'); + expect(flat).not.toContain('getCommandTop'); + expect(flat).not.toContain('getCommandHeight'); + expect(flat).not.toContain('getContentHeight'); + expect(source).not.toMatch(/Selection/); + expect(source).not.toContain('GROUP_TEXT_LEFT'); + expect(source).not.toContain('RIGHT_LEFT'); + expect(source).not.toContain('RIGHT_WIDTH'); + expect(source).not.toContain('selectGroup(id)'); + const keyHit = tagAround( + source, + 'g-rectr', + 'getKeyClick(row.command, j)' + ); + expect(keyHit).toContain('strokeStyle'); + expect(keyHit).toContain('lineWidth'); + expect(keyHit).toContain('isSelected'); + expect(source.match(/strokeStyle=\{\s*isSelected\s*\?/g)).toHaveLength( + 1 + ); + expect(source.match(/lineWidth=\{\s*isSelected\s*\?/g)).toHaveLength(1); + expect(source).not.toContain('if (isSelected)'); + expect(source).not.toContain('hidden={!isSelected}'); + expect(source).not.toContain('{isSelected &&'); + expect(source).not.toContain('isSelected && groupNodes.push'); + expect(source).not.toContain('isSelected && commandNodes.push'); + }); + + // book 四项临界信息缺失时回退为 0,且不再整项条件渲染 + it('falls back to zero for the four critical fields in book', () => { + const source = stripComments(bookSource); + const flat = flatten(source); + expect(flat).toContain( + 'const damage = critical ? critical.baseInfo.damage : 0;' + ); + expect(flat).toContain( + 'const nextDiff = critical ? critical.nextDiff : 0;' + ); + expect(flat).toContain( + 'const damageDiff = critical ? -critical.damageDiff : 0;' + ); + expect(flat).toContain( + 'const def1Damage = critical && def1 ? critical.baseInfo.damage - def1.baseInfo.damage : 0;' + ); + expect(flat).toContain('伤害'); + expect(flat).toContain('临界'); + expect(flat).toContain('减伤'); + expect(flat).toContain('1 防'); + expect(source).not.toMatch(/\{\s*critical\s*&&/); + expect(source).not.toContain('def1 && ('); + }); + + // bookDetail 两项临界信息缺失时回退为 0 + it('falls back to zero for both critical fields in book detail', () => { + const source = stripComments(bookDetailSource); + const flat = flatten(source); + expect(flat).toContain( + 'format(critical.value ? critical.value.nextDiff : 0)' + ); + expect(flat).toContain( + 'format(critical.value ? -critical.value.damageDiff : 0)' + ); + expect(flat).toContain('加攻'); + expect(flat).toContain('减伤'); + expect(source).not.toMatch(/\{\s*critical\.value\s*&&/); + }); + + // bookDetail 用高度回调计算每行位置,且不再用 key 触发重挂载 + it('measures special rows through the height callback without remount keys', () => { + const flat = flatten(stripComments(bookDetailSource)); + expect(flat).toContain('autoHeight'); + expect(flat).toContain('onUpdateHeight'); + expect(flat).toContain('textHeights'); + expect(flat).toContain('specialRows'); + expect(flat).toContain('SPECIAL_MIN_HEIGHT'); + expect(flat).toContain('getDescription()'); + expect(flat).toContain('textTop'); + expect(flat).toContain('textHeight'); + expect(flat).toContain('ISpecialRow'); + expect(flat).toContain('ISpecial'); + expect(flat).toContain('cumsum(hs)'); + expect(flat).toContain('ends[i] - hs[i]'); + expect(flat).not.toContain('top +='); + }); + + // 显示开关按位置分流,且命中层与被覆盖元素逐个配对 + it('splits display toggles by position and pairs hit layers with noevent', () => { + const equipSource = stripComments(equipBoxSource); + const equipFlat = flatten(equipSource); + expect(equipFlat).toContain('hidden={!!equip}'); + expect(equipFlat).toContain('hidden={!equip}'); + expect(equipFlat).toContain('hidden={!dragging.value}'); + expect(equipFlat).toContain("delta === 0 ? ''"); + expect(equipFlat).toContain("equip ? equip.item.text : ''"); + expect(equipFlat).toContain('slotEquip ? slotEquip.item.num : 0'); + expect(equipSource).not.toContain('{!equip &&'); + expect(equipSource).not.toContain('{equip &&'); + expect(equipSource).not.toContain('{delta !== 0 &&'); + expect(equipSource).not.toContain('{slotEquip &&'); + expect(equipSource).not.toContain('{dragging.value &&'); + expect(equipSource).not.toContain('hidden={!slotEquip}'); + expect(equipSource).not.toContain('hidden={delta === 0}'); + const toolSource = stripComments(toolBoxSource); + const toolFlat = flatten(toolSource); + expect(toolFlat).toContain('hidden={!!current}'); + expect(toolFlat).toContain("current ? current.raw.text : ''"); + expect(toolSource).not.toContain('{!current &&'); + expect(toolSource).not.toContain('{current &&'); + expect(toolSource).not.toContain('hidden={!current}'); + const hintIndex = toolSource.indexOf('hidden={!!current}'); + const detailIndex = toolSource.indexOf( + "current ? current.raw.text : ''" + ); + expect(hintIndex).toBeGreaterThanOrEqual(0); + expect(detailIndex).toBeGreaterThan(hintIndex); + const hotkeyFlat = flatten(stripComments(hotkeySource)); + expect(hotkeyFlat).not.toContain('hidden={!isSelected}'); + expect(hotkeyFlat).not.toContain('{isSelected &&'); + + const bookTexts = stripComments(bookSource); + const bookNameTag = tagAround( + bookTexts, + 'text', + "base.getAttribute('name')" + ); + const bookDamageTag = tagAround(bookTexts, 'text', 'format(damage)'); + const bookIconTag = tagAround(bookTexts, 'icon', 'icon={base.code}'); + expect(bookNameTag).toContain('noevent'); + expect(bookDamageTag).toContain('noevent'); + expect(bookIconTag).toContain('noevent'); + + const ownedNameTag = tagAround(equipSource, 'text', 'item.item.name'); + const ownedIconTag = tagAround(equipSource, 'icon', 'item.item.num'); + const slotNameTag = tagAround(equipSource, 'text', 'text={name}'); + const slotIconTag = tagAround( + equipSource, + 'icon', + 'slotEquip ? slotEquip.item.num : 0' + ); + expect(ownedNameTag).toContain('noevent'); + expect(ownedIconTag).toContain('noevent'); + expect(slotNameTag).toContain('noevent'); + expect(slotIconTag).not.toContain('noevent'); + + const toolNameTag = tagAround(toolSource, 'text', 'item.raw.name'); + const toolCountTag = tagAround( + toolSource, + 'text', + 'core.formatBigNumber(item.count)' + ); + const toolRowTag = tagAround( + toolSource, + 'g-rectr', + 'getItemClick(item)' + ); + expect(toolNameTag).toContain('noevent'); + expect(toolCountTag).toContain('noevent'); + expect(toolRowTag).not.toContain('noevent'); + + const hotkeyTexts = stripComments(hotkeySource); + const commandNameTag = tagAround( + hotkeyTexts, + 'text', + 'row.command.name' + ); + const combinationTag = tagAround( + hotkeyTexts, + 'text', + 'formatCombination(showing)' + ); + const keyHitTag = tagAround( + hotkeyTexts, + 'g-rectr', + 'getKeyClick(row.command, j)' + ); + expect(commandNameTag).toContain('noevent'); + expect(combinationTag).toContain('noevent'); + expect(keyHitTag).not.toContain('noevent'); + + for (const tag of [ + bookNameTag, + bookDamageTag, + bookIconTag, + ownedNameTag, + ownedIconTag, + slotNameTag, + slotIconTag, + toolNameTag, + toolCountTag, + toolRowTag, + commandNameTag, + combinationTag, + keyHitTag + ]) { + expect(tag).not.toBe(''); + } + }); + + // hotkey 的 ListPage 开标签带 nocache,避免整屏缓存复用旧画面 + it('marks the hotkey list page as nocache', () => { + const source = stripComments(hotkeySource); + const start = source.indexOf('', start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(source.slice(start, end + 1)).toContain('nocache'); + }); + + // equipbox 的四个循环无关 loc 已提为常量并标注 ElementLocator + it('hoists the four equipbox scroll locators with explicit types', () => { + const source = stripComments(equipBoxSource); + const flat = flatten(source); + const names = [ + 'STATUS_SCROLL_LOC', + 'DESC_SCROLL_LOC', + 'SLOT_SCROLL_LOC', + 'OWNED_SCROLL_LOC' + ]; + for (const name of names) { + expect(flat).toContain(`${name}: ElementLocator`); + expect(flat).toContain(`loc={${name}}`); + } + expect(source).toContain( + "import { ElementLocator, IActionEvent } from '@motajs/render';" + ); + }); + + // 列表必须内联渲染,不得再预建 VNode 数组或按数组名收集 + it('renders lists inline instead of prebuilt VNode arrays', () => { + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource, + hotkeySource + ]; + const arrayNames = [ + 'specialNodes', + 'statusNodes', + 'slotNodes', + 'ownedNodes', + 'permNodes', + 'consNodes', + 'groupNodes', + 'commandNodes' + ]; + for (const raw of sources) { + const source = stripComments(raw); + expect(source).not.toMatch(/\bVNode\b/); + expect(source).not.toContain(': VNode[]'); + for (const name of arrayNames) { + expect(source).not.toContain(name); + expect(source).not.toContain(`${name}.push(`); + } + } + expect(stripComments(bookSource)).not.toContain('rows.push('); + }); + + // 每个列表都必须在 JSX 内直接 map,且循环变量使用 i / j + it('renders every list with inline map and short loop variables', () => { + expect(flatten(stripComments(bookSource))).toContain( + 'enemies.value.map(' + ); + expect(flatten(stripComments(bookDetailSource))).toContain( + 'specialRows.value.map(' + ); + expect(flatten(stripComments(equipBoxSource))).toContain( + 'props.attributes.map(' + ); + expect(flatten(stripComments(equipBoxSource))).toContain( + 'hero.equip.slots.map(' + ); + expect(flatten(stripComments(equipBoxSource))).toContain( + 'owned.value.map(' + ); + expect(flatten(stripComments(toolBoxSource))).toContain( + 'permItems.value.map(' + ); + expect(flatten(stripComments(toolBoxSource))).toContain( + 'consItems.value.map(' + ); + const hotkeyFlat = flatten(stripComments(hotkeySource)); + expect(hotkeyFlat).toContain('groups.map('); + expect(hotkeyFlat).toContain('commandRows.value.map('); + expect(hotkeyFlat).toContain('combinationLists.value[i]'); + expect(hotkeyFlat).toContain('.map((keys, j)'); + expect(hotkeyFlat).toContain('getKeyClick(row.command, j)'); + expect(hotkeyFlat).toContain('selectKey(command, index)'); + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource, + hotkeySource + ]; + for (const raw of sources) { + const source = stripComments(raw); + expect(source).not.toContain('groupIndex'); + expect(source).not.toContain('commandIndex'); + expect(source).not.toMatch(/for\s*\(\s*\r?\n/); + } + }); + + // book 每行必须仍渲染命中层、图标、名称、特殊属性、五个基础数值与四个临界数值 + it('keeps all thirteen elements in every book row', () => { + const source = stripComments(bookSource); + const flat = flatten(source); + const blocks = getScrollBlocks(source); + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block.match(/])/g)).toHaveLength(11); + expect(block.match(/])/g)).toHaveLength(1); + expect(block.match(/])/g)).toHaveLength(1); + for (const label of ['生命', '攻击', '防御', '金币', '经验']) { + expect(flat).toContain(label); + } + expect(flat).toContain('specialText(base)'); + }); + + // 注释不得落在 JSX 子元素位置,Scroll 内只允许 JSX 注释 + it('keeps line comments out of Scroll children', () => { + // 按花括号配对删掉表达式容器,字符串与模板字面量内的括号不参与配对 + const withoutContainers = (text: string): string => { + let result = ''; + let index = 0; + while (index < text.length) { + if (text[index] !== '{') { + result += text[index]; + index++; + continue; + } + let depth = 0; + let quote = ''; + let escaped = false; + let cursor = index; + for (; cursor < text.length; cursor++) { + const current = text[cursor]; + if (quote) { + if (escaped) escaped = false; + else if (current === '\\') escaped = true; + else if (current === quote) quote = ''; + continue; + } + if (current === '"' || current === "'" || current === '`') { + quote = current; + continue; + } + if (current === '{') depth++; + if (current === '}') { + depth--; + if (depth === 0) break; + } + } + index = cursor + 1; + } + return result; + }; + const cases = [ + { source: bookSource, counts: [1] }, + { source: bookDetailSource, counts: [1] }, + { source: equipBoxSource, counts: [1, 0, 1, 1] }, + { source: toolBoxSource, counts: [1, 1, 0] }, + { source: hotkeySource, counts: [1] } + ]; + for (const item of cases) { + const blocks = getScrollBlocks(item.source); + const counts = blocks.map( + block => (block.match(/\{\s*\/\*/g) || []).length + ); + expect(counts).toEqual(item.counts); + for (const block of blocks) { + const children = block.slice(block.indexOf('>') + 1); + expect(children.trimStart()[0]).toMatch(/[{<]/); + expect(withoutContainers(children)).not.toContain('//'); + } + } + const bookFallback = + '// 临界信息缺失时回退为 0,让「没有数据」与「数值为 0」可以区分'; + const bookComments = ['// 数值列表区', '// 临界信息区', bookFallback]; + for (const comment of bookComments) { + expect(bookSource.split(comment)).toHaveLength(2); + } + const bookLines = bookSource.split(/\r?\n/); + expect( + bookLines.filter(line => line.trim() === bookFallback) + ).toHaveLength(1); + const fallbackIndex = bookSource.indexOf(bookFallback); + expect(fallbackIndex).toBeGreaterThan(bookSource.indexOf('')); + const detailComment = + '// TextContent 已按 props.loc 响应式同步位置,行位置变化时无需重挂载'; + expect(bookDetailSource.split(detailComment)).toHaveLength(2); + for (const stale of [ + '// TextContent 只在 setup 时复制一次 props.loc,之后改 loc 不会移动元素', + '// 因此 key 同时包含行位置与实测高度,位置变化时重挂载一次即可收敛' + ]) { + expect(bookDetailSource).not.toContain(stale); + } + const equipComments = [ + '// Scroll 会覆盖直接子元素的 hidden,增减值为 0 时改用空文本', + '// 拖动起手仍只在图标上,整格只负责悬停判定' + ]; + for (const comment of equipComments) { + expect(equipBoxSource.split(comment)).toHaveLength(2); + } + const hotkeyComments = ['// 选中按键框同样只切换这个既有矩形的描边']; + for (const comment of hotkeyComments) { + expect(hotkeySource.split(comment)).toHaveLength(2); + } + expect(hotkeySource).not.toContain('选中分组只由这一行的边框表示'); + expect(hotkeySource).not.toContain('每行点击区与分组名'); + }); + + // 五个列表文件都不再用 key 强制重挂载 + it('renders every list file without keys', () => { + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource, + hotkeySource + ]; + for (const raw of sources) { + expect(stripComments(raw)).not.toContain('key={'); + } + const contentTag = tagAround( + stripComments(bookDetailSource), + 'TextContent', + 'getDescription()' + ); + expect(contentTag).not.toBe(''); + expect(contentTag).not.toContain('key='); + expect(flatten(contentTag)).toContain('loc={textLoc}'); + expect(flatten(stripComments(bookDetailSource))).toMatch( + /textLoc: ElementLocator = \[\s*0,\s*row\.textTop,\s*SPECIAL_WIDTH,\s*row\.textHeight\s*\]/ + ); + // 用户已授权删除两处仍提到 key 的过期注释 + expect(bookDetailSource).not.toContain('同时用于 TextContent 的 key'); + expect(bookDetailSource).not.toContain( + '避免高度回调与 key 重挂载互相触发' + ); + }); + + // hotkey 的分组列表由 ListPage 渲染,关闭按钮使用其内置的关闭文本 + it('delegates the hotkey group list and close text to ListPage', () => { + const hotkey = stripComments(hotkeySource); + expect(hotkey).toContain('ListPage'); + expect(hotkey).toContain('closeLoc='); + expect(hotkey).not.toContain('text="返回"'); + expect(hotkey).not.toContain('text="关闭"'); + expect(listSource).toContain('text="关闭"'); + expect(listSource).toContain('basis'); + }); + + // 五个 UI 文件都不保留多行内联 loc 与内联箭头事件处理器 + it('keeps multiline inline locators and inline arrow handlers out of the list files', () => { + const sources = [ + bookSource, + bookDetailSource, + equipBoxSource, + toolBoxSource, + hotkeySource + ]; + for (const raw of sources) { + const source = stripComments(raw); + expect(source).not.toMatch(/loc=\{\[\s*\r?\n/); + expect(source).not.toMatch(/on[A-Z][A-Za-z]*=\{[^{}]*=>/); + } + }); + + // bookDetail 用 cumsum 计算行位置且不再手写累加 + it('computes special row offsets with cumsum', () => { + const flat = flatten(stripComments(bookDetailSource)); + expect(flat).toContain("from '@motajs/common'"); + expect(flat).toContain('cumsum'); + expect(flat).toContain('cumsum(hs)'); + expect(flat).toContain('ends[i] - hs[i]'); + expect(flat).not.toContain('top +='); + }); + + // TextContent 会随 props.loc 更新位置并恒用 props.width 作为宽度 + it('syncs TextContent position from props.loc and always uses props.width', () => { + const flat = flatten(stripComments(textBoxSource)); + expect(flat).toContain('watch(() => props.loc'); + expect(flat).toContain('props.autoHeight ? height : h'); + expect(flat).toContain('props.width'); + expect(flat).toContain("emit('updateHeight', height)"); + expect(flat).toContain('typer.setConfig(props)'); + expect(flat).toMatch( + /loc\.value = \[\s*x,\s*y,\s*props\.width,\s*props\.autoHeight \? height : h,\s*ax,\s*ay\s*\]/ + ); + }); +}); diff --git a/src/client/ui/background.tsx b/src/client/ui/background.tsx index 854c76e..298a290 100644 --- a/src/client/ui/background.tsx +++ b/src/client/ui/background.tsx @@ -1,18 +1,14 @@ -import { - GameUI, - SetupComponentOptions, - IUIDefaultPropsBase -} from '@motajs/system'; +import { GameUI, SetupComponentOptions } from '@motajs/system'; import { ElementLocator } from '@motajs/render'; import { DefaultProps } from '@motajs/render-vue'; import { computed, defineComponent } from 'vue'; import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client'; +import { IGameUIPropsBase } from '@user/client-base'; -export interface MainBackgroundProps - extends DefaultProps, IUIDefaultPropsBase {} +export interface MainBackgroundProps extends DefaultProps, IGameUIPropsBase {} const mainBackgroundProps = { - props: ['controller', 'instance'] + props: ['controller', 'instance', 'state'] } satisfies SetupComponentOptions; export const MainBackground = defineComponent(() => { diff --git a/src/client/ui/book.tsx b/src/client/ui/book.tsx index 6d4800b..3eb2cd5 100644 --- a/src/client/ui/book.tsx +++ b/src/client/ui/book.tsx @@ -1,239 +1,126 @@ import { GameUI, SetupComponentOptions } from '@motajs/system'; -import { computed, defineComponent } from 'vue'; +import { + computed, + defineComponent, + markRaw, + ref, + shallowRef, + watch +} from 'vue'; import { Font } from '@motajs/render'; -import { ITileLocator } from '@motajs/common'; -import { Scroll } from '@user/client-base'; -import { IEnemyView } from '@user/data-system'; +import { Scroll, useKey } from '@user/client-base'; +import { IReadonlyEnemyContext } from '@user/data-system'; import { IReadonlyEnemy } from '@user/data-base'; -import { IEnemyAttr } from '@user/data-common'; +import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { BookDetailUI } from './bookDetail'; import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client'; -import { IUIPropsBase } from '@user/client-modules'; +import { IGameUIPropsBase } from '@user/client-base'; +import { DefaultProps } from '@motajs/render-vue'; +import { EnemyOne } from './enemyOne'; -export interface BookProps extends IUIPropsBase {} +export interface BookProps extends IGameUIPropsBase, DefaultProps { + /** 指定怪物手册使用的怪物上下文,如果不指定,那么会使用 `state.enemyContext` 作为上下文 */ + context: IReadonlyEnemyContext; +} /** 怪物手册组件的 props 声明 */ const bookProps = { - props: ['controller', 'instance', 'state'] + props: ['controller', 'instance', 'state', 'context'] } satisfies SetupComponentOptions; /** 列表中每一行的高度 */ const ROW_HEIGHT = 72; -/** 列表区域的起始纵坐标 */ -const LIST_TOP = 44; -/** 列表区域的横坐标 */ -const LIST_LEFT = 16; /** 列表区域的宽度 */ -const LIST_WIDTH = MAIN_WIDTH - 32; +const LIST_WIDTH = MAIN_WIDTH; /** 列表区域的高度,底部留出退出按钮的空间 */ -const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 36; -/** 数值列区域的起始横坐标 */ -const STAT_LEFT = 240; +const LIST_HEIGHT = MAIN_HEIGHT - 36; /** 怪物名称字体 */ const nameFont = Font.defaults({ size: 15, weight: 700 }); -/** 特殊名与属性字体 */ -const statFont = Font.defaults({ size: 12 }); +/** 当前层没有怪物时显示的文字字体 */ +const noEnemyFont = Font.defaults({ size: 48 }); /** 怪物手册列表组件 */ export const Book = defineComponent(props => { /** 当前地图的怪物上下文 */ - const ctx = props.state.enemyContext; + const ctx = shallowRef(props.context ?? props.state.enemyContext); + /** 当前选中的怪物行 */ + const selected = ref(-1); + + watch( + () => props.context, + value => { + ctx.value = value ?? props.state.enemyContext; + } + ); // 按怪物图块数字去重,每个数字保留一个代表视图 - const enemies = computed(() => { - const map = new Map>(); - for (const pair of ctx.iterateEnemy()) { - const base = pair[1].getBaseEnemy(); - if (!map.has(base.code)) map.set(base.code, pair[1]); + const enemies = computed[]>(() => { + const codes = new Set(); + for (const [, view] of ctx.value.iterateEnemy()) { + codes.add(view.getBaseEnemy().code); } - return Array.from(map.values()); + const manager = props.state.enemyManager; + return [...codes].map(code => { + return manager.createEnemy(code)!; + }); }); - /** 将数值按大数规则格式化为展示字符串 */ - const format = (value: number) => { - return core.formatBigNumber(value); - }; - - /** - * 以基础属性路径计算怪物临界信息,已达临界上界或未绑定伤害系统时返回 null - */ - const getCritical = ( - base: IReadonlyEnemy, - locator: ITileLocator | null - ) => { - const damage = ctx.getDamageSystem(); - // TODO: 伤害系统未绑定或已达临界上界时,伤害 / 临界 / 减伤 / 「1 防」留空 - if (!damage) return null; - const first = damage - .calculateEnemyCritical(base, 'atk', locator) - .next(); - if (first.done) return null; - return first.value; - }; - - /** - * 以基础属性路径计算「1 防」时的伤害信息,即隔离属性增加 1 点防御后的战斗信息 - */ - const getDef1 = ( - base: IReadonlyEnemy, - locator: ITileLocator | null - ) => { - const damage = ctx.getDamageSystem(); - if (!damage) return null; - const isolated = props.state.hero.getIsolatedAttribute(); - isolated.add('def', 1); - const first = damage - .with(isolated) - .calculateEnemyCritical(base, 'atk', locator) - .next(); - if (first.done) return null; - return first.value; - }; - - /** 拼接怪物全部特殊属性的名称,以空格分隔 */ - const specialText = (base: IReadonlyEnemy) => { - const names: string[] = []; - for (const special of base.iterateSpecials()) { - names.push(special.getSpecialName()); - } - return names.join(' '); - }; - - /** 计算第 index 个数值列的中心横坐标 */ - const statX = (index: number) => { - return STAT_LEFT + (index * (MAIN_WIDTH - STAT_LEFT)) / 5; - }; - - /** 打开指定怪物视图的详细信息界面 */ - const openDetail = (view: IEnemyView) => { - props.controller.open(BookDetailUI, { enemy: view }); + const showDetail = (enemy: IReadonlyEnemy) => { + props.controller.open(BookDetailUI, { enemy, context: ctx.value }); }; /** 关闭当前怪物手册界面 */ const exit = () => props.controller.close(props.instance); + const key = useKey(props.state.hotkey.system)!; + key.implement('@common/confirm', () => { + const enemy = enemies.value[selected.value]; + if (enemy) showDetail(enemy); + }) + .implement('@common/exit', close) + .implement('@ui/book-up', () => { + if (selected.value > 1) { + selected.value--; + } + }) + .implement('@ui/book-down', () => { + if (selected.value < enemies.value.length - 1) { + selected.value++; + } + }) + .implement('@ui/book-page-up', () => { + const next = selected.value - 5; + selected.value = Math.max(next, 0); + }) + .implement('@ui/book-page-down', () => { + const next = selected.value + 5; + selected.value = Math.min(next, enemies.value.length - 1); + }); + return () => ( - - {/* 标题 */} +