feat(09-08): toolbox 道具栏三列布局与道具列表

- 新增 toolbox.tsx:永久道具 / 可消耗道具 / 道具信息三列布局
- 以 heroState.items.iterateItems() 枚举背包,过滤 hideInToolbox
- 各组按数量降序、数量相同按图块数字升序
- 行渲染 <icon> + raw.name + core.formatBigNumber(count)
- 右列展示 raw.name 与 raw.text;列表 / 说明纵向 <Scroll noscroll>
- 右下退出按钮(本任务仅视觉占位)
This commit is contained in:
unanmed 2026-10-11 14:42:12 +08:00
parent e575944df5
commit f039ebc542

View File

@ -0,0 +1,261 @@
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { computed, defineComponent, ref } from 'vue';
import { Font } from '@motajs/render';
import { Scroll } 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 '../shared';
import { IUIPropsBase } from './types';
export interface ToolBoxProps extends IUIPropsBase {
/** 勇士状态对象 */
heroState: IHeroState<IHeroAttr>;
}
const toolBoxProps = {
props: ['controller', 'instance', 'state', 'heroState']
} satisfies SetupComponentOptions<ToolBoxProps>;
/** 单列宽度,画面等分为永久道具、可消耗道具、道具信息三列 */
const COLUMN_WIDTH = MAIN_WIDTH / 3;
/** 列表标题占用的高度 */
const HEADER_HEIGHT = 28;
/** 列表区域起始纵坐标 */
const LIST_TOP = HEADER_HEIGHT + 8;
/** 列表区域高度,底部为退出按钮留出空间 */
const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 36;
/** 每行道具的高度 */
const ROW_HEIGHT = 46;
/** 行内图标的尺寸 */
const ROW_ICON_SIZE = 32;
/** 道具信息列起始横坐标 */
const INFO_LEFT = COLUMN_WIDTH * 2;
/** 道具说明区域起始纵坐标 */
const DESC_TOP = 40;
/** 道具说明区域高度,底部为退出按钮留出空间 */
const DESC_HEIGHT = MAIN_HEIGHT - DESC_TOP - 36;
/** 列表标题字体 */
const titleFont = Font.defaults({ size: 14, weight: 700 });
/** 道具名称与数量字体 */
const itemFont = Font.defaults({ size: 13 });
/** 道具说明字体 */
const descFont = Font.defaults({ size: 12 });
export const ToolBox = defineComponent<ToolBoxProps>(props => {
const hero = props.heroState;
/** 当前选中的道具图块数字,-1 表示未选中 */
const selected = ref(-1);
/**
* 枚举背包内该消耗类型的道具,过滤掉在道具栏隐藏的道具,
* 并按数量从多到少排序,数量相同时按图块数字升序
* @param consumable 保留的道具是否为可消耗道具
*/
const collect = (consumable: boolean) => {
const list: IHeroItemState<IHeroAttr>[] = [];
for (const pair of hero.items.iterateItems()) {
const item = pair[1];
if (item.raw.hideInToolbox === true) continue;
if (item.consumable === consumable) list.push(item);
}
return list.sort((a, b) =>
a.count !== b.count ? b.count - a.count : a.num - b.num
);
};
/** 永久道具列表 */
const permItems = computed(() => collect(false));
/** 可消耗道具列表 */
const consItems = computed(() => collect(true));
/** 当前选中的道具状态,未选中时为 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;
});
/**
* 点击道具行,将点击的道具设为当前选中项
* @param item 被点击的道具状态
*/
const clickItem = (item: IHeroItemState<IHeroAttr>) => {
selected.value = item.num;
};
return () => {
const current = selectedItem.value;
return (
<container loc={FULL_LOC}>
<text
text="永久道具"
loc={[8, HEADER_HEIGHT / 2]}
anc={[0, 0.5]}
font={titleFont}
fillStyle="gold"
/>
<text
text="可消耗道具"
loc={[COLUMN_WIDTH + 8, HEADER_HEIGHT / 2]}
anc={[0, 0.5]}
font={titleFont}
fillStyle="gold"
/>
<Scroll noscroll loc={[0, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]}>
{permItems.value.map((item, i) => {
const isSelected = item.num === selected.value;
return (
<container
key={item.num}
loc={[
0,
i * ROW_HEIGHT,
COLUMN_WIDTH,
ROW_HEIGHT
]}
cursor="pointer"
onClick={() => clickItem(item)}
>
<g-rectr
loc={[
4,
2,
COLUMN_WIDTH - 8,
ROW_HEIGHT - 4
]}
circle={[4]}
fill
fillStyle={
isSelected
? 'rgba(255, 204, 0, 0.28)'
: 'rgba(255, 255, 255, 0.05)'
}
/>
<icon
icon={item.num}
loc={[10, 7, ROW_ICON_SIZE, ROW_ICON_SIZE]}
/>
<text
text={item.raw.name}
loc={[50, ROW_HEIGHT / 2]}
anc={[0, 0.5]}
font={itemFont}
fillStyle="#eeeeee"
/>
<text
text={core.formatBigNumber(item.count)}
loc={[COLUMN_WIDTH - 8, ROW_HEIGHT / 2]}
anc={[1, 0.5]}
font={itemFont}
fillStyle="#cccccc"
/>
</container>
);
})}
</Scroll>
<Scroll
noscroll
loc={[COLUMN_WIDTH, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]}
>
{consItems.value.map((item, i) => {
const isSelected = item.num === selected.value;
return (
<container
key={item.num}
loc={[
0,
i * ROW_HEIGHT,
COLUMN_WIDTH,
ROW_HEIGHT
]}
cursor="pointer"
onClick={() => clickItem(item)}
>
<g-rectr
loc={[
4,
2,
COLUMN_WIDTH - 8,
ROW_HEIGHT - 4
]}
circle={[4]}
fill
fillStyle={
isSelected
? 'rgba(255, 204, 0, 0.28)'
: 'rgba(255, 255, 255, 0.05)'
}
/>
<icon
icon={item.num}
loc={[10, 7, ROW_ICON_SIZE, ROW_ICON_SIZE]}
/>
<text
text={item.raw.name}
loc={[50, ROW_HEIGHT / 2]}
anc={[0, 0.5]}
font={itemFont}
fillStyle="#eeeeee"
/>
<text
text={core.formatBigNumber(item.count)}
loc={[COLUMN_WIDTH - 8, ROW_HEIGHT / 2]}
anc={[1, 0.5]}
font={itemFont}
fillStyle="#cccccc"
/>
</container>
);
})}
</Scroll>
<text
text={current === null ? '未选择道具' : current.raw.name}
loc={[INFO_LEFT + 8, 14]}
anc={[0, 0]}
font={titleFont}
fillStyle="gold"
/>
<Scroll
noscroll
loc={[INFO_LEFT, DESC_TOP, COLUMN_WIDTH, DESC_HEIGHT]}
>
{current === null && (
<text
text="未选择道具"
loc={[8, 4]}
anc={[0, 0]}
font={descFont}
fillStyle="#888888"
/>
)}
{current !== null && (
<text
text={current.raw.text}
loc={[8, 4]}
anc={[0, 0]}
font={descFont}
fillStyle="#dddddd"
/>
)}
</Scroll>
<text
text="退出"
loc={[MAIN_WIDTH - 12, MAIN_HEIGHT - 10]}
anc={[1, 1]}
font={titleFont}
cursor="pointer"
fillStyle="#ffffff"
/>
</container>
);
};
}, toolBoxProps);
export const ToolBoxUI = new GameUI('toolbox', ToolBox);