feat(09-06): add enemy handbook list UI (book.tsx)

- Book / BookUI bound to props.state.enemyContext
- dedupe enemies by base.code, vertical Scroll list
- 9 columns: icon/name/specials/hp/atk/def/money/exp + damage/critical/reduce/1-def
- critical via calculateEnemyCritical (base path); 1-def via with(isolated +1 def)
- click row opens BookDetailUI; bottom-right exit closes instance
This commit is contained in:
unanmed 2026-10-11 13:44:43 +08:00
parent 04fc4ff81e
commit 0f0b2770fe

View File

@ -0,0 +1,235 @@
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { computed, defineComponent } 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 { IReadonlyEnemy } from '@user/data-base';
import { IEnemyAttr } from '@user/data-common';
import { BookDetailUI } from './bookDetail';
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '../shared';
import { IUIPropsBase } from './types';
export interface BookProps extends IUIPropsBase {}
const bookProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<BookProps>;
/** 列表中每一行的高度 */
const ROW_HEIGHT = 72;
/** 列表区域的起始纵坐标 */
const LIST_TOP = 44;
/** 列表区域的横坐标 */
const LIST_LEFT = 16;
/** 列表区域的宽度 */
const LIST_WIDTH = MAIN_WIDTH - 32;
/** 列表区域的高度,底部留出退出按钮的空间 */
const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 36;
/** 数值列区域的起始横坐标 */
const STAT_LEFT = 240;
/** 怪物名称字体 */
const nameFont = Font.defaults({ size: 15, weight: 700 });
/** 特殊名与属性字体 */
const statFont = Font.defaults({ size: 12 });
export const Book = defineComponent<BookProps>(props => {
const ctx = 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]);
}
return Array.from(map.values());
});
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(' ');
};
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 exit = () => props.controller.close(props.instance);
return () => (
<container loc={FULL_LOC}>
<g-rect fill fillStyle="black" loc={FULL_LOC} />
<text
text="怪物手册"
loc={[24, 22]}
anc={[0, 0]}
font={nameFont}
fillStyle="gold"
/>
<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;
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 !== null && (
<text
text={`伤害 ${format(critical.baseInfo.damage)}`}
loc={[statX(0), 52]}
anc={[0, 0.5]}
font={statFont}
fillStyle="#ffffff"
/>
)}
{critical !== null && (
<text
text={`临界 ${format(critical.nextDiff)}`}
loc={[statX(1), 52]}
anc={[0, 0.5]}
font={statFont}
fillStyle="lightsalmon"
/>
)}
{critical !== null && (
<text
text={`减伤 ${format(-critical.damageDiff)}`}
loc={[statX(2), 52]}
anc={[0, 0.5]}
font={statFont}
fillStyle="lightpink"
/>
)}
{critical !== null && def1 !== null && (
<text
text={`1 防 ${format(critical.baseInfo.damage - def1.baseInfo.damage)}`}
loc={[statX(3), 52]}
anc={[0, 0.5]}
font={statFont}
fillStyle="cyan"
/>
)}
</container>
);
})}
</Scroll>
<text
text="退出"
loc={[MAIN_WIDTH - 16, MAIN_HEIGHT - 16]}
anc={[1, 1]}
font={nameFont}
cursor="pointer"
fillStyle="#ffffff"
onClick={exit}
/>
</container>
);
}, bookProps);
export const BookUI = new GameUI('book', Book);