mirror of
https://github.com/motajs/template.git
synced 2026-11-09 03:34:30 +08:00
refactor: 怪物手册 UI 重构
This commit is contained in:
1 parent
376f36b43c
commit
6c6f7da76a
29 files changed
+2577
-930
No files matched your search
@@ -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 元素宽度
|
||||
|
||||
@@ -15,7 +15,7 @@ import { IClientHotkey } from './hotkey';
|
||||
|
||||
export interface IGameUIPropsBase<
|
||||
T extends UIComponent = UIComponent
|
||||
> extends IUIDefaultPropsBase<T> {
|
||||
> extends IUIDefaultPropsBase<T, IGameUIPropsBase<T>> {
|
||||
readonly state: IClientBase;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './types';
|
||||
export * from './func';
|
||||
@@ -1,5 +0,0 @@
|
||||
import { IUIDefaultPropsBase } from '@motajs/system';
|
||||
import { IClientBaseExtended } from '@user/client-base';
|
||||
|
||||
export interface IUIPropsBase
|
||||
extends IUIDefaultPropsBase, IClientBaseExtended {}
|
||||
@@ -427,7 +427,7 @@ export interface IDamageCalculator<TEnemy, THero> {
|
||||
|
||||
export interface IDamageContext<TEnemy, THero> extends IDataBaseExtended {
|
||||
/**
|
||||
* 获取战斗伤害信息
|
||||
* 获取战斗伤害信息,会使用怪物的计算后属性计算伤害,等价于 `getDamageInfoByComputed(view.getComputedEnemy)`
|
||||
* @param enemy 怪物视图
|
||||
*/
|
||||
getDamageInfo(
|
||||
|
||||
@@ -24,11 +24,14 @@ export type SetupComponentOptions<
|
||||
|
||||
export type UIComponent = DefineSetupFnComponent<any> | DefineComponent;
|
||||
|
||||
export interface IUIDefaultPropsBase<T extends UIComponent = UIComponent> {
|
||||
export interface IUIDefaultPropsBase<
|
||||
T extends UIComponent = UIComponent,
|
||||
PB extends IUIDefaultPropsBase<any> = IUIDefaultPropsBase<any, any>
|
||||
> {
|
||||
/** 当前打开的 UI 所属的控制器 */
|
||||
readonly controller: IUIController<this>;
|
||||
readonly controller: IUIController<PB>;
|
||||
/** 当前 UI 实例对象 */
|
||||
readonly instance: IUIInstance<T, this>;
|
||||
readonly instance: IUIInstance<T, PB>;
|
||||
}
|
||||
|
||||
export interface IGameUI<C extends UIComponent = UIComponent> {
|
||||
|
||||
@@ -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;
|
||||
/** 勇士状态对象 */
|
||||
|
||||
@@ -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(/<ListPage[^>]*\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="关闭"');
|
||||
});
|
||||
});
|
||||
@@ -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('<icon');
|
||||
expect(enemyOneSource).toContain('fill={false}');
|
||||
expect(enemyOneSource).toContain('stroke');
|
||||
expect(enemyOneSource).toContain('strokeStyle');
|
||||
expect(enemyOneSource).toContain('selected');
|
||||
expect(enemyOneSource).toContain('onClick');
|
||||
expect(enemyOneSource).toContain('onHover');
|
||||
});
|
||||
|
||||
// book 必须复用 EnemyOne,并补齐空状态、键盘、滚动与详情入口。
|
||||
it('reuses EnemyOne in the book list and wires the legacy interactions', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(/<ListPage[\s\S]*?close/);
|
||||
expect(source).toContain('closeLoc={CLOSE_LOC}');
|
||||
expect(source).toContain('onUpdate={selectGroup}');
|
||||
expect(source).toContain('onClose={exit}');
|
||||
expect(source).toContain('@common/exit');
|
||||
expect(settingSource).not.toContain('text="关闭"');
|
||||
});
|
||||
|
||||
// 控件必须由设置项取得,并只接收设置项与控制器
|
||||
it('renders the setting component with setting and controller props', () => {
|
||||
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<number>',
|
||||
'implements ISettingItem<boolean>',
|
||||
'implements ISettingItem<void>',
|
||||
'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('<Scroll loc={CONTENT_SCROLL_LOC} />');
|
||||
expect(source).toContain('return () => renderPage();');
|
||||
});
|
||||
});
|
||||
@@ -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('<Scroll', index);
|
||||
if (start < 0) break;
|
||||
const end = source.indexOf('</Scroll>', start);
|
||||
if (end < 0) break;
|
||||
blocks.push(source.slice(start, end));
|
||||
index = end + '</Scroll>'.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(/<container(?=[\s>])/);
|
||||
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(/<container(?=[\s>])/g)).toHaveLength(1);
|
||||
}
|
||||
const hotkey = stripComments(hotkeySource);
|
||||
expect(hotkey).not.toMatch(/<container(?=[\s>])/);
|
||||
expect(hotkey.match(/<ListPage(?=[\s>])/g)).toHaveLength(1);
|
||||
expect(hotkey.match(/<Scroll(?=[\s>])/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(/<ListPage[^>]*\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<unknown>');
|
||||
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('<ListPage');
|
||||
const end = 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(/<text(?=[\s>])/g)).toHaveLength(11);
|
||||
expect(block.match(/<icon(?=[\s>])/g)).toHaveLength(1);
|
||||
expect(block.match(/<g-rect(?=[\s>])/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('<Scroll'));
|
||||
expect(fallbackIndex).toBeLessThan(bookSource.indexOf('</Scroll>'));
|
||||
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*\]/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<MainBackgroundProps>;
|
||||
|
||||
export const MainBackground = defineComponent<MainBackgroundProps>(() => {
|
||||
|
||||
+84
-197
@@ -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<IEnemyAttr, IHeroAttr>;
|
||||
}
|
||||
|
||||
/** 怪物手册组件的 props 声明 */
|
||||
const bookProps = {
|
||||
props: ['controller', 'instance', 'state']
|
||||
props: ['controller', 'instance', 'state', 'context']
|
||||
} satisfies SetupComponentOptions<BookProps>;
|
||||
|
||||
/** 列表中每一行的高度 */
|
||||
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<BookProps>(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<number, IEnemyView<IEnemyAttr>>();
|
||||
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<IReadonlyEnemy<IEnemyAttr>[]>(() => {
|
||||
const codes = new Set<number>();
|
||||
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<IEnemyAttr>,
|
||||
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<IEnemyAttr>,
|
||||
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<IEnemyAttr>) => {
|
||||
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<IEnemyAttr>) => {
|
||||
props.controller.open(BookDetailUI, { enemy: view });
|
||||
const showDetail = (enemy: IReadonlyEnemy<IEnemyAttr>) => {
|
||||
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 () => (
|
||||
<container loc={FULL_LOC}>
|
||||
{/* 标题 */}
|
||||
<container loc={FULL_LOC} nocache>
|
||||
<text
|
||||
text="怪物手册"
|
||||
loc={[24, 22]}
|
||||
anc={[0, 0]}
|
||||
font={nameFont}
|
||||
fillStyle="gold"
|
||||
text="本层无怪物"
|
||||
hidden={enemies.value.length !== 0}
|
||||
loc={[MAIN_WIDTH / 2, MAIN_HEIGHT / 2]}
|
||||
anc={[0.5, 0.5]}
|
||||
font={noEnemyFont}
|
||||
/>
|
||||
{/* 怪物列表区 */}
|
||||
<Scroll
|
||||
noscroll
|
||||
loc={[LIST_LEFT, LIST_TOP, LIST_WIDTH, LIST_HEIGHT]}
|
||||
>
|
||||
{enemies.value.map((view, i) => {
|
||||
const base = view.getBaseEnemy();
|
||||
const locator = ctx.getEnemyLocatorByView(view);
|
||||
const critical = getCritical(base, locator);
|
||||
const def1 = getDef1(base, locator);
|
||||
const y = i * ROW_HEIGHT;
|
||||
<Scroll loc={[0, 0, LIST_WIDTH, LIST_HEIGHT]}>
|
||||
{enemies.value.map((base, i) => {
|
||||
return (
|
||||
<container
|
||||
key={base.code}
|
||||
loc={[0, y, LIST_WIDTH, ROW_HEIGHT]}
|
||||
cursor="pointer"
|
||||
onClick={() => openDetail(view)}
|
||||
>
|
||||
<icon icon={base.code} loc={[8, 16, 40, 40]} />
|
||||
<text
|
||||
text={base.getAttribute('name')}
|
||||
loc={[56, 14]}
|
||||
anc={[0, 0]}
|
||||
font={nameFont}
|
||||
fillStyle="#ffffff"
|
||||
/>
|
||||
<text
|
||||
text={specialText(base)}
|
||||
loc={[56, 36]}
|
||||
anc={[0, 0]}
|
||||
font={statFont}
|
||||
fillStyle="#ffd27f"
|
||||
/>
|
||||
{/* 数值列表区 */}
|
||||
<text
|
||||
text={`生命 ${format(base.getAttribute('hp'))}`}
|
||||
loc={[statX(0), 22]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightgreen"
|
||||
/>
|
||||
<text
|
||||
text={`攻击 ${format(base.getAttribute('atk'))}`}
|
||||
loc={[statX(1), 22]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightcoral"
|
||||
/>
|
||||
<text
|
||||
text={`防御 ${format(base.getAttribute('def'))}`}
|
||||
loc={[statX(2), 22]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightblue"
|
||||
/>
|
||||
<text
|
||||
text={`金币 ${format(base.getAttribute('money'))}`}
|
||||
loc={[statX(3), 22]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightyellow"
|
||||
/>
|
||||
<text
|
||||
text={`经验 ${format(base.getAttribute('exp'))}`}
|
||||
loc={[statX(4), 22]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lawngreen"
|
||||
/>
|
||||
{/* 临界信息区 */}
|
||||
{critical && (
|
||||
<text
|
||||
text={`伤害 ${format(critical.baseInfo.damage)}`}
|
||||
loc={[statX(0), 52]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="#ffffff"
|
||||
/>
|
||||
)}
|
||||
{critical && (
|
||||
<text
|
||||
text={`临界 ${format(critical.nextDiff)}`}
|
||||
loc={[statX(1), 52]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightsalmon"
|
||||
/>
|
||||
)}
|
||||
{critical && (
|
||||
<text
|
||||
text={`减伤 ${format(-critical.damageDiff)}`}
|
||||
loc={[statX(2), 52]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightpink"
|
||||
/>
|
||||
)}
|
||||
{critical && def1 && (
|
||||
<text
|
||||
text={`1 防 ${format(critical.baseInfo.damage - def1.baseInfo.damage)}`}
|
||||
loc={[statX(3), 52]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="cyan"
|
||||
/>
|
||||
)}
|
||||
</container>
|
||||
<EnemyOne
|
||||
loc={[0, i * ROW_HEIGHT, LIST_WIDTH, LIST_HEIGHT]}
|
||||
context={markRaw(ctx.value)}
|
||||
enemy={markRaw(base)}
|
||||
selected={selected.value === i}
|
||||
onClick={() => showDetail(base)}
|
||||
onEnter={() => void (selected.value = i)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
{/* 右下角退出按钮 */}
|
||||
<text
|
||||
text="退出"
|
||||
loc={[MAIN_WIDTH - 16, MAIN_HEIGHT - 16]}
|
||||
|
||||
+337
-195
@@ -1,13 +1,26 @@
|
||||
import { GameUI, SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { Font } from '@motajs/render';
|
||||
import { ITileLocator } from '@motajs/common';
|
||||
import { Scroll, TextContent } from '@user/client-base';
|
||||
import { IEnemyCritical, IEnemyView, EnemyView } from '@user/data-system';
|
||||
import { IEnemy, IReadonlyEnemy } from '@user/data-base';
|
||||
import { IEnemyAttr } from '@user/data-common';
|
||||
import { computed, defineComponent, ref, shallowRef, watch } from 'vue';
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import { cumsum, ITileLocator } from '@motajs/common';
|
||||
import {
|
||||
adjustGrid2,
|
||||
IGameUIPropsBase,
|
||||
Scroll,
|
||||
TextContent,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import {
|
||||
IEnemyCritical,
|
||||
IEnemyView,
|
||||
EnemyView,
|
||||
IDamageSystem,
|
||||
IReadonlyEnemyContext
|
||||
} from '@user/data-system';
|
||||
import { IReadonlyEnemy, ISpecial } from '@user/data-base';
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client';
|
||||
import { IUIPropsBase } from '@user/client-modules';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { EnemyOne } from './enemyOne';
|
||||
|
||||
/** 详情当前展示的属性模式 */
|
||||
const enum DetailMode {
|
||||
@@ -17,53 +30,146 @@ const enum DetailMode {
|
||||
Computed
|
||||
}
|
||||
|
||||
export interface BookDetailProps extends IUIPropsBase {
|
||||
enemy: IEnemy<IEnemyAttr> | IEnemyView<IEnemyAttr>;
|
||||
export interface BookDetailProps extends IGameUIPropsBase, DefaultProps {
|
||||
/** 怪物上下文 */
|
||||
context: IReadonlyEnemyContext<IEnemyAttr, IHeroAttr>;
|
||||
/** 要展示的怪物对象,与 `view` 同时传入时以 `view` 为准 */
|
||||
enemy?: IReadonlyEnemy<IEnemyAttr>;
|
||||
/** 要展示的怪物视图,与 `enemy` 同时传入时以 `view` 为准 */
|
||||
view?: IEnemyView<IEnemyAttr>;
|
||||
/** 怪物的定位符,当传入怪物视图时此项无效,仅当传入怪物对象时生效 */
|
||||
locator?: ITileLocator;
|
||||
}
|
||||
|
||||
/** 怪物详情组件的 props 声明 */
|
||||
const bookDetailProps = {
|
||||
props: ['controller', 'instance', 'state', 'enemy']
|
||||
props: [
|
||||
'controller',
|
||||
'instance',
|
||||
'state',
|
||||
'enemy',
|
||||
'view',
|
||||
'locator',
|
||||
'context',
|
||||
'loc',
|
||||
'width',
|
||||
'height'
|
||||
]
|
||||
} satisfies SetupComponentOptions<BookDetailProps>;
|
||||
|
||||
/** 怪物名称字体 */
|
||||
const nameFont = Font.defaults({ size: 20, weight: 700 });
|
||||
/** 属性数值字体 */
|
||||
const statFont = Font.defaults({ size: 15 });
|
||||
/** 特殊属性字体 */
|
||||
const specialFont = Font.defaults({ size: 14 });
|
||||
/** 没有特殊属性时的字体 */
|
||||
const noSpecialFont = Font.defaults({ size: 24 });
|
||||
/** 临界表字体 */
|
||||
const criticalFont = Font.defaults({ size: 12 });
|
||||
|
||||
/** 顶部摘要属性的高度 */
|
||||
const SUMMARY_HEIGHT = 72;
|
||||
/** 顶部摘要属性与特殊属性区的间隔 */
|
||||
const SUMMARY_INTERVAL = 16;
|
||||
/** 摘要与特殊属性区的分割线 */
|
||||
const SUMMARY_SPECIAL_DIVIDER: [number, number, number, number] = [
|
||||
16,
|
||||
SUMMARY_HEIGHT + SUMMARY_INTERVAL / 2,
|
||||
MAIN_WIDTH - 16,
|
||||
SUMMARY_HEIGHT + SUMMARY_INTERVAL / 2
|
||||
];
|
||||
|
||||
/** 临界表数量 */
|
||||
const CRITICAL_COUNT = 8;
|
||||
/** 临界表高度 */
|
||||
const CRITICAL_HEIGHT = 64;
|
||||
/** 临界表左边缘位置 */
|
||||
const CRITICAL_LEFT = 0;
|
||||
/** 临界表的网格间距 */
|
||||
const CRITICAL_INTERVAL = 4;
|
||||
|
||||
/** 特殊属性列表区域的横坐标 */
|
||||
const SPECIAL_LEFT = 0;
|
||||
/** 特殊属性列表区域的宽度 */
|
||||
const SPECIAL_WIDTH = MAIN_WIDTH;
|
||||
/** 特殊属性列表区域的起始纵坐标 */
|
||||
const SPECIAL_TOP = SUMMARY_HEIGHT + SUMMARY_INTERVAL;
|
||||
/** 特殊属性列表区域的高度 */
|
||||
const SPECIAL_HEIGHT =
|
||||
MAIN_HEIGHT - SUMMARY_HEIGHT - SUMMARY_INTERVAL - CRITICAL_HEIGHT;
|
||||
/** 每行特殊属性标题占用的高度 */
|
||||
const SPECIAL_LABEL_HEIGHT = 22;
|
||||
/** 描述文本首次测量前使用的回退高度 */
|
||||
const SPECIAL_MIN_HEIGHT = 20;
|
||||
/** 相邻两行特殊属性之间的纵向间隔 */
|
||||
const SPECIAL_GAP = 10;
|
||||
/** 特殊属性列表区域的外接矩形,只由既有布局常量组成 */
|
||||
const SPECIAL_SCROLL_LOC: ElementLocator = [
|
||||
SPECIAL_LEFT,
|
||||
SPECIAL_TOP,
|
||||
SPECIAL_WIDTH,
|
||||
SPECIAL_HEIGHT
|
||||
];
|
||||
|
||||
/** 顶部怪物主信息区域的定位符 */
|
||||
const MAIN_INFO_LOC: ElementLocator = [0, 0, MAIN_WIDTH, SUMMARY_HEIGHT];
|
||||
/** 底部临界表区域的起始纵坐标 */
|
||||
const CRITICAL_TOP = 272;
|
||||
/** 临界表容器位置 */
|
||||
const CRITICAL_LOC: ElementLocator = [
|
||||
CRITICAL_LEFT,
|
||||
CRITICAL_TOP,
|
||||
MAIN_WIDTH,
|
||||
CRITICAL_HEIGHT
|
||||
];
|
||||
|
||||
interface ISpecialRow {
|
||||
/** 该行对应的特殊属性 */
|
||||
readonly special: ISpecial<unknown>;
|
||||
/** 标题的纵坐标 */
|
||||
readonly labelTop: number;
|
||||
/** 描述文本的纵坐标 */
|
||||
readonly textTop: number;
|
||||
/** 描述文本的高度 */
|
||||
readonly textHeight: number;
|
||||
}
|
||||
|
||||
/** 怪物详细信息组件 */
|
||||
export const BookDetail = defineComponent<BookDetailProps>(props => {
|
||||
/** 当前展示的属性模式 */
|
||||
const mode = ref(DetailMode.Base);
|
||||
const mode = ref(DetailMode.Computed);
|
||||
// 按特殊属性 code 记录实测描述高度,供后续行计算纵坐标
|
||||
const textHeights = ref<Record<number, number>>({});
|
||||
|
||||
/** 若入参为怪物视图则返回该视图,否则返回 null */
|
||||
const getView = (): IEnemyView<IEnemyAttr> | null => {
|
||||
if (props.enemy instanceof EnemyView) return props.enemy;
|
||||
return null;
|
||||
};
|
||||
/** 临界表 */
|
||||
const criticals = shallowRef<IEnemyCritical[]>([]);
|
||||
|
||||
/** 临界表网格布局 */
|
||||
const grid = computed(() =>
|
||||
adjustGrid2(
|
||||
MAIN_WIDTH,
|
||||
CRITICAL_HEIGHT,
|
||||
criticals.value.length + 1,
|
||||
1,
|
||||
CRITICAL_INTERVAL
|
||||
)
|
||||
);
|
||||
|
||||
/** 获取基础属性,视图取基础怪物,否则取入参本身 */
|
||||
const getBase = (): IReadonlyEnemy<IEnemyAttr> => {
|
||||
if (props.enemy instanceof EnemyView) return props.enemy.getBaseEnemy();
|
||||
else return props.enemy as IReadonlyEnemy<IEnemyAttr>;
|
||||
};
|
||||
|
||||
/** 获取计算后属性,视图走计算接口,否则取入参本身 */
|
||||
const getComputed = (): IReadonlyEnemy<IEnemyAttr> => {
|
||||
if (props.enemy instanceof EnemyView) {
|
||||
return props.enemy.getComputedEnemy();
|
||||
const getBase = (): IReadonlyEnemy<IEnemyAttr> | undefined => {
|
||||
if (props.view) {
|
||||
return props.view.getBaseEnemy();
|
||||
} else {
|
||||
return props.enemy as IReadonlyEnemy<IEnemyAttr>;
|
||||
return props.enemy;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: 后续支持「在地图中打开指定 View 详情」时,locator 需按实际位置补全
|
||||
const getLocator = (): ITileLocator | null => {
|
||||
const view = getView();
|
||||
if (!view) return null;
|
||||
return view.context.getEnemyLocatorByView(view);
|
||||
/** 获取计算后属性,视图走计算接口,否则取入参本身 */
|
||||
const getComputed = (): IReadonlyEnemy<IEnemyAttr> | undefined => {
|
||||
if (props.view) {
|
||||
return props.view.getComputedEnemy();
|
||||
} else {
|
||||
return props.enemy;
|
||||
}
|
||||
};
|
||||
|
||||
/** 当前模式对应的怪物数据 */
|
||||
@@ -71,176 +177,212 @@ export const BookDetail = defineComponent<BookDetailProps>(props => {
|
||||
mode.value === DetailMode.Base ? getBase() : getComputed()
|
||||
);
|
||||
|
||||
// 详情仅展示特殊属性列表,不展示光环
|
||||
const specials = computed(() =>
|
||||
Array.from(current.value.iterateSpecials())
|
||||
);
|
||||
|
||||
/**
|
||||
* 计算当前模式下的临界信息,基础模式走裸怪物临界接口,
|
||||
* 计算模式走怪物视图临界接口,已达上界时返回 null
|
||||
*/
|
||||
const critical = computed<IEnemyCritical | null>(() => {
|
||||
const damage = props.state.enemyContext.getDamageSystem();
|
||||
if (!damage) return null;
|
||||
if (mode.value === DetailMode.Base) {
|
||||
const first = damage
|
||||
.calculateEnemyCritical(getBase(), 'atk', getLocator())
|
||||
.next();
|
||||
return first.done ? null : first.value;
|
||||
/** 特殊属性列表 */
|
||||
const specials = computed(() => {
|
||||
if (current.value) {
|
||||
return [...current.value.iterateSpecials()];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
const view = getView();
|
||||
if (!view) return null;
|
||||
const first = damage.calculateViewCritical(view, 'atk').next();
|
||||
return first.done ? null : first.value;
|
||||
});
|
||||
|
||||
/** 将数值按大数规则格式化为展示字符串 */
|
||||
const format = (value: number) => core.formatBigNumber(value);
|
||||
|
||||
/** 计算第 index 个数值列的中心横坐标 */
|
||||
const statX = (index: number) => 24 + (index * (MAIN_WIDTH - 48)) / 5;
|
||||
|
||||
/** 在基础属性与计算后属性之间切换 */
|
||||
const toggleMode = () => {
|
||||
if (!(props.enemy instanceof EnemyView)) return;
|
||||
mode.value =
|
||||
mode.value === DetailMode.Base
|
||||
? DetailMode.Computed
|
||||
: DetailMode.Base;
|
||||
};
|
||||
|
||||
/** 关闭当前怪物详情界面 */
|
||||
const close = () => props.controller.close(props.instance);
|
||||
|
||||
/** 左下角切换按钮的显示文本 */
|
||||
const toggleText = computed(() =>
|
||||
mode.value === DetailMode.Base ? '切换至计算后属性' : '切换至基础属性'
|
||||
);
|
||||
|
||||
return () => {
|
||||
const enemy = current.value;
|
||||
return (
|
||||
<container loc={FULL_LOC}>
|
||||
{/* 标题 */}
|
||||
<text
|
||||
text={enemy.getAttribute('name')}
|
||||
loc={[24, 22]}
|
||||
anc={[0, 0]}
|
||||
font={nameFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
{/* 属性数值区 */}
|
||||
<text
|
||||
text={`生命 ${format(enemy.getAttribute('hp'))}`}
|
||||
loc={[statX(0), 64]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightgreen"
|
||||
/>
|
||||
<text
|
||||
text={`攻击 ${format(enemy.getAttribute('atk'))}`}
|
||||
loc={[statX(1), 64]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightcoral"
|
||||
/>
|
||||
<text
|
||||
text={`防御 ${format(enemy.getAttribute('def'))}`}
|
||||
loc={[statX(2), 64]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightblue"
|
||||
/>
|
||||
<text
|
||||
text={`金币 ${format(enemy.getAttribute('money'))}`}
|
||||
loc={[statX(3), 64]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightyellow"
|
||||
/>
|
||||
<text
|
||||
text={`经验 ${format(enemy.getAttribute('exp'))}`}
|
||||
loc={[statX(4), 64]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lawngreen"
|
||||
/>
|
||||
{/* 特殊属性列表区 */}
|
||||
<Scroll noscroll loc={[24, 92, MAIN_WIDTH - 48, 150]}>
|
||||
{specials.value.map((special, i) => {
|
||||
const y = i * 44;
|
||||
return (
|
||||
<container
|
||||
key={special.code}
|
||||
loc={[0, y, MAIN_WIDTH - 48, 44]}
|
||||
>
|
||||
<text
|
||||
text={`${special.getSpecialName()}:`}
|
||||
loc={[0, 6]}
|
||||
anc={[0, 0]}
|
||||
font={specialFont}
|
||||
fillStyle={special.getNameColor()}
|
||||
/>
|
||||
<TextContent
|
||||
text={special.getDescription()}
|
||||
width={MAIN_WIDTH - 48}
|
||||
loc={[0, 24, MAIN_WIDTH - 48, 20]}
|
||||
font={specialFont}
|
||||
fillStyle="#dddddd"
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
<text
|
||||
text="临界"
|
||||
loc={[24, 258]}
|
||||
anc={[0, 0.5]}
|
||||
font={specialFont}
|
||||
fillStyle="#fffa9e"
|
||||
/>
|
||||
{/* 临界信息区 */}
|
||||
{critical.value && (
|
||||
<text
|
||||
text={`加攻 ${format(critical.value.nextDiff)}`}
|
||||
loc={[24, 282]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightsalmon"
|
||||
/>
|
||||
)}
|
||||
{critical.value && (
|
||||
<text
|
||||
text={`减伤 ${format(-critical.value.damageDiff)}`}
|
||||
loc={[160, 282]}
|
||||
anc={[0, 0.5]}
|
||||
font={statFont}
|
||||
fillStyle="lightpink"
|
||||
/>
|
||||
)}
|
||||
{/* 左下切换按钮 / 右下返回按钮 */}
|
||||
<text
|
||||
text={toggleText.value}
|
||||
loc={[24, MAIN_HEIGHT - 18]}
|
||||
anc={[0, 1]}
|
||||
font={statFont}
|
||||
cursor="pointer"
|
||||
fillStyle="gold"
|
||||
onClick={toggleMode}
|
||||
/>
|
||||
<text
|
||||
text="返回上一级"
|
||||
loc={[MAIN_WIDTH - 24, MAIN_HEIGHT - 18]}
|
||||
anc={[1, 1]}
|
||||
font={statFont}
|
||||
cursor="pointer"
|
||||
fillStyle="#ffffff"
|
||||
onClick={close}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
/**
|
||||
* 更新特殊属性的文本高度
|
||||
* @param code 特殊属性码
|
||||
* @param height 这个特殊属性的文本高度
|
||||
*/
|
||||
const updateTextHeight = (code: number, height: number): void => {
|
||||
if (textHeights.value[code] === height) return;
|
||||
textHeights.value[code] = height;
|
||||
};
|
||||
|
||||
// 只保留仍存在的条目;整体清空会让没有重新上报高度的条目退回最小高度、长描述重新重叠
|
||||
watch(specials, () => {
|
||||
const next: Record<number, number> = {};
|
||||
for (const special of specials.value) {
|
||||
const height = textHeights.value[special.code];
|
||||
if (height) next[special.code] = height;
|
||||
}
|
||||
textHeights.value = next;
|
||||
});
|
||||
|
||||
// 一次遍历累加出每行位置;行高只在循环内使用,不写入接口
|
||||
const specialRows = computed<ISpecialRow[]>(() => {
|
||||
const hs = specials.value.map(special => {
|
||||
const measured = textHeights.value[special.code];
|
||||
return (
|
||||
(measured ?? SPECIAL_MIN_HEIGHT) +
|
||||
SPECIAL_LABEL_HEIGHT +
|
||||
SPECIAL_GAP
|
||||
);
|
||||
});
|
||||
const ends = cumsum(hs);
|
||||
return specials.value.map((special, i) => ({
|
||||
special,
|
||||
labelTop: ends[i] - hs[i],
|
||||
textTop: ends[i] - hs[i] + SPECIAL_LABEL_HEIGHT,
|
||||
textHeight: hs[i] - SPECIAL_LABEL_HEIGHT - SPECIAL_GAP
|
||||
}));
|
||||
});
|
||||
|
||||
/** 获取临界生成器 */
|
||||
const getCriticalGenerator = (
|
||||
damage: IDamageSystem<IEnemyAttr, IHeroAttr>,
|
||||
target: IReadonlyEnemy<IEnemyAttr>
|
||||
) => {
|
||||
if (mode.value === DetailMode.Base) {
|
||||
const locator = props.locator ?? null;
|
||||
return damage.calculateEnemyCritical(target, 'atk', locator);
|
||||
} else if (props.view) {
|
||||
return damage.calculateViewCritical(props.view, 'atk');
|
||||
} else {
|
||||
return damage.calculateEnemyCritical(target, 'atk', null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算当前状态下的临界信息
|
||||
* @param value 当前要展示的怪物数据
|
||||
*/
|
||||
const getCriticals = (value?: IReadonlyEnemy<IEnemyAttr>) => {
|
||||
const target = value ?? current.value;
|
||||
if (!target) {
|
||||
criticals.value = [];
|
||||
return;
|
||||
}
|
||||
const damage = props.state.enemyContext.getDamageSystem();
|
||||
if (!damage) {
|
||||
criticals.value = [];
|
||||
return;
|
||||
}
|
||||
const gen = getCriticalGenerator(damage, target);
|
||||
if (!gen) {
|
||||
criticals.value = [];
|
||||
return;
|
||||
}
|
||||
const list: IEnemyCritical[] = [];
|
||||
for (let i = 0; i < CRITICAL_COUNT; i++) {
|
||||
const next = gen.next();
|
||||
if (next.done) break;
|
||||
else list.push(next.value);
|
||||
}
|
||||
criticals.value = list;
|
||||
};
|
||||
|
||||
// 当前怪物或模式变化后重算临界表,current 为空时置空
|
||||
watch(current, value => getCriticals(value), { immediate: true });
|
||||
|
||||
/** 将数值按大数规则格式化为展示字符串 */
|
||||
const format = (value: number) => core.formatBigNumber(value);
|
||||
|
||||
/** 在基础属性与计算后属性之间切换 */
|
||||
const toggleMode = () => {
|
||||
if (!props.view && !(props.enemy instanceof EnemyView)) return;
|
||||
if (mode.value === DetailMode.Base) {
|
||||
mode.value = DetailMode.Computed;
|
||||
} else {
|
||||
mode.value = DetailMode.Base;
|
||||
}
|
||||
};
|
||||
|
||||
/** 关闭当前怪物详情界面 */
|
||||
const close = () => props.controller.close(props.instance);
|
||||
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@common/exit', close).implement('@common/confirm', close);
|
||||
|
||||
return () => (
|
||||
<container loc={FULL_LOC}>
|
||||
<EnemyOne
|
||||
loc={MAIN_INFO_LOC}
|
||||
context={props.context}
|
||||
enemy={props.enemy}
|
||||
view={props.view}
|
||||
locator={props.locator}
|
||||
selected={false}
|
||||
/>
|
||||
<g-line
|
||||
line={SUMMARY_SPECIAL_DIVIDER}
|
||||
lineDash={[1, 1]}
|
||||
lineWidth={1}
|
||||
strokeStyle="#999"
|
||||
/>
|
||||
<Scroll noscroll loc={SPECIAL_SCROLL_LOC}>
|
||||
{specialRows.value.map(row => {
|
||||
const textLoc: ElementLocator = [0, row.textTop];
|
||||
const text = `[c=${row.special.getNameColor()}]${row.special.getSpecialName()}[/c]: ${row.special.getDescription()}`;
|
||||
return [
|
||||
<TextContent
|
||||
text={text}
|
||||
width={SPECIAL_WIDTH}
|
||||
loc={textLoc}
|
||||
font={specialFont}
|
||||
fillStyle="#dddddd"
|
||||
autoHeight
|
||||
onUpdateHeight={(height: number) =>
|
||||
updateTextHeight(row.special.code, height)
|
||||
}
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
<text
|
||||
text="无特殊属性"
|
||||
hidden={specials.value.length > 0}
|
||||
loc={[SPECIAL_LEFT, SPECIAL_TOP + SPECIAL_HEIGHT / 2]}
|
||||
anc={[0.5, 0.5]}
|
||||
font={noSpecialFont}
|
||||
fillStyle="#888"
|
||||
/>
|
||||
<container loc={CRITICAL_LOC}>
|
||||
{grid.value.locs.map((loc, index) => {
|
||||
const bottom = loc[1]! + loc[3]!;
|
||||
const cy = (bottom + loc[1]!) / 2;
|
||||
const c = criticals.value[index - 1];
|
||||
const text1 = index === 0 ? '加攻' : format(c.nextDiff);
|
||||
const text2 = index === 0 ? '减伤' : format(c.damageDiff);
|
||||
return [
|
||||
<text
|
||||
text={text1}
|
||||
loc={[loc[0]!, cy]}
|
||||
anc={[0, 0.5]}
|
||||
font={criticalFont}
|
||||
/>,
|
||||
<text
|
||||
text={text2}
|
||||
loc={[loc[0]!, cy]}
|
||||
anc={[0, 0.5]}
|
||||
font={criticalFont}
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</container>
|
||||
{/* 左下切换按钮 / 右下返回按钮 */}
|
||||
<text
|
||||
text={toggleText.value}
|
||||
loc={[24, MAIN_HEIGHT - 18]}
|
||||
anc={[0, 1]}
|
||||
font={statFont}
|
||||
cursor="pointer"
|
||||
fillStyle="gold"
|
||||
onClick={toggleMode}
|
||||
/>
|
||||
<text
|
||||
text="返回上一级"
|
||||
loc={[MAIN_WIDTH - 24, MAIN_HEIGHT - 18]}
|
||||
anc={[1, 1]}
|
||||
font={statFont}
|
||||
cursor="pointer"
|
||||
fillStyle="#ffffff"
|
||||
onClick={close}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, bookDetailProps);
|
||||
|
||||
/** 怪物详细信息的 UI 控制器 */
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { sleep } from '@motajs/common';
|
||||
|
||||
export interface ConfirmBoxProps extends IGameUIPropsBase, TextContentProps {
|
||||
/** 确认框的提示文本内容 */
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref, watch } from 'vue';
|
||||
import { Font } from '@motajs/render';
|
||||
import { ITileLocator } from '@motajs/common';
|
||||
import { adjustGrid2, TextAlign, TextContent } from '@user/client-base';
|
||||
import {
|
||||
IDamageSystem,
|
||||
IEnemyView,
|
||||
IReadonlyEnemyContext
|
||||
} from '@user/data-system';
|
||||
import { IReadonlyEnemy } from '@user/data-base';
|
||||
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
|
||||
/** 怪物主信息组件的 props */
|
||||
export interface IEnemyOneProps extends DefaultProps {
|
||||
/** 怪物所属的上下文对象 */
|
||||
context: IReadonlyEnemyContext<IEnemyAttr, IHeroAttr>;
|
||||
/** 要展示的怪物对象,与 `view` 同时传入时以 `view` 为准 */
|
||||
enemy?: IReadonlyEnemy<IEnemyAttr>;
|
||||
/** 要展示的怪物视图,与 `enemy` 同时传入时以 `view` 为准 */
|
||||
view?: IEnemyView<IEnemyAttr>;
|
||||
/** 怪物在地图上的位置 */
|
||||
locator?: ITileLocator;
|
||||
/** 是展示基础属性(false)还是展示计算后属性(true),默认为计算后属性 */
|
||||
computed?: boolean;
|
||||
/** 当前行是否处于选中态 */
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/** 怪物主信息组件的 props 声明 */
|
||||
const enemyOneProps = {
|
||||
props: ['enemy', 'view', 'selected', 'loc', 'width', 'height']
|
||||
} satisfies SetupComponentOptions<IEnemyOneProps>;
|
||||
|
||||
/** 怪物图标尺寸 */
|
||||
const ICON_SIZE = 48;
|
||||
/** 怪物图标中心点横坐标 */
|
||||
const ICON_CENTER = 48;
|
||||
/** 怪物名称距离图标中心点的距离 */
|
||||
const ENEMY_NAME_BOTTOM = ICON_CENTER / 2 + 4;
|
||||
/** 怪物特殊属性摘要距离图标中心点的距离 */
|
||||
const ENEMY_SPECIAL_TOP = ICON_CENTER / 2 + 4;
|
||||
/** 怪物特殊属性摘要的最大宽度 */
|
||||
const ENEMY_SPECIAL_MAX_WIDTH = 80;
|
||||
/** 怪物特殊属性摘要的高度 */
|
||||
const ENEMY_SPECIAL_HEIGHT = 24;
|
||||
|
||||
/** 属性网格的横向偏移 */
|
||||
const GRID_LEFT = 96;
|
||||
/** 网格信息区,数值与标签名中间的偏移量 */
|
||||
const GRID_VALUE_OFFSET = 32;
|
||||
|
||||
/** 怪物名称字体 */
|
||||
const nameFont = Font.defaults({ size: 15, weight: 700 });
|
||||
/** 特殊摘要与属性字体 */
|
||||
const statFont = Font.defaults({ size: 12 });
|
||||
|
||||
/** 怪物主信息组件,供怪物手册列表与详情页共用 */
|
||||
export const EnemyOne = defineComponent<IEnemyOneProps>(props => {
|
||||
/** 当前是展示计算后属性还是基础属性 */
|
||||
const showComputed = ref(props.computed ?? true);
|
||||
|
||||
const width = computed(() => props.loc?.[2] ?? props.width ?? 300);
|
||||
const height = computed(() => props.loc?.[3] ?? props.height ?? 100);
|
||||
|
||||
/** 网格布局 */
|
||||
const grid = computed(() =>
|
||||
adjustGrid2(width.value - GRID_LEFT, height.value, 3, 3, 2)
|
||||
);
|
||||
|
||||
/** 将数值按大数规则格式化为展示字符串 */
|
||||
const format = (value: number) => {
|
||||
return core.formatBigNumber(value);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.computed,
|
||||
value => {
|
||||
showComputed.value = value ?? true;
|
||||
}
|
||||
);
|
||||
|
||||
/** 获取基础属性,视图取基础怪物,否则取入参本身 */
|
||||
const getBase = (): IReadonlyEnemy<IEnemyAttr> | undefined => {
|
||||
if (props.view) {
|
||||
return props.view.getBaseEnemy();
|
||||
} else {
|
||||
return props.enemy;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取计算后属性,视图走计算接口,否则取入参本身 */
|
||||
const getComputed = (): IReadonlyEnemy<IEnemyAttr> | undefined => {
|
||||
if (props.view) {
|
||||
return props.view.getComputedEnemy();
|
||||
} else {
|
||||
return props.enemy;
|
||||
}
|
||||
};
|
||||
|
||||
/** 当前的怪物属性 */
|
||||
const enemy = computed(() =>
|
||||
showComputed.value ? getComputed() : getBase()
|
||||
);
|
||||
|
||||
const specials = computed((): string => {
|
||||
if (!enemy.value) return '无属性';
|
||||
const specials = [...enemy.value.iterateSpecials()].sort(
|
||||
(a, b) => a.code - b.code
|
||||
);
|
||||
const names = specials.map(v => [v.getSpecialName(), v.getNameColor()]);
|
||||
if (names.length === 0) return '无属性';
|
||||
else if (names.length === 1)
|
||||
return `[c=${names[0][1]}]${names[0][0]}[/c]`;
|
||||
else if (names.length === 2) {
|
||||
if (names[0].length + names[1].length > 12) {
|
||||
return `[c=${names[0][1]}]${names[0][0]}[/c]...`;
|
||||
} else {
|
||||
return `[c=${names[0][1]}]${names[0][0]}[/c] [c=${names[1][1]}]${names[1][0]}[/c]`;
|
||||
}
|
||||
} else {
|
||||
if (names[0].length + names[1].length > 12) {
|
||||
return `[c=${names[0][1]}]${names[0][0]}[/c]...`;
|
||||
} else {
|
||||
return `[c=${names[0][1]}]${names[0][0]}[/c] [c=${names[1][1]}]${names[1][0]}[/c]...`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取临界生成器
|
||||
* @param system 伤害计算系统
|
||||
*/
|
||||
const getCritical = function* (
|
||||
system: IDamageSystem<IEnemyAttr, IHeroAttr>
|
||||
) {
|
||||
const loc = props.locator ?? null;
|
||||
if (showComputed.value) {
|
||||
const view = props.view;
|
||||
const computed = getComputed();
|
||||
if (view) {
|
||||
yield* system.calculateViewCritical(view, 'atk');
|
||||
} else if (computed) {
|
||||
yield* system.calculateEnemyCritical(computed, 'atk', loc);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const base = getBase();
|
||||
if (base) {
|
||||
yield* system.calculateEnemyCritical(base, 'atk', loc);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取怪物的伤害、临界等信息 */
|
||||
const damageData = computed(() => {
|
||||
const enemy = showComputed.value ? getComputed() : getBase();
|
||||
const system = props.context.getDamageSystem();
|
||||
if (!system || !enemy) return ['无法战斗', '0', '0', '0'];
|
||||
const state = props.context.state;
|
||||
const critical = getCritical(system);
|
||||
const first = critical.next();
|
||||
const hero = state.hero.getAttribute();
|
||||
const isolated = state.hero.getIsolatedAttribute();
|
||||
isolated.add('def', 1);
|
||||
const system2 = system.with(isolated);
|
||||
const def1 = system2.getDamageInfoByComputed(enemy);
|
||||
if (!first.done) {
|
||||
// 有临界,正常算
|
||||
const value = first.value;
|
||||
const def = def1?.damage ?? value.baseInfo.damage;
|
||||
return [
|
||||
format(value.baseInfo.damage),
|
||||
format(value.nextDiff),
|
||||
format(value.damageDiff),
|
||||
format(value.baseInfo.damage - def)
|
||||
];
|
||||
} else {
|
||||
// 此时有两种情况,一种是没有下一个临界了,一种是还没破防
|
||||
const curr = system.getDamageInfoByComputed(enemy);
|
||||
const def = enemy.getAttribute('def');
|
||||
if (hero.getFinalAttribute('atk') <= enemy.getAttribute('def')) {
|
||||
// 未破防
|
||||
isolated.add('def', -1);
|
||||
isolated.set('atk', def + 1);
|
||||
const dam2 = system2.getDamageInfoByComputed(enemy);
|
||||
if (!dam2) {
|
||||
// 还没破防,说明可能是特殊机制怪,那么就直接按 0 算
|
||||
return ['无法战斗', '0', '0', '0'];
|
||||
} else {
|
||||
// 破防了,那么显示破防临界
|
||||
return [
|
||||
isNil(curr?.damage) ? '无法战斗' : format(curr.damage),
|
||||
format(def + 1 - hero.getFinalAttribute('atk')),
|
||||
`-> ${dam2.damage}`,
|
||||
'0'
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 已破防,那么应该是没有临界了
|
||||
if (!curr) {
|
||||
// 先计算当前伤害,但是如果没有,那么可能是机制怪
|
||||
return ['无法战斗', '0', '0', '0'];
|
||||
} else {
|
||||
const def = def1?.damage ?? curr.damage;
|
||||
return [
|
||||
format(curr.damage),
|
||||
'0',
|
||||
'0',
|
||||
format(def - curr.damage)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
const items = [
|
||||
{
|
||||
label: '生命',
|
||||
value: format(enemy.value?.getAttribute('hp') ?? 0),
|
||||
color: 'lightgreen'
|
||||
},
|
||||
{
|
||||
label: '攻击',
|
||||
value: format(enemy.value?.getAttribute('atk') ?? 0),
|
||||
color: 'lightcoral'
|
||||
},
|
||||
{
|
||||
label: '防御',
|
||||
value: format(enemy.value?.getAttribute('def') ?? 0),
|
||||
color: 'lightblue'
|
||||
},
|
||||
{
|
||||
label: '金币',
|
||||
value: format(enemy.value?.getAttribute('money') ?? 0),
|
||||
color: 'lightyellow'
|
||||
},
|
||||
{
|
||||
label: '经验',
|
||||
value: format(enemy.value?.getAttribute('exp') ?? 0),
|
||||
color: 'lawngreen'
|
||||
},
|
||||
{ label: '伤害', value: damageData.value[0], color: 'red' },
|
||||
{ label: '临界', value: damageData.value[1], color: 'lightsalmon' },
|
||||
{ label: '减伤', value: damageData.value[2], color: 'lightpink' },
|
||||
{ label: '1 防', value: damageData.value[3], color: 'cyan' }
|
||||
];
|
||||
return (
|
||||
<container>
|
||||
<g-rectr
|
||||
loc={[1, 1, width.value - 2, height.value - 2]}
|
||||
circle={[10]}
|
||||
hidden={!props.selected}
|
||||
stroke
|
||||
strokeStyle="gold"
|
||||
lineWidth={1.5}
|
||||
noevent
|
||||
/>
|
||||
<icon
|
||||
icon={enemy.value?.code ?? 0}
|
||||
loc={[ICON_CENTER, height.value / 2, ICON_SIZE, ICON_SIZE]}
|
||||
anc={[0.5, 0.5]}
|
||||
noevent
|
||||
/>
|
||||
<text
|
||||
text={enemy.value?.getAttribute('name') ?? '新怪物'}
|
||||
loc={[ICON_CENTER, height.value / 2 - ENEMY_NAME_BOTTOM]}
|
||||
anc={[0.5, 1]}
|
||||
font={nameFont}
|
||||
fillStyle="#ffffff"
|
||||
noevent
|
||||
/>
|
||||
{/* 因为不同属性名颜色不同,所以要用 TextContent 来支持解析 */}
|
||||
<TextContent
|
||||
text={specials.value}
|
||||
width={ENEMY_SPECIAL_MAX_WIDTH}
|
||||
height={ENEMY_SPECIAL_HEIGHT}
|
||||
textAlign={TextAlign.Center}
|
||||
loc={[ICON_CENTER, height.value / 2 + ENEMY_SPECIAL_TOP]}
|
||||
anc={[0.5, 0]}
|
||||
font={statFont}
|
||||
fillStyle="#ffd27f"
|
||||
noevent
|
||||
/>
|
||||
<container
|
||||
loc={[GRID_LEFT, 0, width.value - GRID_LEFT, height.value]}
|
||||
>
|
||||
{grid.value.locs.map((loc, index) => {
|
||||
const item = items[index];
|
||||
return [
|
||||
<text
|
||||
text={item.label}
|
||||
loc={[loc[0], loc[1]]}
|
||||
font={statFont}
|
||||
fillStyle={item.color}
|
||||
/>,
|
||||
<text
|
||||
text={item.value}
|
||||
loc={[loc[0]! + GRID_VALUE_OFFSET, loc[1]]}
|
||||
font={statFont}
|
||||
fillStyle={item.color}
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</container>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
}, enemyOneProps);
|
||||
+298
-274
@@ -2,11 +2,13 @@ import { GameUI, SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { Font } from '@motajs/render';
|
||||
import { Scroll, TextContent } from '@user/client-base';
|
||||
import { EquipStatus, IHeroState } from '@user/data-base';
|
||||
import { ElementLocator, IActionEvent } from '@motajs/render';
|
||||
import { Scroll, TextContent, useKey } from '@user/client-base';
|
||||
import { EquipStatus, IEquipmentState, IHeroState } from '@user/data-base';
|
||||
import { IHeroAttr } from '@user/data-common';
|
||||
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client';
|
||||
import { IUIPropsBase } from '@user/client-modules';
|
||||
import { IGameUIPropsBase } from '@user/client-base';
|
||||
import { ToolBoxUI } from './toolbox';
|
||||
|
||||
interface IHeroAttrItem {
|
||||
/** 勇士属性的键名 */
|
||||
@@ -15,7 +17,7 @@ interface IHeroAttrItem {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface EquipBoxProps extends IUIPropsBase {
|
||||
export interface EquipBoxProps extends IGameUIPropsBase {
|
||||
/** 勇士状态对象 */
|
||||
heroState: IHeroState<IHeroAttr>;
|
||||
/** 状态区域要列举的属性,按数组顺序自上而下展示 */
|
||||
@@ -59,6 +61,42 @@ const OWNED_HEADER = 22;
|
||||
const OWNED_ROW_HEIGHT = 50;
|
||||
/** 拥有装备行内图标的尺寸 */
|
||||
const OWNED_ICON_SIZE = 40;
|
||||
/** 描述列表区域的顶部纵坐标,装备名与装备孔占用其上方的 56 像素 */
|
||||
const DESC_LIST_TOP = DESC_TOP + 56;
|
||||
/** 描述列表区域的高度 */
|
||||
const DESC_LIST_HEIGHT = DESC_HEIGHT - 56;
|
||||
/** 拥有装备列表区域的顶部纵坐标,标题占用其上方的空间 */
|
||||
const OWNED_LIST_TOP = OWNED_TOP + OWNED_HEADER;
|
||||
/** 拥有装备列表区域的高度 */
|
||||
const OWNED_LIST_HEIGHT = OWNED_HEIGHT - OWNED_HEADER;
|
||||
/** 状态区滚动区域的外接矩形,只由既有布局常量组成 */
|
||||
const STATUS_SCROLL_LOC: ElementLocator = [
|
||||
0,
|
||||
STATUS_HEADER,
|
||||
STATUS_WIDTH,
|
||||
MAIN_HEIGHT - STATUS_HEADER
|
||||
];
|
||||
/** 描述区滚动区域的外接矩形,只由既有布局常量组成 */
|
||||
const DESC_SCROLL_LOC: ElementLocator = [
|
||||
RIGHT_LEFT,
|
||||
DESC_LIST_TOP,
|
||||
RIGHT_WIDTH,
|
||||
DESC_LIST_HEIGHT
|
||||
];
|
||||
/** 装备槽区滚动区域的外接矩形,只由既有布局常量组成 */
|
||||
const SLOT_SCROLL_LOC: ElementLocator = [
|
||||
RIGHT_LEFT,
|
||||
SLOT_TOP,
|
||||
RIGHT_WIDTH,
|
||||
SLOT_HEIGHT
|
||||
];
|
||||
/** 拥有装备区滚动区域的外接矩形,只由既有布局常量组成 */
|
||||
const OWNED_SCROLL_LOC: ElementLocator = [
|
||||
RIGHT_LEFT,
|
||||
OWNED_LIST_TOP,
|
||||
RIGHT_WIDTH,
|
||||
OWNED_LIST_HEIGHT
|
||||
];
|
||||
/** 拖拽浮层图标的尺寸 */
|
||||
const DRAG_ICON_SIZE = 44;
|
||||
/** 判定为拖拽所需的最小移动距离(像素) */
|
||||
@@ -217,8 +255,16 @@ export const EquipBox = defineComponent<EquipBoxProps>(props => {
|
||||
return;
|
||||
}
|
||||
if (fromSlot >= 0) {
|
||||
// 拖拽已装备的装备,释放时卸下
|
||||
hero.equip.unequip(fromSlot);
|
||||
// 拖拽已装备的装备:目标槽有效且可装备则换装,否则卸下
|
||||
if (
|
||||
target >= 0 &&
|
||||
target !== fromSlot &&
|
||||
hero.equip.canEquipTo(uid, target) !== EquipStatus.CannotEquip
|
||||
) {
|
||||
hero.equip.equip(uid, target, true);
|
||||
} else {
|
||||
hero.equip.unequip(fromSlot);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -232,15 +278,68 @@ export const EquipBox = defineComponent<EquipBoxProps>(props => {
|
||||
/** 关闭当前装备界面 */
|
||||
const exit = () => props.controller.close(props.instance);
|
||||
|
||||
/** 打开道具栏界面 */
|
||||
const toTool = () => {
|
||||
props.controller.keep();
|
||||
props.controller.close(props.instance);
|
||||
props.controller.open(ToolBoxUI, {
|
||||
heroState: props.heroState,
|
||||
attributes: props.attributes
|
||||
});
|
||||
};
|
||||
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@common/exit', exit).implement('@ui/command', exit);
|
||||
|
||||
/** 把指针移动转发给拖拽跟踪,组件需要的是无参事件处理器 */
|
||||
const onMove = (ev: IActionEvent) => {
|
||||
movePress(ev.absoluteX, ev.absoluteY);
|
||||
};
|
||||
|
||||
/** 生成装备槽的悬停处理器,指针进入时记录当前槽位 */
|
||||
const getSlotEnter = (slot: number) => () => {
|
||||
hoverSlot.value = slot;
|
||||
};
|
||||
|
||||
/** 生成装备槽的离开处理器,仅在仍悬停该槽时清除悬停态 */
|
||||
const getSlotLeave = (slot: number) => () => {
|
||||
if (hoverSlot.value === slot) hoverSlot.value = -1;
|
||||
};
|
||||
|
||||
/** 生成装备槽图标的按下处理器,按下时以槽位来源开始跟踪 */
|
||||
const getSlotDown =
|
||||
(equip: IEquipmentState<IHeroAttr> | null, slot: number) =>
|
||||
(ev: IActionEvent) => {
|
||||
if (!equip) return;
|
||||
startPress(equip.uid, slot, ev.absoluteX, ev.absoluteY);
|
||||
};
|
||||
|
||||
/** 生成拥有装备行的按下处理器,按下时以拥有装备来源开始跟踪 */
|
||||
const getOwnedDown = (uid: number) => (ev: IActionEvent) => {
|
||||
startPress(uid, -1, ev.absoluteX, ev.absoluteY);
|
||||
};
|
||||
|
||||
return () => {
|
||||
const equip = selectedEquip.value;
|
||||
const d = diff.value;
|
||||
const dragLoc: ElementLocator = [
|
||||
dragX.value - DRAG_ICON_SIZE / 2,
|
||||
dragY.value - DRAG_ICON_SIZE / 2,
|
||||
DRAG_ICON_SIZE,
|
||||
DRAG_ICON_SIZE
|
||||
];
|
||||
return (
|
||||
<container
|
||||
loc={FULL_LOC}
|
||||
onMove={ev => movePress(ev.absoluteX, ev.absoluteY)}
|
||||
onUp={endPress}
|
||||
>
|
||||
<container loc={FULL_LOC} onMove={onMove} onUp={endPress}>
|
||||
{/* 右上角跳到道具栏 */}
|
||||
<text
|
||||
text="道具栏 →"
|
||||
loc={[MAIN_WIDTH - 8, 8]}
|
||||
anc={[1, 0]}
|
||||
font={titleFont}
|
||||
cursor="pointer"
|
||||
fillStyle="gold"
|
||||
onClick={toTool}
|
||||
/>
|
||||
{/* 状态区 */}
|
||||
<text
|
||||
text="状态"
|
||||
@@ -249,15 +348,8 @@ export const EquipBox = defineComponent<EquipBoxProps>(props => {
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
<Scroll
|
||||
noscroll
|
||||
loc={[
|
||||
0,
|
||||
STATUS_HEADER,
|
||||
STATUS_WIDTH,
|
||||
MAIN_HEIGHT - STATUS_HEADER
|
||||
]}
|
||||
>
|
||||
<Scroll noscroll loc={STATUS_SCROLL_LOC}>
|
||||
{/* 每行属性名、数值与增减值,直接作为 Scroll 子项 */}
|
||||
{props.attributes.map((attr, i) => {
|
||||
const value = hero.attribute.getFinalAttribute(attr.id);
|
||||
let delta = 0;
|
||||
@@ -267,255 +359,193 @@ export const EquipBox = defineComponent<EquipBoxProps>(props => {
|
||||
}
|
||||
const valueX =
|
||||
delta === 0 ? STATUS_WIDTH - 8 : STATUS_WIDTH - 60;
|
||||
return (
|
||||
<container
|
||||
key={attr.id}
|
||||
loc={[
|
||||
0,
|
||||
i * STATUS_ROW_HEIGHT,
|
||||
STATUS_WIDTH,
|
||||
STATUS_ROW_HEIGHT
|
||||
]}
|
||||
>
|
||||
<text
|
||||
text={attr.name}
|
||||
loc={[8, STATUS_ROW_HEIGHT / 2]}
|
||||
anc={[0, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#dddddd"
|
||||
/>
|
||||
<text
|
||||
text={String(value)}
|
||||
loc={[valueX, STATUS_ROW_HEIGHT / 2]}
|
||||
anc={[1, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#ffffff"
|
||||
/>
|
||||
{delta !== 0 && (
|
||||
<text
|
||||
text={`(${delta > 0 ? '+' : ''}${delta})`}
|
||||
loc={[
|
||||
STATUS_WIDTH - 8,
|
||||
STATUS_ROW_HEIGHT / 2
|
||||
]}
|
||||
anc={[1, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle={
|
||||
delta > 0 ? '#57ff78' : '#ff5a5a'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</container>
|
||||
);
|
||||
const y = i * STATUS_ROW_HEIGHT;
|
||||
const deltaLoc: ElementLocator = [
|
||||
STATUS_WIDTH - 8,
|
||||
y + STATUS_ROW_HEIGHT / 2
|
||||
];
|
||||
return [
|
||||
<text
|
||||
text={attr.name}
|
||||
loc={[8, y + STATUS_ROW_HEIGHT / 2]}
|
||||
anc={[0, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#dddddd"
|
||||
/>,
|
||||
<text
|
||||
text={String(value)}
|
||||
loc={[valueX, y + STATUS_ROW_HEIGHT / 2]}
|
||||
anc={[1, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#ffffff"
|
||||
/>,
|
||||
// Scroll 会覆盖直接子元素的 hidden,增减值为 0 时改用空文本
|
||||
<text
|
||||
text={
|
||||
delta === 0
|
||||
? ''
|
||||
: `(${delta > 0 ? '+' : ''}${delta})`
|
||||
}
|
||||
loc={deltaLoc}
|
||||
anc={[1, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle={delta > 0 ? '#57ff78' : '#ff5a5a'}
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
|
||||
{/* 描述区 */}
|
||||
<container
|
||||
loc={[RIGHT_LEFT, DESC_TOP, RIGHT_WIDTH, DESC_HEIGHT]}
|
||||
>
|
||||
{!equip && (
|
||||
<text
|
||||
text="未选择装备"
|
||||
loc={[8, 12]}
|
||||
anc={[0, 0]}
|
||||
font={labelFont}
|
||||
fillStyle="#888888"
|
||||
/>
|
||||
)}
|
||||
{equip && (
|
||||
<text
|
||||
text={equip.item.name}
|
||||
loc={[8, 12]}
|
||||
anc={[0, 0]}
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
)}
|
||||
{equip && (
|
||||
<text
|
||||
text={`装备孔:${equip.item.equip.slots.join(' / ')}`}
|
||||
loc={[8, 36]}
|
||||
anc={[0, 0]}
|
||||
font={labelFont}
|
||||
fillStyle="#bbbbbb"
|
||||
/>
|
||||
)}
|
||||
{equip && (
|
||||
<Scroll
|
||||
noscroll
|
||||
loc={[0, 56, RIGHT_WIDTH, DESC_HEIGHT - 56]}
|
||||
>
|
||||
<TextContent
|
||||
text={equip.item.text}
|
||||
width={RIGHT_WIDTH - 16}
|
||||
loc={[8, 4, RIGHT_WIDTH - 16, DESC_HEIGHT - 60]}
|
||||
font={labelFont}
|
||||
fillStyle="#dddddd"
|
||||
/>
|
||||
</Scroll>
|
||||
)}
|
||||
</container>
|
||||
<text
|
||||
text="未选择装备"
|
||||
loc={[RIGHT_LEFT + 8, DESC_TOP + 12]}
|
||||
anc={[0, 0]}
|
||||
font={labelFont}
|
||||
fillStyle="#888888"
|
||||
hidden={!!equip}
|
||||
/>
|
||||
<text
|
||||
text={equip ? equip.item.name : ''}
|
||||
loc={[RIGHT_LEFT + 8, DESC_TOP + 12]}
|
||||
anc={[0, 0]}
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
hidden={!equip}
|
||||
/>
|
||||
<text
|
||||
text={
|
||||
equip
|
||||
? `装备孔:${equip.item.equip.slots.join(' / ')}`
|
||||
: ''
|
||||
}
|
||||
loc={[RIGHT_LEFT + 8, DESC_TOP + 36]}
|
||||
anc={[0, 0]}
|
||||
font={labelFont}
|
||||
fillStyle="#bbbbbb"
|
||||
hidden={!equip}
|
||||
/>
|
||||
<Scroll noscroll loc={DESC_SCROLL_LOC}>
|
||||
<TextContent
|
||||
text={equip ? equip.item.text : ''}
|
||||
width={RIGHT_WIDTH - 16}
|
||||
loc={[8, 4, RIGHT_WIDTH - 16, DESC_HEIGHT - 60]}
|
||||
font={labelFont}
|
||||
fillStyle="#dddddd"
|
||||
/>
|
||||
</Scroll>
|
||||
|
||||
{/* 装备槽区 */}
|
||||
<container
|
||||
loc={[RIGHT_LEFT, SLOT_TOP, RIGHT_WIDTH, SLOT_HEIGHT]}
|
||||
>
|
||||
<Scroll hor noscroll loc={[0, 0, RIGHT_WIDTH, SLOT_HEIGHT]}>
|
||||
{hero.equip.slots.map((name, i) => {
|
||||
const slotEquip = equippedList.value[i];
|
||||
const isSelected =
|
||||
!!slotEquip && slotEquip.uid === selected.value;
|
||||
return (
|
||||
<container
|
||||
key={i}
|
||||
loc={[
|
||||
i * SLOT_CELL_WIDTH,
|
||||
0,
|
||||
SLOT_CELL_WIDTH,
|
||||
SLOT_HEIGHT
|
||||
]}
|
||||
cursor="pointer"
|
||||
onEnter={() => {
|
||||
hoverSlot.value = i;
|
||||
}}
|
||||
onLeave={() => {
|
||||
if (hoverSlot.value === i) {
|
||||
hoverSlot.value = -1;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<g-rectr
|
||||
loc={[
|
||||
4,
|
||||
4,
|
||||
SLOT_CELL_WIDTH - 8,
|
||||
SLOT_HEIGHT - 8
|
||||
]}
|
||||
circle={[6]}
|
||||
fill
|
||||
stroke
|
||||
fillStyle="rgba(255, 255, 255, 0.08)"
|
||||
strokeStyle={
|
||||
isSelected ? 'gold' : '#666666'
|
||||
}
|
||||
lineWidth={isSelected ? 2 : 1}
|
||||
/>
|
||||
{slotEquip && (
|
||||
<icon
|
||||
icon={slotEquip.item.num}
|
||||
loc={[
|
||||
(SLOT_CELL_WIDTH -
|
||||
SLOT_ICON_SIZE) /
|
||||
2,
|
||||
14,
|
||||
SLOT_ICON_SIZE,
|
||||
SLOT_ICON_SIZE
|
||||
]}
|
||||
cursor="pointer"
|
||||
onDown={ev =>
|
||||
startPress(
|
||||
slotEquip.uid,
|
||||
i,
|
||||
ev.absoluteX,
|
||||
ev.absoluteY
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
text={name}
|
||||
loc={[
|
||||
SLOT_CELL_WIDTH / 2,
|
||||
SLOT_HEIGHT - 12
|
||||
]}
|
||||
anc={[0.5, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#cccccc"
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
</container>
|
||||
<Scroll hor noscroll loc={SLOT_SCROLL_LOC}>
|
||||
{/* 每格槽底、图标与槽名,直接作为 Scroll 子项 */}
|
||||
{hero.equip.slots.map((name, i) => {
|
||||
const slotEquip = equippedList.value[i];
|
||||
const isSelected =
|
||||
!!slotEquip && slotEquip.uid === selected.value;
|
||||
const cellX = i * SLOT_CELL_WIDTH;
|
||||
const slotCellLoc: ElementLocator = [
|
||||
cellX + 4,
|
||||
4,
|
||||
SLOT_CELL_WIDTH - 8,
|
||||
SLOT_HEIGHT - 8
|
||||
];
|
||||
const slotIconLoc: ElementLocator = [
|
||||
cellX + (SLOT_CELL_WIDTH - SLOT_ICON_SIZE) / 2,
|
||||
14,
|
||||
SLOT_ICON_SIZE,
|
||||
SLOT_ICON_SIZE
|
||||
];
|
||||
const slotNameLoc: ElementLocator = [
|
||||
cellX + SLOT_CELL_WIDTH / 2,
|
||||
SLOT_HEIGHT - 12
|
||||
];
|
||||
return [
|
||||
<g-rectr
|
||||
loc={slotCellLoc}
|
||||
circle={[6]}
|
||||
fill
|
||||
stroke
|
||||
fillStyle="rgba(255, 255, 255, 0.08)"
|
||||
strokeStyle={isSelected ? 'gold' : '#666666'}
|
||||
lineWidth={isSelected ? 2 : 1}
|
||||
cursor="pointer"
|
||||
onEnter={getSlotEnter(i)}
|
||||
onLeave={getSlotLeave(i)}
|
||||
/>,
|
||||
// 拖动起手仍只在图标上,整格只负责悬停判定
|
||||
<icon
|
||||
icon={slotEquip ? slotEquip.item.num : 0}
|
||||
loc={slotIconLoc}
|
||||
cursor="pointer"
|
||||
onEnter={getSlotEnter(i)}
|
||||
onLeave={getSlotLeave(i)}
|
||||
onDown={getSlotDown(slotEquip, i)}
|
||||
/>,
|
||||
<text
|
||||
text={name}
|
||||
loc={slotNameLoc}
|
||||
anc={[0.5, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#cccccc"
|
||||
noevent
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
|
||||
{/* 拥有装备区 */}
|
||||
<container
|
||||
loc={[RIGHT_LEFT, OWNED_TOP, RIGHT_WIDTH, OWNED_HEIGHT]}
|
||||
>
|
||||
<text
|
||||
text="拥有装备"
|
||||
loc={[8, 2]}
|
||||
anc={[0, 0]}
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
<Scroll
|
||||
noscroll
|
||||
loc={[
|
||||
0,
|
||||
OWNED_HEADER,
|
||||
RIGHT_WIDTH,
|
||||
OWNED_HEIGHT - OWNED_HEADER
|
||||
]}
|
||||
>
|
||||
{owned.value.map((item, i) => {
|
||||
const isSelected = item.uid === selected.value;
|
||||
return (
|
||||
<container
|
||||
key={item.uid}
|
||||
loc={[
|
||||
0,
|
||||
i * OWNED_ROW_HEIGHT,
|
||||
RIGHT_WIDTH,
|
||||
OWNED_ROW_HEIGHT
|
||||
]}
|
||||
cursor="pointer"
|
||||
onDown={ev =>
|
||||
startPress(
|
||||
item.uid,
|
||||
-1,
|
||||
ev.absoluteX,
|
||||
ev.absoluteY
|
||||
)
|
||||
}
|
||||
>
|
||||
<g-rectr
|
||||
loc={[
|
||||
4,
|
||||
2,
|
||||
RIGHT_WIDTH - 8,
|
||||
OWNED_ROW_HEIGHT - 4
|
||||
]}
|
||||
circle={[4]}
|
||||
fill
|
||||
fillStyle={
|
||||
isSelected
|
||||
? 'rgba(255, 204, 0, 0.28)'
|
||||
: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
/>
|
||||
<icon
|
||||
icon={item.item.num}
|
||||
loc={[
|
||||
10,
|
||||
6,
|
||||
OWNED_ICON_SIZE,
|
||||
OWNED_ICON_SIZE
|
||||
]}
|
||||
/>
|
||||
<text
|
||||
text={item.item.name}
|
||||
loc={[58, OWNED_ROW_HEIGHT / 2]}
|
||||
anc={[0, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#eeeeee"
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
</container>
|
||||
<text
|
||||
text="拥有装备"
|
||||
loc={[RIGHT_LEFT + 8, OWNED_TOP + 2]}
|
||||
anc={[0, 0]}
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
<Scroll noscroll loc={OWNED_SCROLL_LOC}>
|
||||
{/* 每行行底、图标与名称,直接作为 Scroll 子项 */}
|
||||
{owned.value.map((item, i) => {
|
||||
const isSelected = item.uid === selected.value;
|
||||
const y = i * OWNED_ROW_HEIGHT;
|
||||
const ownedCellLoc: ElementLocator = [
|
||||
4,
|
||||
y + 2,
|
||||
RIGHT_WIDTH - 8,
|
||||
OWNED_ROW_HEIGHT - 4
|
||||
];
|
||||
const ownedIconLoc: ElementLocator = [
|
||||
10,
|
||||
y + 6,
|
||||
OWNED_ICON_SIZE,
|
||||
OWNED_ICON_SIZE
|
||||
];
|
||||
return [
|
||||
<g-rectr
|
||||
loc={ownedCellLoc}
|
||||
circle={[4]}
|
||||
fill
|
||||
fillStyle={
|
||||
isSelected
|
||||
? 'rgba(255, 204, 0, 0.28)'
|
||||
: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
cursor="pointer"
|
||||
onDown={getOwnedDown(item.uid)}
|
||||
/>,
|
||||
<icon
|
||||
icon={item.item.num}
|
||||
loc={ownedIconLoc}
|
||||
noevent
|
||||
/>,
|
||||
<text
|
||||
text={item.item.name}
|
||||
loc={[58, y + OWNED_ROW_HEIGHT / 2]}
|
||||
anc={[0, 0.5]}
|
||||
font={labelFont}
|
||||
fillStyle="#eeeeee"
|
||||
noevent
|
||||
/>
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
|
||||
{/* 右下角退出按钮 */}
|
||||
<text
|
||||
@@ -529,19 +559,13 @@ export const EquipBox = defineComponent<EquipBoxProps>(props => {
|
||||
/>
|
||||
|
||||
{/* 拖拽浮层 */}
|
||||
{dragging.value && (
|
||||
<icon
|
||||
icon={dragNum.value}
|
||||
loc={[
|
||||
dragX.value - DRAG_ICON_SIZE / 2,
|
||||
dragY.value - DRAG_ICON_SIZE / 2,
|
||||
DRAG_ICON_SIZE,
|
||||
DRAG_ICON_SIZE
|
||||
]}
|
||||
noevent
|
||||
zIndex={100}
|
||||
/>
|
||||
)}
|
||||
<icon
|
||||
icon={dragNum.value > 0 ? dragNum.value : 0}
|
||||
loc={dragLoc}
|
||||
noevent
|
||||
zIndex={100}
|
||||
hidden={!dragging.value}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
|
||||
+142
-182
@@ -6,9 +6,9 @@ import {
|
||||
KeyCodeUtils,
|
||||
SetupComponentOptions
|
||||
} from '@motajs/system';
|
||||
import { Font } from '@motajs/render';
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import { computed, defineComponent, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { IGameUIPropsBase, Scroll, Selection, useKey } from '@user/client-base';
|
||||
import { IGameUIPropsBase, ListPage, Scroll, useKey } from '@user/client-base';
|
||||
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client/uiConstant';
|
||||
|
||||
/** 界面左侧与顶部的内边距 */
|
||||
@@ -16,22 +16,48 @@ const LEFT_PAD = 16;
|
||||
const TOP_PAD = 16;
|
||||
/** 左侧分组列表的宽度 */
|
||||
const GROUP_WIDTH = 180;
|
||||
/** 左侧分组列表占整个界面的宽度比例,交给 ListPage 决定列表区宽度 */
|
||||
const GROUP_BASIS = GROUP_WIDTH / MAIN_WIDTH;
|
||||
/** ListPage 内容区的宽度,即界面总宽减去左栏所占的比例 */
|
||||
const CONTENT_WIDTH = MAIN_WIDTH * (1 - GROUP_BASIS);
|
||||
/** 右栏指令区域的宽度,左右各留出一份内边距 */
|
||||
const COMMAND_WIDTH = CONTENT_WIDTH - LEFT_PAD * 3;
|
||||
/** 左侧每一行分组的高度 */
|
||||
const GROUP_ROW_HEIGHT = 24;
|
||||
/** 左侧列表区域的起始纵坐标 */
|
||||
const LIST_TOP = TOP_PAD + 32;
|
||||
/** 右侧指令区域的起始横坐标 */
|
||||
const RIGHT_LEFT = GROUP_WIDTH + LEFT_PAD * 2;
|
||||
/** 右侧指令区域的宽度 */
|
||||
const RIGHT_WIDTH = MAIN_WIDTH - RIGHT_LEFT - LEFT_PAD;
|
||||
/** 左右两栏列表区域的高度,底部留出返回按钮的空间 */
|
||||
const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 48;
|
||||
/** 右栏指令区域在 ListPage 内容区内的定位,叠加内容区原点后与原有绝对位置一致 */
|
||||
const COMMAND_SCROLL_LOC: ElementLocator = [
|
||||
LEFT_PAD * 2,
|
||||
LIST_TOP,
|
||||
COMMAND_WIDTH,
|
||||
LIST_HEIGHT
|
||||
];
|
||||
/** 关闭按钮的定位,放在界面左下角,文本由 ListPage 内置渲染 */
|
||||
const CLOSE_LOC: ElementLocator = [
|
||||
LEFT_PAD,
|
||||
MAIN_HEIGHT - LEFT_PAD,
|
||||
void 0,
|
||||
void 0,
|
||||
0,
|
||||
1
|
||||
];
|
||||
/** 单个按键框的宽度 */
|
||||
const KEY_BOX_WIDTH = 180;
|
||||
/** 单个按键框的高度 */
|
||||
const KEY_BOX_HEIGHT = 24;
|
||||
/** 指令之间的纵向间隔 */
|
||||
const COMMAND_GAP = 12;
|
||||
/** 按键框左边缘的横坐标 */
|
||||
const BOX_LEFT = COMMAND_WIDTH - KEY_BOX_WIDTH - LEFT_PAD;
|
||||
/** 按键框命中矩形的高度,比整框少 4 像素以保留边框间隙 */
|
||||
const BOX_FILL_HEIGHT = KEY_BOX_HEIGHT - 4;
|
||||
/** 指令名的文本起始横坐标 */
|
||||
const COMMAND_TEXT_LEFT = 8;
|
||||
/** 指令名相对所在行顶部的纵向偏移 */
|
||||
const COMMAND_TEXT_TOP = 12;
|
||||
/** 界面内所有文本共用的字体 */
|
||||
const CONTENT_FONT = Font.defaults({ size: 13 });
|
||||
|
||||
@@ -42,11 +68,23 @@ const hotkeySettingsProps = {
|
||||
props: ['controller', 'instance', 'state']
|
||||
} satisfies SetupComponentOptions<HotkeySettingsProps>;
|
||||
|
||||
interface ICommandRow {
|
||||
/** 该行对应的指令 */
|
||||
readonly command: IHotkeyCommand;
|
||||
/** 该行顶部纵坐标 */
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
/** 热键设置界面,左栏切换分组,右栏为每条指令的每个按键组合显示一个可点击框 */
|
||||
export const HotkeySettings = defineComponent<HotkeySettingsProps>(props => {
|
||||
const hotkey = props.state.hotkey;
|
||||
const groups = Array.from(hotkey.iterateGroups());
|
||||
const selectedGroup = ref(groups.length > 0 ? groups[0][0] : '');
|
||||
/** 分组列表的显示项,第一项为分组 id,第二项为分组名称 */
|
||||
const groupItems: [string, string][] = groups.map(([id, group]) => [
|
||||
id,
|
||||
group.name
|
||||
]);
|
||||
const commands = computed(() => {
|
||||
const group = hotkey.getGroup(selectedGroup.value);
|
||||
return group ? group.commands : [];
|
||||
@@ -75,28 +113,18 @@ export const HotkeySettings = defineComponent<HotkeySettingsProps>(props => {
|
||||
combinationLists.value = lists;
|
||||
};
|
||||
|
||||
/** 计算一条指令占用的高度,同一指令的多个组合纵向堆叠 */
|
||||
const getCommandHeight = (command: IHotkeyCommand): number => {
|
||||
return Math.max(command.length, 1) * KEY_BOX_HEIGHT + COMMAND_GAP;
|
||||
};
|
||||
|
||||
/** 计算第 index 条指令的顶部纵坐标 */
|
||||
const getCommandTop = (index: number): number => {
|
||||
// 一次遍历预计算每条指令的顶部纵坐标,避免逐行求和
|
||||
const commandRows = computed<ICommandRow[]>(() => {
|
||||
const rows: ICommandRow[] = [];
|
||||
let top = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
top += getCommandHeight(commands.value[i]);
|
||||
}
|
||||
return top;
|
||||
};
|
||||
|
||||
/** 计算当前分组全部指令占用的总高度,作为滚动区域的内容高度 */
|
||||
const getContentHeight = (): number => {
|
||||
let height = 0;
|
||||
for (const command of commands.value) {
|
||||
height += getCommandHeight(command);
|
||||
const height =
|
||||
Math.max(command.length, 1) * KEY_BOX_HEIGHT + COMMAND_GAP;
|
||||
rows.push({ command, top });
|
||||
top += height;
|
||||
}
|
||||
return height;
|
||||
};
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** 移除文档上的按键监听 */
|
||||
const removeCapture = (): void => {
|
||||
@@ -128,6 +156,11 @@ export const HotkeySettings = defineComponent<HotkeySettingsProps>(props => {
|
||||
document.addEventListener('keyup', onKeyUp, true);
|
||||
};
|
||||
|
||||
/** 生成按键框的点击处理器,捕获该框对应的指令与组合下标 */
|
||||
const getKeyClick = (command: IHotkeyCommand, index: number) => () => {
|
||||
selectKey(command, index);
|
||||
};
|
||||
|
||||
/** 捕获阶段处理按下事件,忽略重复与无法映射的按键 */
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
event.preventDefault();
|
||||
@@ -173,164 +206,91 @@ export const HotkeySettings = defineComponent<HotkeySettingsProps>(props => {
|
||||
props.controller.close(props.instance);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container loc={FULL_LOC}>
|
||||
<text
|
||||
text="返回"
|
||||
loc={[LEFT_PAD, TOP_PAD]}
|
||||
cursor="pointer"
|
||||
onClick={close}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle="#ffffff"
|
||||
/>
|
||||
<Scroll loc={[LEFT_PAD, LIST_TOP, GROUP_WIDTH, LIST_HEIGHT]}>
|
||||
<container
|
||||
loc={[
|
||||
0,
|
||||
0,
|
||||
GROUP_WIDTH,
|
||||
Math.max(groups.length, 1) * GROUP_ROW_HEIGHT
|
||||
]}
|
||||
>
|
||||
{groups.map(([id, group], groupIndex) => {
|
||||
const isSelected = id === selectedGroup.value;
|
||||
return (
|
||||
<container
|
||||
key={id}
|
||||
loc={[
|
||||
0,
|
||||
groupIndex * GROUP_ROW_HEIGHT,
|
||||
GROUP_WIDTH,
|
||||
GROUP_ROW_HEIGHT
|
||||
]}
|
||||
/** 渲染右栏指令区,作为 ListPage 默认插槽的内容 */
|
||||
const renderCommands = () => (
|
||||
<Scroll loc={COMMAND_SCROLL_LOC}>
|
||||
{/* 每条指令名与它的每个按键框、组合文本,展开为同级子项 */}
|
||||
{commandRows.value.map((row, i) => {
|
||||
const list = combinationLists.value[i] ?? [];
|
||||
const nameLoc: ElementLocator = [
|
||||
COMMAND_TEXT_LEFT,
|
||||
row.top + COMMAND_TEXT_TOP
|
||||
];
|
||||
return [
|
||||
<text
|
||||
text={row.command.name}
|
||||
loc={nameLoc}
|
||||
anc={[0, 0]}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle="#ffffff"
|
||||
noevent
|
||||
/>,
|
||||
...list.map((keys, j) => {
|
||||
const boxTop = row.top + j * KEY_BOX_HEIGHT;
|
||||
const isSelected =
|
||||
selectedCommand.value === row.command &&
|
||||
selectedIndex.value === j;
|
||||
const showing =
|
||||
isSelected && heldKeys.value.length > 0
|
||||
? heldKeys.value
|
||||
: keys;
|
||||
const boxLoc: ElementLocator = [
|
||||
BOX_LEFT,
|
||||
boxTop,
|
||||
KEY_BOX_WIDTH,
|
||||
BOX_FILL_HEIGHT
|
||||
];
|
||||
const textLoc: ElementLocator = [
|
||||
BOX_LEFT + KEY_BOX_WIDTH / 2,
|
||||
boxTop + BOX_FILL_HEIGHT / 2
|
||||
];
|
||||
// 每个组合展开为按键框与文本两个同级子项
|
||||
return [
|
||||
// 选中按键框同样只切换这个既有矩形的描边
|
||||
<g-rectr
|
||||
loc={boxLoc}
|
||||
circle={[4]}
|
||||
fill
|
||||
stroke
|
||||
fillStyle="rgba(255, 255, 255, 0.08)"
|
||||
strokeStyle={
|
||||
isSelected ? 'gold' : 'transparent'
|
||||
}
|
||||
lineWidth={isSelected ? 2 : 1}
|
||||
cursor="pointer"
|
||||
onClick={() => selectGroup(id)}
|
||||
>
|
||||
{isSelected && (
|
||||
<Selection
|
||||
loc={[
|
||||
0,
|
||||
0,
|
||||
GROUP_WIDTH,
|
||||
GROUP_ROW_HEIGHT
|
||||
]}
|
||||
border="gold"
|
||||
noevent
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
text={group.name}
|
||||
loc={[8, GROUP_ROW_HEIGHT / 2]}
|
||||
anc={[0, 0.5]}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle={isSelected ? 'gold' : '#ffffff'}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</container>
|
||||
</Scroll>
|
||||
<Scroll loc={[RIGHT_LEFT, LIST_TOP, RIGHT_WIDTH, LIST_HEIGHT]}>
|
||||
<container loc={[0, 0, RIGHT_WIDTH, getContentHeight()]}>
|
||||
{commands.value.map((command, commandIndex) => {
|
||||
const list = combinationLists.value[commandIndex] ?? [];
|
||||
return (
|
||||
<container
|
||||
key={command.command}
|
||||
loc={[
|
||||
0,
|
||||
getCommandTop(commandIndex),
|
||||
RIGHT_WIDTH,
|
||||
getCommandHeight(command)
|
||||
]}
|
||||
>
|
||||
<text
|
||||
text={command.name}
|
||||
loc={[8, 12]}
|
||||
anc={[0, 0]}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle="#ffffff"
|
||||
/>
|
||||
<container
|
||||
loc={[
|
||||
RIGHT_WIDTH - KEY_BOX_WIDTH - LEFT_PAD,
|
||||
0,
|
||||
KEY_BOX_WIDTH,
|
||||
list.length * KEY_BOX_HEIGHT
|
||||
]}
|
||||
>
|
||||
{list.map((keys, index) => {
|
||||
const isSelected =
|
||||
selectedCommand.value === command &&
|
||||
selectedIndex.value === index;
|
||||
const showing =
|
||||
isSelected &&
|
||||
heldKeys.value.length > 0
|
||||
? heldKeys.value
|
||||
: keys;
|
||||
return (
|
||||
<container
|
||||
key={index}
|
||||
loc={[
|
||||
0,
|
||||
index * KEY_BOX_HEIGHT,
|
||||
KEY_BOX_WIDTH,
|
||||
KEY_BOX_HEIGHT
|
||||
]}
|
||||
cursor="pointer"
|
||||
onClick={() =>
|
||||
selectKey(command, index)
|
||||
}
|
||||
>
|
||||
<g-rectr
|
||||
loc={[
|
||||
0,
|
||||
0,
|
||||
KEY_BOX_WIDTH,
|
||||
KEY_BOX_HEIGHT - 4
|
||||
]}
|
||||
circle={[4]}
|
||||
fill
|
||||
fillStyle="rgba(255, 255, 255, 0.08)"
|
||||
/>
|
||||
{isSelected && (
|
||||
<Selection
|
||||
loc={[
|
||||
0,
|
||||
0,
|
||||
KEY_BOX_WIDTH,
|
||||
KEY_BOX_HEIGHT - 4
|
||||
]}
|
||||
border="gold"
|
||||
noevent
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
text={formatCombination(
|
||||
showing
|
||||
)}
|
||||
loc={[
|
||||
KEY_BOX_WIDTH / 2,
|
||||
(KEY_BOX_HEIGHT - 4) / 2
|
||||
]}
|
||||
anc={[0.5, 0.5]}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle={
|
||||
isSelected
|
||||
? 'gold'
|
||||
: '#ffffff'
|
||||
}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</container>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</container>
|
||||
</Scroll>
|
||||
</container>
|
||||
onClick={getKeyClick(row.command, j)}
|
||||
/>,
|
||||
<text
|
||||
text={formatCombination(showing)}
|
||||
loc={textLoc}
|
||||
anc={[0.5, 0.5]}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle={isSelected ? 'gold' : '#ffffff'}
|
||||
noevent
|
||||
/>
|
||||
];
|
||||
})
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
);
|
||||
|
||||
return () => (
|
||||
<ListPage
|
||||
loc={FULL_LOC}
|
||||
basis={GROUP_BASIS}
|
||||
list={groupItems}
|
||||
selected={selectedGroup.value}
|
||||
lineHeight={GROUP_ROW_HEIGHT}
|
||||
font={CONTENT_FONT}
|
||||
close
|
||||
closeLoc={CLOSE_LOC}
|
||||
onUpdate={selectGroup}
|
||||
onClose={close}
|
||||
nocache
|
||||
>
|
||||
{{ default: () => renderCommands() }}
|
||||
</ListPage>
|
||||
);
|
||||
}, hotkeySettingsProps);
|
||||
|
||||
|
||||
@@ -12,5 +12,3 @@ export * from './title';
|
||||
export * from './toolbar';
|
||||
export * from './toolbox';
|
||||
export * from './viewmap';
|
||||
|
||||
export * from './constant';
|
||||
@@ -7,7 +7,8 @@ import { cosh, CurveMode, linear } from '@motajs/animate';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { sleep } from '@motajs/common';
|
||||
import { GameTitleUI } from './title';
|
||||
import { IUIPropsBase, transitioned } from '@user/client-modules';
|
||||
import { transitioned } from '@user/client-modules';
|
||||
import { IGameUIPropsBase } from '@user/client-base';
|
||||
|
||||
/** 加载界面的任务进度条半径 */
|
||||
export const LOAD_TASK_RADIUS = Math.min(MAIN_WIDTH, MAIN_HEIGHT) / 6;
|
||||
@@ -28,7 +29,7 @@ export const LOAD_UNLOADED_COLOR = '#ccc';
|
||||
/** 加载界面的文字颜色 */
|
||||
export const LOAD_FONT_COLOR = '#fff';
|
||||
|
||||
export interface ILoadProps extends IUIPropsBase, DefaultProps {}
|
||||
export interface ILoadProps extends IGameUIPropsBase, DefaultProps {}
|
||||
|
||||
const loadSceneProps = {
|
||||
props: ['controller', 'instance', 'state']
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Font, MotaOffscreenCanvas2D, CustomRenderItem } from '@motajs/render';
|
||||
// import { WeatherController } from '../weather';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Textbox, TextboxProps, Tip } from '@user/client-base';
|
||||
import {
|
||||
IGameUIPropsBase,
|
||||
Textbox,
|
||||
TextboxProps,
|
||||
Tip
|
||||
} from '@user/client-base';
|
||||
import { GameUI, SetupComponentOptions } from '@motajs/system';
|
||||
import {
|
||||
ENABLE_RIGHT_STATUS_BAR,
|
||||
@@ -15,9 +20,10 @@ import {
|
||||
} from '@/client';
|
||||
import { LeftStatusBar, RightStatusBar } from './statusBar';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { IUIPropsBase, using } from '@user/client-modules';
|
||||
import { using } from '@user/client-modules';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
|
||||
interface MainSceneProps extends IUIPropsBase {}
|
||||
interface MainSceneProps extends IGameUIPropsBase, DefaultProps {}
|
||||
|
||||
const mainSceneProps = {
|
||||
props: ['controller', 'instance', 'state']
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@user/client-base';
|
||||
import { HALF_HEIGHT, HALF_WIDTH, MAP_WIDTH, POP_BOX_WIDTH } from '@/client';
|
||||
import { adjustGrid, IGridLayoutData } from '@user/client-base';
|
||||
import { getSave, IUIPropsBase, SaveData } from '@user/client-modules';
|
||||
import { getSave, SaveData } from '@user/client-modules';
|
||||
|
||||
/** 存档缩略图尺寸 */
|
||||
export const SAVE_ITEM_SIZE = MAP_BLOCK_WIDTH * 10;
|
||||
@@ -43,7 +43,7 @@ const enum SaveMode {
|
||||
Other
|
||||
}
|
||||
|
||||
export interface SaveProps extends IUIPropsBase, DefaultProps {
|
||||
export interface SaveProps extends IGameUIPropsBase, DefaultProps {
|
||||
loc: ElementLocator;
|
||||
mode: SaveMode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { GameUI, SetupComponentOptions } from '@motajs/system';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import {
|
||||
IGameUIPropsBase,
|
||||
ISettingItem,
|
||||
ISettingSystem,
|
||||
ListPage,
|
||||
Scroll,
|
||||
TextContent,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client/uiConstant';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
|
||||
/** 界面左侧与顶部的内边距 */
|
||||
const LEFT_PAD = 16;
|
||||
const TOP_PAD = 16;
|
||||
/** 左侧分组列表的宽度 */
|
||||
const GROUP_WIDTH = 180;
|
||||
/** 左侧分组列表占整个界面的宽度比例,交给 ListPage 决定列表区宽度 */
|
||||
const GROUP_BASIS = GROUP_WIDTH / MAIN_WIDTH;
|
||||
/** ListPage 内容区的宽度,即界面总宽减去左栏所占的比例 */
|
||||
const CONTENT_WIDTH = MAIN_WIDTH * (1 - GROUP_BASIS);
|
||||
/** 左侧列表区域的起始纵坐标 */
|
||||
const LIST_TOP = TOP_PAD + 32;
|
||||
/** 左右两栏列表区域的高度,底部留出关闭文本的空间 */
|
||||
const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 48;
|
||||
/** 左侧每一行分组的高度 */
|
||||
const GROUP_ROW_HEIGHT = 24;
|
||||
/** 单个设置行的高度 */
|
||||
const ROW_HEIGHT = 64;
|
||||
/** 设置行之间的纵向间隔 */
|
||||
const ROW_GAP = 12;
|
||||
/** 设置行的宽度 */
|
||||
const ROW_WIDTH = CONTENT_WIDTH - LEFT_PAD * 3;
|
||||
/** 设置行内文本的宽度,右侧留出固定控件区 */
|
||||
const ROW_TEXT_WIDTH = ROW_WIDTH - LEFT_PAD * 2 - 180;
|
||||
/** 设置行内描述文本的纵坐标 */
|
||||
const DESCRIPTION_TOP = 20;
|
||||
/** 设置行内预览文本的纵坐标 */
|
||||
const PREVIEW_TOP = 44;
|
||||
/** 设置行内控件区的纵坐标 */
|
||||
const CONTROL_TOP = 20;
|
||||
/** 右栏内容区的定位,叠加内容区原点后与原有绝对位置一致 */
|
||||
const CONTENT_SCROLL_LOC: ElementLocator = [
|
||||
LEFT_PAD * 2,
|
||||
LIST_TOP,
|
||||
CONTENT_WIDTH - LEFT_PAD * 3,
|
||||
LIST_HEIGHT
|
||||
];
|
||||
/** 关闭按钮的定位,放在界面左下角,文本由 ListPage 内置渲染 */
|
||||
const CLOSE_LOC: ElementLocator = [
|
||||
LEFT_PAD,
|
||||
MAIN_HEIGHT - LEFT_PAD,
|
||||
void 0,
|
||||
void 0,
|
||||
0,
|
||||
1
|
||||
];
|
||||
/** 设置行右侧固定控件区的定位,与控件自身的 180×24 区域一致 */
|
||||
const CONTROL_AREA_LOC: ElementLocator = [
|
||||
ROW_WIDTH - LEFT_PAD - 180,
|
||||
CONTROL_TOP,
|
||||
180,
|
||||
24
|
||||
];
|
||||
/** 界面内所有文本共用的字体 */
|
||||
const CONTENT_FONT = Font.defaults({ size: 13 });
|
||||
|
||||
export interface SettingPageProps extends IGameUIPropsBase, DefaultProps {
|
||||
// 当前界面显示的设置系统
|
||||
readonly system: ISettingSystem;
|
||||
}
|
||||
|
||||
/** 设置界面组件的 props 声明 */
|
||||
const settingPageProps = {
|
||||
props: ['controller', 'instance', 'state', 'system']
|
||||
} satisfies SetupComponentOptions<SettingPageProps>;
|
||||
|
||||
/** 通用设置界面,左栏切换分组,右栏平铺当前分组的设置行 */
|
||||
export const SettingPage = defineComponent<SettingPageProps>(props => {
|
||||
// 分组列表的显示项,第一项为分组 id,第二项为分组名称
|
||||
const groupItems: [string, string][] = Array.from(
|
||||
props.system.iterateGroups()
|
||||
).map(([id, group]) => [id, group.name]);
|
||||
// 当前选中的分组 id,默认取第一个分组
|
||||
const selectedGroup = ref<string>(
|
||||
groupItems.length > 0 ? groupItems[0][0] : ''
|
||||
);
|
||||
|
||||
// 切换当前选中的分组
|
||||
const selectGroup = (id: string): void => {
|
||||
selectedGroup.value = id;
|
||||
};
|
||||
|
||||
// 关闭界面,关闭文本由 ListPage 内置渲染
|
||||
const exit = (): void => {
|
||||
props.controller.close(props.instance);
|
||||
};
|
||||
|
||||
// 渲染单个设置行,包含名称、描述、预览与控件
|
||||
const renderRow = (item: ISettingItem, index: number) => {
|
||||
const rowTop = index * (ROW_HEIGHT + ROW_GAP);
|
||||
const rowLoc: ElementLocator = [0, rowTop, ROW_WIDTH, ROW_HEIGHT];
|
||||
const nameLoc: ElementLocator = [LEFT_PAD, 0];
|
||||
const descriptionLoc: ElementLocator = [LEFT_PAD, DESCRIPTION_TOP];
|
||||
const previewLoc: ElementLocator = [LEFT_PAD, PREVIEW_TOP];
|
||||
const Control = item.getSettingComponent();
|
||||
return (
|
||||
<container loc={rowLoc}>
|
||||
<text
|
||||
text={item.name}
|
||||
loc={nameLoc}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle="#ffffff"
|
||||
noevent
|
||||
/>
|
||||
<TextContent
|
||||
text={item.description}
|
||||
loc={descriptionLoc}
|
||||
width={ROW_TEXT_WIDTH}
|
||||
autoHeight
|
||||
font={CONTENT_FONT}
|
||||
noevent
|
||||
/>
|
||||
<text
|
||||
text={item.getPreviewString()}
|
||||
loc={previewLoc}
|
||||
font={CONTENT_FONT}
|
||||
fillStyle="gold"
|
||||
noevent
|
||||
/>
|
||||
{/* 行内右侧的固定控件区,控件只通过设置项与控制器交互 */}
|
||||
<container loc={CONTROL_AREA_LOC}>
|
||||
<Control setting={item} controller={props.controller} />
|
||||
</container>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染指定分组的设置行,作为 ListPage 默认插槽的内容
|
||||
const renderGroup = (key: string) => {
|
||||
const group = props.system.getGroup(key);
|
||||
if (!group) return [];
|
||||
return (
|
||||
<Scroll loc={CONTENT_SCROLL_LOC}>
|
||||
{group.items.map(renderRow)}
|
||||
</Scroll>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染整个页面,空系统时只渲染一个空滚动区占位
|
||||
const renderPage = () => {
|
||||
if (groupItems.length === 0) {
|
||||
return <Scroll loc={CONTENT_SCROLL_LOC} />;
|
||||
}
|
||||
return (
|
||||
<ListPage
|
||||
loc={FULL_LOC}
|
||||
basis={GROUP_BASIS}
|
||||
list={groupItems}
|
||||
selected={selectedGroup.value}
|
||||
lineHeight={GROUP_ROW_HEIGHT}
|
||||
font={CONTENT_FONT}
|
||||
close
|
||||
closeLoc={CLOSE_LOC}
|
||||
onUpdate={selectGroup}
|
||||
onClose={exit}
|
||||
>
|
||||
{{ default: renderGroup }}
|
||||
</ListPage>
|
||||
);
|
||||
};
|
||||
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@common/exit', exit);
|
||||
|
||||
return () => renderPage();
|
||||
}, settingPageProps);
|
||||
|
||||
/** @see {@link SettingPage} */
|
||||
export const SettingPageUI = new GameUI('setting-page', SettingPage);
|
||||
@@ -5,15 +5,11 @@ import {
|
||||
IUIDefaultPropsBase
|
||||
} from '@motajs/system';
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
waitbox,
|
||||
ListPage,
|
||||
TextContent,
|
||||
IGameUIPropsBase
|
||||
} from '@user/client-base';
|
||||
import { ListPage, TextContent, IGameUIPropsBase } from '@user/client-base';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
// @ts-expect-error 需要重构
|
||||
import { ItemState } from '@user/data-state';
|
||||
import { waitbox } from './waitBox';
|
||||
|
||||
// 未完工
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
STATUS_BAR_HEIGHT,
|
||||
STATUS_BAR_WIDTH
|
||||
} from '@/client';
|
||||
import { IUIPropsBase } from '@user/client-modules';
|
||||
import { IGameUIPropsBase } from '@user/client-base';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
|
||||
export interface ILeftHeroStatus {
|
||||
@@ -99,7 +99,7 @@ interface KeyLikeInfo {
|
||||
readonly items: KeyLikeItem[];
|
||||
}
|
||||
|
||||
interface StatusBarProps extends IUIPropsBase, DefaultProps {
|
||||
interface StatusBarProps extends IGameUIPropsBase, DefaultProps {
|
||||
loc: ElementLocator;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ElementLocator, Font } from '@motajs/render';
|
||||
import {
|
||||
ExitFullscreen,
|
||||
Fullscreen,
|
||||
IGameUIPropsBase,
|
||||
SoundVolume,
|
||||
triggerFullscreen,
|
||||
useKey
|
||||
@@ -17,7 +18,6 @@ import { cosh, CurveMode, linear } from '@motajs/animate';
|
||||
import { sleep } from '@motajs/common';
|
||||
import {
|
||||
ITransitionedController,
|
||||
IUIPropsBase,
|
||||
transitioned,
|
||||
transitionedColor
|
||||
} from '@user/client-modules';
|
||||
@@ -65,7 +65,7 @@ interface ButtonOption {
|
||||
colorTrans: ITransitionedController<string>;
|
||||
}
|
||||
|
||||
export interface GameTitleProps extends DefaultProps, IUIPropsBase {}
|
||||
export interface GameTitleProps extends DefaultProps, IGameUIPropsBase {}
|
||||
|
||||
const gameTitleProps = {
|
||||
props: ['controller', 'instance', 'state']
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
StepForward,
|
||||
ViewMapIcon
|
||||
} from '@user/client-base';
|
||||
import { linear } from 'mutate-animate';
|
||||
import { KeyCode } from '@motajs/system';
|
||||
import { linear } from '@motajs/animate';
|
||||
import { Progress } from '@user/client-base';
|
||||
import { generateBinary } from '@motajs/legacy-common';
|
||||
import { SetupComponentOptions } from '@motajs/system';
|
||||
import { saveSave, saveLoad } from './save';
|
||||
import { MAIN_HEIGHT, FULL_LOC, POP_BOX_WIDTH, CENTER_LOC } from '@/client';
|
||||
|
||||
+174
-34
@@ -1,20 +1,34 @@
|
||||
import { GameUI, SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { computed, defineComponent, nextTick, onUnmounted, ref } from 'vue';
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import { Scroll, TextContent } from '@user/client-base';
|
||||
import {
|
||||
IGameUIPropsBase,
|
||||
Scroll,
|
||||
TextContent,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { IHeroItemState, IHeroState } from '@user/data-base';
|
||||
import { IHeroAttr } from '@user/data-common';
|
||||
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client';
|
||||
import { IUIPropsBase } from '@user/client-modules';
|
||||
import { EquipBoxUI } from './equipbox';
|
||||
|
||||
export interface ToolBoxProps extends IUIPropsBase {
|
||||
interface IHeroAttrItem {
|
||||
/** 勇士属性的键名 */
|
||||
readonly id: keyof IHeroAttr;
|
||||
/** 该属性在状态区域显示的中文名 */
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface ToolBoxProps extends IGameUIPropsBase {
|
||||
/** 勇士状态对象 */
|
||||
heroState: IHeroState<IHeroAttr>;
|
||||
/** 跳到装备栏时传给装备界面的属性列表,默认为空 */
|
||||
attributes?: readonly IHeroAttrItem[];
|
||||
}
|
||||
|
||||
/** 道具栏组件的 props 声明 */
|
||||
const toolBoxProps = {
|
||||
props: ['controller', 'instance', 'state', 'heroState']
|
||||
props: ['controller', 'instance', 'state', 'heroState', 'attributes']
|
||||
} satisfies SetupComponentOptions<ToolBoxProps>;
|
||||
|
||||
/** 单列宽度,画面等分为永久道具、可消耗道具、道具信息三列 */
|
||||
@@ -32,9 +46,11 @@ const ROW_ICON_SIZE = 32;
|
||||
/** 道具信息列起始横坐标 */
|
||||
const INFO_LEFT = COLUMN_WIDTH * 2;
|
||||
/** 道具说明区域起始纵坐标 */
|
||||
const DESC_TOP = 40;
|
||||
const DESC_TOP = 88;
|
||||
/** 道具说明区域高度,底部为退出按钮留出空间 */
|
||||
const DESC_HEIGHT = MAIN_HEIGHT - DESC_TOP - 36;
|
||||
/** 详情区图标尺寸 */
|
||||
const DETAIL_ICON_SIZE = 32;
|
||||
|
||||
/** 列表标题字体 */
|
||||
const titleFont = Font.defaults({ size: 14, weight: 700 });
|
||||
@@ -48,8 +64,14 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
/** 勇士状态对象 */
|
||||
const hero = props.heroState;
|
||||
|
||||
/** 当前选中的道具图块数字,-1 表示未选中 */
|
||||
const selected = ref(-1);
|
||||
/** 当前所在的道具列 */
|
||||
const mode = ref<'permanent' | 'consumable'>('consumable');
|
||||
/** 当前列中选中的行号 */
|
||||
const index = ref(0);
|
||||
/** 使用失败提示是否正在显示 */
|
||||
const failTip = ref(false);
|
||||
/** 使用失败提示的清除定时器 */
|
||||
let failTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* 枚举背包内该消耗类型的道具,过滤掉在道具栏隐藏的道具,
|
||||
@@ -72,43 +94,122 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
const permItems = computed(() => collect(false));
|
||||
/** 可消耗道具列表 */
|
||||
const consItems = computed(() => collect(true));
|
||||
/** 跳到装备栏时传递的属性列表 */
|
||||
const attributes = props.attributes ?? [];
|
||||
|
||||
/** 当前选中的道具状态,未选中时为 null */
|
||||
/** 当前列选中的道具状态,未选中时为 null */
|
||||
const selectedItem = computed<IHeroItemState<IHeroAttr> | null>(() => {
|
||||
if (selected.value < 0) return null;
|
||||
for (const item of permItems.value) {
|
||||
if (item.num === selected.value) return item;
|
||||
}
|
||||
for (const item of consItems.value) {
|
||||
if (item.num === selected.value) return item;
|
||||
}
|
||||
return null;
|
||||
const list =
|
||||
mode.value === 'permanent' ? permItems.value : consItems.value;
|
||||
return list[index.value] ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 点击道具行:未选中时仅选中,再次点击已选中的道具时使用该道具
|
||||
* @param item 被点击的道具状态
|
||||
*/
|
||||
const clickItem = (item: IHeroItemState<IHeroAttr>) => {
|
||||
if (selected.value === item.num) {
|
||||
hero.items.useItem(item.num);
|
||||
/** 显示使用失败提示,并在约 2 秒后自动清除 */
|
||||
const showFailure = () => {
|
||||
failTip.value = true;
|
||||
if (failTimer) clearTimeout(failTimer);
|
||||
failTimer = setTimeout(() => {
|
||||
failTip.value = false;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
/** 使用当前选中的道具 */
|
||||
const useSelected = () => {
|
||||
const item = selectedItem.value;
|
||||
if (!item) return;
|
||||
const canUse = item.raw.effect.canUse?.(item.raw, props.state) ?? true;
|
||||
if (!canUse) {
|
||||
showFailure();
|
||||
return;
|
||||
}
|
||||
selected.value = item.num;
|
||||
const hold = props.controller.keep();
|
||||
props.controller.close(props.instance);
|
||||
nextTick(() => {
|
||||
hero.items.useItem(item.num);
|
||||
hold.safelyUnload();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成道具行的点击处理器,再次点击当前项时使用道具
|
||||
* @param item 被点击的道具状态
|
||||
* @param column 被点击的道具列
|
||||
*/
|
||||
const getItemClick =
|
||||
(item: IHeroItemState<IHeroAttr>, column: 'permanent' | 'consumable') =>
|
||||
() => {
|
||||
const list =
|
||||
column === 'permanent' ? permItems.value : consItems.value;
|
||||
const row = list.indexOf(item);
|
||||
if (mode.value === column && index.value === row) {
|
||||
useSelected();
|
||||
return;
|
||||
}
|
||||
mode.value = column;
|
||||
index.value = row < 0 ? 0 : row;
|
||||
};
|
||||
|
||||
/** 切换到目标道具列,并把行号夹到该列范围内 */
|
||||
const switchColumn = (column: 'permanent' | 'consumable') => {
|
||||
if (mode.value === column) return;
|
||||
mode.value = column;
|
||||
const list = column === 'permanent' ? permItems.value : consItems.value;
|
||||
const max = list.length - 1;
|
||||
if (index.value > max) index.value = max < 0 ? 0 : max;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按给定步长移动选中行,并限制在当前列范围内
|
||||
* @param delta 移动的步长
|
||||
*/
|
||||
const moveRow = (delta: number) => {
|
||||
const list =
|
||||
mode.value === 'permanent' ? permItems.value : consItems.value;
|
||||
const max = list.length - 1;
|
||||
if (max < 0) return;
|
||||
index.value = Math.min(Math.max(index.value + delta, 0), max);
|
||||
};
|
||||
|
||||
/** 打开装备栏界面 */
|
||||
const toEquip = () => {
|
||||
props.controller.keep();
|
||||
props.controller.close(props.instance);
|
||||
props.controller.open(EquipBoxUI, {
|
||||
heroState: props.heroState,
|
||||
attributes
|
||||
});
|
||||
};
|
||||
|
||||
/** 关闭当前道具栏界面 */
|
||||
const exit = () => props.controller.close(props.instance);
|
||||
|
||||
/** 生成道具行的点击处理器,点击后按再点规则选中或使用该道具 */
|
||||
const getItemClick = (item: IHeroItemState<IHeroAttr>) => () => {
|
||||
clickItem(item);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (failTimer) clearTimeout(failTimer);
|
||||
});
|
||||
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@common/select-left', () => switchColumn('permanent'))
|
||||
.implement('@common/select-right', () => switchColumn('consumable'))
|
||||
.implement('@common/select-up', () => moveRow(-1))
|
||||
.implement('@common/select-down', () => moveRow(1))
|
||||
.implement('@common/confirm', useSelected)
|
||||
.implement('@common/exit', exit)
|
||||
.implement('@ui/toolbox', exit);
|
||||
|
||||
return () => {
|
||||
const current = selectedItem.value;
|
||||
return (
|
||||
<container loc={FULL_LOC}>
|
||||
{/* 右上角跳到装备栏 */}
|
||||
<text
|
||||
text="装备栏 →"
|
||||
loc={[MAIN_WIDTH - 8, HEADER_HEIGHT / 2]}
|
||||
anc={[1, 0.5]}
|
||||
font={titleFont}
|
||||
cursor="pointer"
|
||||
fillStyle="gold"
|
||||
onClick={toEquip}
|
||||
/>
|
||||
{/* 永久道具列 */}
|
||||
<text
|
||||
text="永久道具"
|
||||
@@ -129,7 +230,8 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
{/* 每行行底、图标、名称与数量,直接作为 Scroll 子项 */}
|
||||
{permItems.value.map((item, i) => {
|
||||
const y = i * ROW_HEIGHT;
|
||||
const isSelected = item.num === selected.value;
|
||||
const isSelected =
|
||||
mode.value === 'permanent' && i === index.value;
|
||||
const cellLoc: ElementLocator = [
|
||||
4,
|
||||
y + 2,
|
||||
@@ -147,7 +249,7 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
cursor="pointer"
|
||||
onClick={getItemClick(item)}
|
||||
onClick={getItemClick(item, 'permanent')}
|
||||
/>,
|
||||
<icon
|
||||
icon={item.num}
|
||||
@@ -180,7 +282,8 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
{/* 每行行底、图标、名称与数量,直接作为 Scroll 子项 */}
|
||||
{consItems.value.map((item, i) => {
|
||||
const y = i * ROW_HEIGHT;
|
||||
const isSelected = item.num === selected.value;
|
||||
const isSelected =
|
||||
mode.value === 'consumable' && i === index.value;
|
||||
const cellLoc: ElementLocator = [
|
||||
4,
|
||||
y + 2,
|
||||
@@ -198,7 +301,7 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
cursor="pointer"
|
||||
onClick={getItemClick(item)}
|
||||
onClick={getItemClick(item, 'consumable')}
|
||||
/>,
|
||||
<icon
|
||||
icon={item.num}
|
||||
@@ -224,15 +327,52 @@ export const ToolBox = defineComponent<ToolBoxProps>(props => {
|
||||
];
|
||||
})}
|
||||
</Scroll>
|
||||
{/* 详情图标、名称与类型 */}
|
||||
<icon
|
||||
icon={current ? current.num : 0}
|
||||
loc={[INFO_LEFT + 8, 8, DETAIL_ICON_SIZE, DETAIL_ICON_SIZE]}
|
||||
noevent
|
||||
hidden={!current}
|
||||
/>
|
||||
{/* 道具名称 */}
|
||||
<text
|
||||
text={current ? current.raw.name : '未选择道具'}
|
||||
loc={[INFO_LEFT + 8, 14]}
|
||||
loc={[INFO_LEFT + 48, 12]}
|
||||
anc={[0, 0]}
|
||||
font={titleFont}
|
||||
fillStyle="gold"
|
||||
/>
|
||||
<text
|
||||
text={
|
||||
current
|
||||
? current.consumable
|
||||
? '消耗道具'
|
||||
: '永久道具'
|
||||
: ''
|
||||
}
|
||||
loc={[INFO_LEFT + 48, 34]}
|
||||
anc={[0, 0]}
|
||||
font={descFont}
|
||||
fillStyle="#bbbbbb"
|
||||
/>
|
||||
<text
|
||||
text="点击该物品以使用"
|
||||
loc={[INFO_LEFT + 8, 58]}
|
||||
anc={[0, 0]}
|
||||
font={descFont}
|
||||
fillStyle="#dddddd"
|
||||
hidden={!current}
|
||||
/>
|
||||
<text
|
||||
text="当前无法使用该道具!"
|
||||
loc={[INFO_LEFT + 8, 74]}
|
||||
anc={[0, 0]}
|
||||
font={descFont}
|
||||
fillStyle="lightcoral"
|
||||
hidden={!failTip.value}
|
||||
/>
|
||||
{/* 道具说明 */}
|
||||
{/* 未选择道具时的说明占位 */}
|
||||
<text
|
||||
text="未选择道具"
|
||||
loc={[INFO_LEFT + 8, DESC_TOP + 4]}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#game-draw {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -5,3 +14,12 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.motajs-input-element {
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
border: none;
|
||||
z-index: 1000;
|
||||
background-color: transparent;
|
||||
}
|
||||
Reference in new issue
Block a user