refactor: UI 挪到 src

This commit is contained in:
AncTe committed 2026-10-19 21:18:22 +08:00
1 parent 319d18d067
commit 8f32509199
35 files changed
+177 -370

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
import { WebLoadStarter } from '@motajs/loader';
import { createClientCore } from './client/entry';
import { IClientCore } from '@user/client-modules';
import { LoadSceneUI } from '../packages-user/client-modules/src/ui/load';
import { LoadSceneUI } from './client/ui/load';
declare global {
interface Window {
+8 -17
View File
@@ -1,17 +1,8 @@
import { FaceGroup } from '@user/data-common';
import { MAIN_HEIGHT, MAIN_WIDTH } from './ui';
// 本文件是游戏的底层核心配置,如果不懂的话千万不要动
//#region 数据端配置
/** 数据端核心配置对象,不懂的话就不要动 */
export const CORE_URL = 'content/core.jsonc';
/** 勇士的默认朝向组,用于控制勇士的移动,不懂的话就不要动 */
export const HERO_FACE = FaceGroup.Dir8;
//#endregion
//#region 渲染端配置
//#region 常规配置
/** 渲染端的渲染画布,不懂的话就不要动 */
export const RENDER_CANVAS = '#render-main';
@@ -22,16 +13,16 @@ export const TILESET_RESERVE = 100000;
/** 一个 Tileset 的图块数量单位,例如 10000 时表示一个 Tileset 会占用 10000 或 10000 的倍数个图块,不懂的话不要动 */
export const TILESET_UNIT = 10000;
// 关于渲染的其他配置可在 ./client/ui/config.ts 中修改,
// 关于渲染的其他配置可在 ./ui/constant.ts 中修改,
// 关于高级渲染设置,请参考样板使用文档,那里会进行更详细的讲解。
/** 渲染器的渲染宽度,默认为地图宽度 416 + 左侧状态栏宽度 180 */
export const RENDER_WIDTH = 416 + 180;
/** 渲染器的渲染高度,默认为地图高度 416 */
export const RENDER_HEIGHT = 416;
/** 渲染器的渲染宽度,默认为 UI 配置中的整体宽度 */
export const RENDER_WIDTH = MAIN_WIDTH;
/** 渲染器的渲染高度,默认为 UI 配置中的整体高度 */
export const RENDER_HEIGHT = MAIN_HEIGHT;
//#endregion
//#region 渲染端调试配置
//#region 调试配置
/**
* 渲染器的激励源是否使用变速器,使用后可以通过 {@link VARIATOR_DEBUG_SPEED} 调整激励源速度,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
RENDER_WIDTH,
TILESET_RESERVE,
TILESET_UNIT
} from '../constant';
} from './constant';
import { createCoreStateConfig } from '../data/entry';
import { MainClientPlugin } from './initialize';
import { clientPlugins } from './plugin';
+3
View File
@@ -0,0 +1,3 @@
export * from './constant';
export * from './entry';
export * from './initialize';
+28
View File
@@ -0,0 +1,28 @@
import {
GameUI,
SetupComponentOptions,
IUIDefaultPropsBase
} from '@motajs/system';
import { DefaultProps } from '@motajs/render-vue';
import { defineComponent } from 'vue';
import { MAIN_HEIGHT, MAIN_WIDTH } from './constant';
export interface MainBackgroundProps
extends DefaultProps, IUIDefaultPropsBase {}
const mainBackgroundProps = {
props: ['controller', 'instance']
} satisfies SetupComponentOptions<MainBackgroundProps>;
export const MainBackground = defineComponent<MainBackgroundProps>(() => {
return () => (
<g-rect
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
fill
fillStyle="rgba(0, 0, 0, 0.8)"
/>
);
}, mainBackgroundProps);
/** 主 UI 控制器的背景 UI */
export const MainBackgroundUI = new GameUI('main-background', MainBackground);
+251
View File
@@ -0,0 +1,251 @@
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 './constant';
import { IUIPropsBase } from '@user/client-modules';
export interface BookProps extends IUIPropsBase {}
/** 怪物手册组件的 props 声明 */
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(' ');
};
/** 计算第 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 exit = () => props.controller.close(props.instance);
return () => (
<container 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 && (
<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>
);
})}
</Scroll>
{/* 右下角退出按钮 */}
<text
text="退出"
loc={[MAIN_WIDTH - 16, MAIN_HEIGHT - 16]}
anc={[1, 1]}
font={nameFont}
cursor="pointer"
fillStyle="#ffffff"
onClick={exit}
/>
</container>
);
}, bookProps);
/** 怪物手册列表的 UI 控制器 */
export const BookUI = new GameUI('book', Book);
+247
View File
@@ -0,0 +1,247 @@
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 { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from './constant';
import { IUIPropsBase } from '@user/client-modules';
/** 详情当前展示的属性模式 */
const enum DetailMode {
/** 基础属性,即不考虑光环等加成的原始状态 */
Base,
/** 计算后属性,即含光环等加成的状态 */
Computed
}
export interface BookDetailProps extends IUIPropsBase {
enemy: IEnemy<IEnemyAttr> | IEnemyView<IEnemyAttr>;
}
/** 怪物详情组件的 props 声明 */
const bookDetailProps = {
props: ['controller', 'instance', 'state', 'enemy']
} satisfies SetupComponentOptions<BookDetailProps>;
/** 怪物名称字体 */
const nameFont = Font.defaults({ size: 20, weight: 700 });
/** 属性数值字体 */
const statFont = Font.defaults({ size: 15 });
/** 特殊属性字体 */
const specialFont = Font.defaults({ size: 14 });
/** 怪物详细信息组件 */
export const BookDetail = defineComponent<BookDetailProps>(props => {
/** 当前展示的属性模式 */
const mode = ref(DetailMode.Base);
/** 若入参为怪物视图则返回该视图,否则返回 null */
const getView = (): IEnemyView<IEnemyAttr> | null => {
if (props.enemy instanceof EnemyView) return props.enemy;
return null;
};
/** 获取基础属性,视图取基础怪物,否则取入参本身 */
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();
} else {
return props.enemy as IReadonlyEnemy<IEnemyAttr>;
}
};
// TODO: 后续支持「在地图中打开指定 View 详情」时,locator 需按实际位置补全
const getLocator = (): ITileLocator | null => {
const view = getView();
if (!view) return null;
return view.context.getEnemyLocatorByView(view);
};
/** 当前模式对应的怪物数据 */
const current = computed(() =>
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 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>
);
};
}, bookDetailProps);
/** 怪物详细信息的 UI 控制器 */
export const BookDetailUI = new GameUI('book-detail', BookDetail);
+86
View File
@@ -0,0 +1,86 @@
import { ElementLocator, Font } from '@motajs/render';
// 本文件为 UI 配置文件,你可以修改下面的每个常量来控制 UI 的显示参数,每个常量都有注释说明
//#region 地图配置
/** 每个格子的宽度 */
export const CELL_WIDTH = 32;
/** 每个格子的高度 */
export const CELL_HEIGHT = 32;
/** 地图格子宽度,此处仅影响画面,不影响游戏内逻辑,游戏内逻辑地图大小请在 core.js 中修改 */
export const MAP_BLOCK_WIDTH = 13;
/** 地图格子高度,此处仅影响画面,不影响游戏内逻辑,游戏内逻辑地图大小请在 core.js 中修改 */
export const MAP_BLOCK_HEIGHT = 13;
/** 地图像素宽度 */
export const MAP_WIDTH = CELL_WIDTH * MAP_BLOCK_WIDTH;
/** 地图像素高度 */
export const MAP_HEIGHT = CELL_HEIGHT * MAP_BLOCK_HEIGHT;
/** 地图宽度的一半 */
export const HALF_MAP_WIDTH = MAP_WIDTH / 2;
/** 地图高度的一半 */
export const HALF_MAP_HEIGHT = MAP_HEIGHT / 2;
/**
* 动态内容预留,不明白含义的话不要动。地图上所有正在移动的图块称为动态内容,这些内容的数量无法预测,因此需要预留数组大小,
* 如果不够再临时扩充。如果你的塔中有大量的移动操作,可以适当提高此值,避免频繁的内存扩充行为,可以一定程度上提高性能表现。
*/
export const DYNAMIC_RESERVE = 16;
/**
* 移动图块容忍度,不明白含义的话不要动。如果移动图块的数量长期小于当前预留数量,那么将会降低预留数量,提升性能表现。
* 调整此值可以调整频率,值越大,越不容易因为数量小于预留数量而减小预留。
*/
export const MOVING_TOLERANCE = 60;
/** 开关门动画的动画时长 */
export const DOOR_ANIMATE_INTERVAL = 50;
//#endregion
//#region 状态栏配置
/** 状态栏像素宽度 */
export const STATUS_BAR_WIDTH = 180;
/** 状态栏像素高度 */
export const STATUS_BAR_HEIGHT = 32 * MAP_BLOCK_HEIGHT;
/** 右侧状态栏的横坐标 */
export const RIGHT_STATUS_POS = STATUS_BAR_WIDTH + MAP_WIDTH;
/** 是否启用右侧状态栏 */
export const ENABLE_RIGHT_STATUS_BAR = false;
/** 状态栏数量,启用右侧状态栏为两个,不启用为一个 */
export const STATUS_BAR_COUNT = ENABLE_RIGHT_STATUS_BAR ? 2 : 1;
/** 状态栏宽度的一半 */
export const HALF_STATUS_WIDTH = STATUS_BAR_WIDTH / 2;
//#endregion
//#region 游戏画面
/** 游戏画面像素宽度,宽=地图宽度+状态栏宽度*状态栏数量 */
export const MAIN_WIDTH = MAP_WIDTH + STATUS_BAR_WIDTH * STATUS_BAR_COUNT;
/** 游戏画面像素高度 */
export const MAIN_HEIGHT = MAP_HEIGHT;
/** 游戏画面宽度的一半 */
export const HALF_WIDTH = MAIN_WIDTH / 2;
/** 游戏画面高度的一半 */
export const HALF_HEIGHT = MAIN_HEIGHT / 2;
/** 全屏显示的 loc */
export const FULL_LOC: ElementLocator = [0, 0, MAIN_WIDTH, MAIN_HEIGHT];
/** 居中显示的 loc */
export const CENTER_LOC: ElementLocator = [
HALF_WIDTH,
HALF_HEIGHT,
void 0,
void 0,
0.5,
0.5
];
//#endregion
//#region 通用配置
/** 弹框的宽度,使用在内置 UI 与组件中,包括确认框、选择框、等待框等 */
export const POP_BOX_WIDTH = MAP_WIDTH / 2;
/** 默认字体 */
export const DEFAULT_FONT = new Font('Verdana', 16);
//#endregion
+551
View File
@@ -0,0 +1,551 @@
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 { IHeroAttr } from '@user/data-common';
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from './constant';
import { IUIPropsBase } from '@user/client-modules';
interface IHeroAttrItem {
/** 勇士属性的键名 */
readonly id: keyof IHeroAttr;
/** 该属性在状态区域显示的中文名 */
readonly name: string;
}
export interface EquipBoxProps extends IUIPropsBase {
/** 勇士状态对象 */
heroState: IHeroState<IHeroAttr>;
/** 状态区域要列举的属性,按数组顺序自上而下展示 */
attributes: readonly IHeroAttrItem[];
}
/** 装备界面组件的 props 声明 */
const equipBoxProps = {
props: ['controller', 'instance', 'state', 'heroState', 'attributes']
} satisfies SetupComponentOptions<EquipBoxProps>;
/** 左侧状态区域宽度 */
const STATUS_WIDTH = 180;
/** 左侧状态区域标题占用的高度 */
const STATUS_HEADER = 28;
/** 左侧状态区域每一行的高度 */
const STATUS_ROW_HEIGHT = 26;
/** 右侧区域起始横坐标 */
const RIGHT_LEFT = STATUS_WIDTH;
/** 右侧区域宽度 */
const RIGHT_WIDTH = MAIN_WIDTH - RIGHT_LEFT;
/** 装备描述区域顶部纵坐标 */
const DESC_TOP = 8;
/** 装备描述区域高度 */
const DESC_HEIGHT = 100;
/** 装备槽区域顶部纵坐标 */
const SLOT_TOP = DESC_TOP + DESC_HEIGHT;
/** 装备槽区域高度 */
const SLOT_HEIGHT = 112;
/** 单个装备槽的宽度 */
const SLOT_CELL_WIDTH = 76;
/** 装备槽内图标的尺寸 */
const SLOT_ICON_SIZE = 40;
/** 拥有装备区域顶部纵坐标 */
const OWNED_TOP = SLOT_TOP + SLOT_HEIGHT;
/** 拥有装备区域高度,底部为退出按钮留出空间 */
const OWNED_HEIGHT = MAIN_HEIGHT - OWNED_TOP - 32;
/** 拥有装备区域标题占用的高度 */
const OWNED_HEADER = 22;
/** 拥有装备每一行的高度 */
const OWNED_ROW_HEIGHT = 50;
/** 拥有装备行内图标的尺寸 */
const OWNED_ICON_SIZE = 40;
/** 拖拽浮层图标的尺寸 */
const DRAG_ICON_SIZE = 44;
/** 判定为拖拽所需的最小移动距离(像素) */
const DRAG_THRESHOLD = 8;
/** 区域标题字体 */
const titleFont = Font.defaults({ size: 15, weight: 700 });
/** 属性名与描述文本字体 */
const labelFont = Font.defaults({ size: 13 });
/** 装备界面组件 */
export const EquipBox = defineComponent<EquipBoxProps>(props => {
/** 勇士状态对象 */
const hero = props.heroState;
/** 当前选中的装备实例 uid,-1 表示未选中 */
const selected = ref(-1);
/** 被拖拽的装备实例 uid,-1 表示当前没有拖拽 */
const dragUid = ref(-1);
/** 拖拽来源的装备槽索引,-1 表示来自拥有装备列表 */
const dragFromSlot = ref(-1);
/** 拖拽起点的绝对横坐标 */
const dragStartX = ref(0);
/** 拖拽起点的绝对纵坐标 */
const dragStartY = ref(0);
/** 指针当前的绝对横坐标 */
const dragX = ref(0);
/** 指针当前的绝对纵坐标 */
const dragY = ref(0);
/** 本次按下是否已越过阈值进入拖拽 */
const dragging = ref(false);
/** 指针当前悬停的装备槽索引,-1 表示未悬停在任何槽 */
const hoverSlot = ref(-1);
/**
* 获取装备可装备至的同类型第一个槽位索引,找不到时返回 -1
* @param uid 装备实例 uid
*/
const getTargetSlot = (uid: number) => {
for (let i = 0; i < hero.equip.slots.length; i++) {
if (hero.equip.canEquipTo(uid, i) !== EquipStatus.CannotEquip) {
return i;
}
}
return -1;
};
/** 拥有装备列表,按装备道具图块数字升序排列 */
const owned = computed(() => {
const list = hero.items.equipment.instances();
return list.slice().sort((a, b) => a.item.num - b.item.num);
});
/** 各装备槽当前所装备的实例,未装备的槽为 null */
const equippedList = computed(() => {
return hero.equip.slots.map((_, i) => {
const uid = hero.equip.getEquipped(i);
if (isNil(uid)) return null;
return hero.items.equipment.get(uid);
});
});
/** 当前选中的装备实例,未选中时为 null */
const selectedEquip = computed(() => {
if (selected.value < 0) return null;
return hero.items.equipment.get(selected.value);
});
/** 选中装备所处的装备槽索引,未装备时为 -1 */
const selectedSlot = computed(() => {
if (selected.value < 0) return -1;
for (let i = 0; i < hero.equip.slots.length; i++) {
if (hero.equip.getEquipped(i) === selected.value) return i;
}
return -1;
});
// 未装备的装备与同类型第一个槽位对比;已装备的装备仅展示卸下带来的增减
const diff = computed<Readonly<Partial<IHeroAttr>> | null>(() => {
if (selected.value < 0) return null;
const equippedSlot = selectedSlot.value;
if (equippedSlot >= 0) {
return hero.equip.compareEquip(-1, selected.value, equippedSlot);
}
const target = getTargetSlot(selected.value);
if (target < 0) return null;
const existing = hero.equip.getEquipped(target);
const another = isNil(existing) ? -1 : existing;
return hero.equip.compareEquip(selected.value, another, target);
});
/** 被拖拽装备的图块数字,未拖拽时为 -1 */
const dragNum = computed(() => {
if (dragUid.value < 0) return -1;
const equip = hero.items.equipment.get(dragUid.value);
return equip ? equip.item.num : -1;
});
/**
* 开始跟踪一次装备按下,记录起点并按来源区分装备槽与拥有装备
* @param uid 装备实例 uid
* @param fromSlot 装备所处的装备槽索引,来自拥有装备列表时为 -1
* @param x 按下点的绝对横坐标
* @param y 按下点的绝对纵坐标
*/
const startPress = (
uid: number,
fromSlot: number,
x: number,
y: number
) => {
dragUid.value = uid;
dragFromSlot.value = fromSlot;
dragStartX.value = x;
dragStartY.value = y;
dragX.value = x;
dragY.value = y;
dragging.value = false;
hoverSlot.value = -1;
};
/**
* 跟踪指针移动,越过阈值后进入拖拽态并更新浮层位置
* @param x 指针当前的绝对横坐标
* @param y 指针当前的绝对纵坐标
*/
const movePress = (x: number, y: number) => {
if (dragUid.value < 0) return;
dragX.value = x;
dragY.value = y;
const dx = x - dragStartX.value;
const dy = y - dragStartY.value;
if (dx * dx + dy * dy > DRAG_THRESHOLD * DRAG_THRESHOLD) {
dragging.value = true;
}
};
// 释放指针:未越过阈值视为点击选中;越过阈值则按来源执行穿脱
const endPress = () => {
if (dragUid.value < 0) return;
const uid = dragUid.value;
const fromSlot = dragFromSlot.value;
const target = hoverSlot.value;
const wasDragging = dragging.value;
dragUid.value = -1;
dragFromSlot.value = -1;
dragging.value = false;
hoverSlot.value = -1;
if (!wasDragging) {
// 再次点击同一件未装备的装备,将它穿到同类型第一个槽位
if (fromSlot < 0 && selected.value === uid) {
const slot = getTargetSlot(uid);
if (slot >= 0) hero.equip.equip(uid, slot, true);
}
selected.value = uid;
return;
}
if (fromSlot >= 0) {
// 拖拽已装备的装备,释放时卸下
hero.equip.unequip(fromSlot);
return;
}
if (
target >= 0 &&
hero.equip.canEquipTo(uid, target) !== EquipStatus.CannotEquip
) {
hero.equip.equip(uid, target, true);
}
};
/** 关闭当前装备界面 */
const exit = () => props.controller.close(props.instance);
return () => {
const equip = selectedEquip.value;
const d = diff.value;
return (
<container
loc={FULL_LOC}
onMove={ev => movePress(ev.absoluteX, ev.absoluteY)}
onUp={endPress}
>
{/* 状态区 */}
<text
text="状态"
loc={[8, 8]}
anc={[0, 0]}
font={titleFont}
fillStyle="gold"
/>
<Scroll
noscroll
loc={[
0,
STATUS_HEADER,
STATUS_WIDTH,
MAIN_HEIGHT - STATUS_HEADER
]}
>
{props.attributes.map((attr, i) => {
const value = hero.attribute.getFinalAttribute(attr.id);
let delta = 0;
if (d) {
const raw = d[attr.id];
if (typeof raw === 'number') delta = raw;
}
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>
);
})}
</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>
{/* 装备槽区 */}
<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>
{/* 拥有装备区 */}
<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={[MAIN_WIDTH - 12, MAIN_HEIGHT - 10]}
anc={[1, 1]}
font={titleFont}
cursor="pointer"
fillStyle="#ffffff"
onClick={exit}
/>
{/* 拖拽浮层 */}
{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}
/>
)}
</container>
);
};
}, equipBoxProps);
/** 装备界面的 UI 控制器 */
export const EquipBoxUI = new GameUI('equipbox', EquipBox);
+16
View File
@@ -0,0 +1,16 @@
export * from './background';
export * from './book';
export * from './bookDetail';
export * from './equipbox';
export * from './load';
export * from './main';
export * from './save';
export * from './settings';
export * from './statistics';
export * from './statusBar';
export * from './title';
export * from './toolbar';
export * from './toolbox';
export * from './viewmap';
export * from './constant';
+164
View File
@@ -0,0 +1,164 @@
import { DefaultProps } from '@motajs/render-vue';
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { defineComponent } from 'vue';
import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from './constant';
import { ElementLocator, Font, MotaOffscreenCanvas2D } from '@motajs/render';
import { transitioned } from '../../../packages-user/client-modules/src/render/use';
import { cosh, CurveMode, linear } from '@motajs/animate';
import { clamp } from 'lodash-es';
import { sleep } from '@motajs/common';
import { GameTitleUI } from './title';
import { IUIPropsBase } from '@user/client-modules';
/** 加载界面的任务进度条半径 */
export const LOAD_TASK_RADIUS = Math.min(MAIN_WIDTH, MAIN_HEIGHT) / 6;
/** 加载界面的字节进度条纵轴位置 */
export const LOAD_BYTE_HEIGHT = MAIN_HEIGHT / 2 + MAIN_HEIGHT / 4;
/** 加载界面任务进度条的纵轴位置 */
export const LOAD_TASK_CENTER_HEIGHT = MAIN_HEIGHT / 2 - MAIN_HEIGHT / 8;
/** 加载界面字节进度条的长度 */
export const LOAD_BYTE_LENGTH = MAIN_WIDTH - MAIN_WIDTH / 12;
/** 加载界面任务进度条的粗细 */
export const LOAD_TASK_LINE_WIDTH = 6;
/** 加载界面字节进度条的粗细 */
export const LOAD_BYTE_LINE_WIDTH = 6;
/** 已加载部分进度条的颜色 */
export const LOAD_LOADED_COLOR = '#57ff78';
/** 未加载部分进度条的颜色 */
export const LOAD_UNLOADED_COLOR = '#ccc';
/** 加载界面的文字颜色 */
export const LOAD_FONT_COLOR = '#fff';
export interface ILoadProps extends IUIPropsBase, DefaultProps {}
const loadSceneProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<ILoadProps>;
export const LoadScene = defineComponent<ILoadProps>(props => {
const loader = props.state.loader;
const taskFont = new Font('Verdana', 24);
const byteFont = new Font('Verdana', 12);
/** 当前加载进度 */
const taskProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!;
const byteProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!;
const alpha = transitioned(1, 400, linear())!;
// 两个进度条的位置
const taskLoc: ElementLocator = [
MAIN_WIDTH / 2,
LOAD_TASK_CENTER_HEIGHT,
LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2,
LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2,
0.5,
0.5
];
const byteLoc: ElementLocator = [
MAIN_WIDTH / 2,
LOAD_BYTE_HEIGHT,
LOAD_BYTE_LENGTH + LOAD_BYTE_LINE_WIDTH,
LOAD_BYTE_LINE_WIDTH * 2 + byteFont.size,
0.5,
0.5
];
const loadEnd = async () => {
alpha.set(0);
await sleep(400);
props.controller.closeAll();
props.controller.open(GameTitleUI, {});
};
const startLoad = async () => {
for await (const _ of loader.start()) {
taskProgress.set(loader.manager.getLoadedTasks());
byteProgress.set(loader.manager.getLoadedByte());
}
loadEnd();
};
// 开始加载
startLoad();
/** 渲染加载任务进度 */
const renderTaskList = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.lineCap = 'round';
ctx.lineWidth = LOAD_TASK_LINE_WIDTH;
ctx.font = taskFont.string();
const loaded = loader.manager.getLoadedTasks();
const total = loader.manager.getAddedTasks();
// 这里使用渐变参数,因为要有动画效果
const progress = clamp(taskProgress.value / total, 0, 1);
const cx = taskLoc[2]! / 2;
const cy = taskLoc[3]! / 2;
ctx.beginPath();
ctx.arc(cx, cy, LOAD_TASK_RADIUS, 0, Math.PI * 2);
ctx.strokeStyle = LOAD_UNLOADED_COLOR;
ctx.stroke();
ctx.beginPath();
const end = progress * Math.PI * 2 - Math.PI / 2;
ctx.arc(cx, cy, LOAD_TASK_RADIUS, -Math.PI / 2, end);
ctx.strokeStyle = LOAD_LOADED_COLOR;
ctx.stroke();
ctx.fillStyle = LOAD_FONT_COLOR;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${loaded} / ${total}`, cx, cy + 3);
};
/** 渲染加载字节进度 */
const renderByteList = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.lineCap = 'round';
ctx.lineWidth = LOAD_BYTE_LINE_WIDTH;
ctx.font = byteFont.string();
const total = loader.manager.getTotalByte();
const loaded = loader.manager.getLoadedByte();
// 这里使用渐变参数,因为要有动画效果
const progress = clamp(byteProgress.value / total, 0, 1);
const sx = LOAD_BYTE_LINE_WIDTH;
const sy = byteFont.size + LOAD_BYTE_LINE_WIDTH;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + LOAD_BYTE_LENGTH, sy);
ctx.strokeStyle = LOAD_UNLOADED_COLOR;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + progress * LOAD_BYTE_LENGTH, sy);
ctx.strokeStyle = LOAD_LOADED_COLOR;
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillStyle = LOAD_FONT_COLOR;
const loadedMB = (loaded / 2 ** 20).toFixed(2);
const totalMB = (total / 2 ** 20).toFixed(2);
const percent = loader.manager.getByteRatio() * 100;
ctx.fillText(
`${loadedMB}MB / ${totalMB}MB | ${percent.toFixed(2)}%`,
byteLoc[2]! - LOAD_BYTE_LINE_WIDTH,
byteLoc[3]! - LOAD_BYTE_LINE_WIDTH * 2
);
};
return () => (
<container loc={FULL_LOC} alpha={alpha.ref.value}>
<custom
loc={taskLoc}
render={renderTaskList}
bindings={[taskProgress.ref]}
nocache
/>
<custom
loc={byteLoc}
render={renderByteList}
bindings={[byteProgress.ref]}
nocache
/>
</container>
);
}, loadSceneProps);
export const LoadSceneUI = new GameUI('load-scene', LoadScene);
+182
View File
@@ -0,0 +1,182 @@
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 { GameUI, SetupComponentOptions } from '@motajs/system';
import {
ENABLE_RIGHT_STATUS_BAR,
MAIN_HEIGHT,
MAIN_WIDTH,
MAP_HEIGHT,
MAP_WIDTH,
RIGHT_STATUS_POS,
STATUS_BAR_HEIGHT,
STATUS_BAR_WIDTH
} from './constant';
import { LeftStatusBar, RightStatusBar } from './statusBar';
import { isNil } from 'lodash-es';
import { IUIPropsBase, using } from '@user/client-modules';
interface MainSceneProps extends IUIPropsBase {}
const mainSceneProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<MainSceneProps>;
const MainScene = defineComponent<MainSceneProps>(props => {
const mainMapRenderer = props.state.mainMapRenderer;
const mainMapExtension = props.state.mainMapExtension;
//#region 基本定义
const mainTextboxProps: TextboxProps = {
text: '',
hidden: true,
loc: [0, MAP_HEIGHT - 150, MAP_WIDTH, 150],
zIndex: 30,
fillStyle: '#fff',
titleFill: 'gold',
font: new Font('normal'),
titleFont: new Font('normal', 20, 'px', 700),
winskin: 'winskin.png',
interval: 30,
lineHeight: 4,
width: MAP_WIDTH
};
const hideStatus = ref(
props.state.flags.getFieldValueDefaults('hideStatusBar', false)
);
// TODO: flags 更新未接(当前无监听系统)
// const weather = new WeatherController();
// weather.extern('main');
//#region sprite 渲染
let lastLength = 0;
using.onExcitedFunc(() => {
const len = core.status.stepPostfix?.length ?? 0;
if (len !== lastLength) {
mapMiscSprite.value?.update();
lastLength = len;
}
});
const mapMiscSprite = ref<CustomRenderItem>();
const renderMapMisc = (canvas: MotaOffscreenCanvas2D) => {
const step = core.status.stepPostfix;
if (!step) return;
const ctx = canvas.ctx;
ctx.fillStyle = '#fff';
step.forEach(({ x, y, direction }) => {
ctx.fillRect(x * 32 + 12, y * 32 + 12, 8, 8);
if (!isNil(direction)) {
switch (direction) {
case 'down':
ctx.fillRect(x * 32 + 12, y * 32 + 20, 8, 12);
break;
case 'left':
ctx.fillRect(x * 32, y * 32 + 12, 12, 8);
break;
case 'right':
ctx.fillRect(x * 32 + 20, y * 32 + 12, 12, 8);
break;
case 'up':
ctx.fillRect(x * 32 + 12, y * 32, 8, 12);
break;
}
}
});
};
return () => (
<container
id="main-scene"
width={MAIN_WIDTH}
height={MAIN_HEIGHT}
noanti
nocache
>
<LeftStatusBar
loc={[0, 0, STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT]}
hidden={hideStatus.value}
state={props.state}
></LeftStatusBar>
<g-line
line={[STATUS_BAR_WIDTH, 0, STATUS_BAR_WIDTH, MAIN_HEIGHT]}
lineWidth={1}
/>
<container
id="map-draw"
loc={[STATUS_BAR_WIDTH, 0, MAP_WIDTH, MAP_HEIGHT]}
zIndex={10}
noanti
>
<map-render
renderer={mainMapRenderer}
// @ts-expect-error 需要重构
layerState={state.maps}
extension={mainMapExtension}
loc={[0, 0, MAP_WIDTH, MAP_HEIGHT]}
/>
<Textbox id="main-textbox" {...mainTextboxProps}></Textbox>
{/* <FloorChange id="floor-change" zIndex={50}></FloorChange> */}
<Tip
id="main-tip"
zIndex={80}
loc={[8, 8, 200, 32]}
pad={[12, 6]}
corner={16}
/>
<custom
noevent
loc={[0, 0, MAP_WIDTH, MAP_HEIGHT]}
ref={mapMiscSprite}
zIndex={170}
render={renderMapMisc}
/>
</container>
<g-line
line={[RIGHT_STATUS_POS, 0, RIGHT_STATUS_POS, MAP_HEIGHT]}
lineWidth={1}
/>
<RightStatusBar
loc={[RIGHT_STATUS_POS, 0, STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT]}
hidden={hideStatus.value && ENABLE_RIGHT_STATUS_BAR}
state={props.state}
></RightStatusBar>
<container
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
hidden={!props.state.mainUIController.active.value}
zIndex={200}
>
{props.state.mainUIController.render()}
</container>
<g-rect
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
hidden={hideStatus.value}
zIndex={100}
stroke
noevent
></g-rect>
<g-line
line={[STATUS_BAR_WIDTH, 0, RIGHT_STATUS_POS, 0]}
hidden={!hideStatus.value}
zIndex={100}
/>
<g-line
line={[
STATUS_BAR_WIDTH,
MAP_HEIGHT,
RIGHT_STATUS_POS,
MAP_HEIGHT
]}
hidden={!hideStatus.value}
zIndex={100}
/>
</container>
);
}, mainSceneProps);
export const MainSceneUI = new GameUI('main-scene', MainScene);
+613
View File
@@ -0,0 +1,613 @@
import { ElementLocator, IWheelEvent, Font } from '@motajs/render';
import { DefaultProps } from '@motajs/render-vue';
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
import {
defineComponent,
ref,
computed,
onMounted,
shallowReactive
} from 'vue';
import {
getConfirm,
Page,
PageExpose,
Thumbnail,
TipStore,
IGameUIPropsBase,
MAP_BLOCK_WIDTH
} from '@user/client-base';
import { HALF_HEIGHT, HALF_WIDTH, MAP_WIDTH, POP_BOX_WIDTH } from './constant';
import { adjustGrid, IGridLayoutData } from '@user/client-base';
import { getSave, IUIPropsBase, SaveData, useKey } from '@user/client-modules';
/** 存档缩略图尺寸 */
export const SAVE_ITEM_SIZE = MAP_BLOCK_WIDTH * 10;
/** 单个存档上方显示第几号存档的高度 */
export const SAVE_ITEM_TOP = 24;
/** 单个存档下方显示这个存档信息的高度 */
export const SAVE_ITEM_DOWN = 24;
/** 单个存档高度,包括存档下方的信息 */
export const SAVE_ITEM_HEIGHT = SAVE_ITEM_SIZE + SAVE_ITEM_TOP + SAVE_ITEM_DOWN;
/** 存档间距 */
export const SAVE_INTERVAL = 20;
/** 存档下巴高度,即下方显示页码和返回按钮的高度 */
export const SAVE_DOWN_PAD = 30;
/** 存档页码数,调高并不会影响性能,但是如果玩家存档太多的话会导致存档体积很大 */
export const SAVE_PAGES = 1000;
const enum SaveMode {
Save,
Load,
Other
}
export interface SaveProps extends IUIPropsBase, DefaultProps {
loc: ElementLocator;
mode: SaveMode;
}
export interface SaveItemProps extends DefaultProps {
loc: ElementLocator;
index: number;
selected: boolean;
inDelete: boolean;
data: SaveData | null;
}
export type SaveEmits = {
/** 点击存档时触发 */
emit: (index: number, exist: boolean) => void;
/** 删除存档时触发 */
delete: (index: number, exist: boolean) => void;
/** 手动点击退出时触发 */
exit: () => void;
};
const saveProps = {
props: ['loc', 'controller', 'instance', 'mode'],
emits: ['delete', 'emit', 'exit']
} satisfies SetupComponentOptions<SaveProps, SaveEmits, keyof SaveEmits>;
const saveBtnProps = {
props: ['loc', 'index', 'selected', 'inDelete', 'data']
} satisfies SetupComponentOptions<SaveItemProps>;
export const SaveItem = defineComponent<SaveItemProps>(props => {
const font = Font.defaults({ size: 16 });
const statusFont = Font.defaults({ size: 12 });
const w = computed(() => props.loc[2] ?? 200);
const h = computed(() => props.loc[3] ?? 200);
const lineWidth = computed(() => (props.selected ? 4 : 2));
const imgLoc = computed<ElementLocator>(() => {
const size = w.value - 4;
return [2, SAVE_ITEM_TOP, size, size];
});
const name = computed(() => {
return props.index === 0 ? '自动存档' : `存档${props.index}`;
});
const statusText = computed(() => {
if (!props.data) return '';
else {
const hero = props.data.data.hero;
const hp = core.formatBigNumber(hero.hp);
const atk = core.formatBigNumber(hero.atk);
const def = core.formatBigNumber(hero.def);
return `${hp}/${atk}/${def}`;
}
});
const timeText = computed(() => {
if (!props.data) return '';
const date = new Date(props.data.data.time);
return date.toLocaleString();
});
const strokeStyle = computed(() => {
if (props.selected) return props.inDelete ? 'red' : 'gold';
else return 'white';
});
const floorId = computed(() => props.data?.data.floorId ?? 'empty');
const mapBlocks = computed(() => {
if (!props.data) return [];
else {
const currData = props.data.data;
const map = core.maps.loadMap(currData.maps, currData.floorId);
core.extractBlocksForUI(map, currData.hero.flags); // 这一步会向map写入blocks
return map.blocks;
}
});
return () => (
<container loc={props.loc}>
<text
text={name.value}
font={font}
loc={[w.value / 2, 0]}
anc={[0.5, 0]}
/>
<g-rect
loc={imgLoc.value}
strokeAndFill
fillStyle="gray"
strokeStyle={strokeStyle.value}
lineWidth={lineWidth.value}
lineJoin="miter"
cursor="pointer"
/>
<Thumbnail
hidden={!props.data}
loc={imgLoc.value}
padStyle="gray"
floorId={floorId.value}
map={mapBlocks.value}
hero={props.data?.data.hero}
all
noHD
size={(w.value - 4) / MAP_WIDTH}
noevent
/>
<text
text={statusText.value}
fillStyle="yellow"
font={statusFont}
loc={[w.value / 2, h.value - SAVE_ITEM_DOWN]}
anc={[0.5, 0]}
/>
<text
text={timeText.value}
fillStyle="yellow"
font={statusFont}
loc={[w.value / 2, h.value - SAVE_ITEM_DOWN + 12]}
anc={[0.5, 0]}
/>
</container>
);
}, saveBtnProps);
export const Save = defineComponent<SaveProps, SaveEmits, keyof SaveEmits>(
(props, { emit }) => {
const font = Font.defaults({ size: 18 });
const pageFont = Font.defaults({ size: 14 });
/** 当前页上被选中的存档的posIndex */
const selected = ref(0);
const now = ref(0);
const inDelete = ref(false);
const pageRef = ref<PageExpose>();
const saveData: Record<number, SaveData | null> = shallowReactive({});
const width = computed(() => props.loc[2] ?? 200);
const height = computed(() => props.loc[3] ?? 200);
const grid = computed<IGridLayoutData>(() =>
adjustGrid(
width.value,
height.value - SAVE_DOWN_PAD,
SAVE_ITEM_SIZE,
SAVE_ITEM_HEIGHT,
SAVE_INTERVAL
)
);
const contentLoc = computed<ElementLocator>(() => {
const cx = width.value / 2;
const cy = (height.value - SAVE_DOWN_PAD) / 2;
return [cx, cy, grid.value.width, grid.value.height, 0.5, 0.5];
});
const deleteLoc = computed<ElementLocator>(() => {
const pad = (width.value - grid.value.width) / 2;
return [pad, height.value - 13, void 0, void 0, 0, 1];
});
const exitLoc = computed<ElementLocator>(() => {
const pad = (width.value - grid.value.width) / 2;
const right = width.value - pad;
return [right, height.value - 13, void 0, void 0, 1, 1];
});
//#region 数据信息
/**
* 获取存档在当前页的序号,范围为 0 到 pageCap-1。
*/
const getPosIndex = (index: number) => {
if (index === -1) return 0;
return (index % (grid.value.count - 1)) + 1;
};
/**
* 获取存档的总序号,从 0 开始,用于数据交互。
*/
const getIndex = (posIndex: number, page: number) => {
return page * (grid.value.count - 1) + posIndex - 1;
};
const updateDataList = async (page: number) => {
const promises: Promise<SaveData | null>[] = [getSave(0)];
for (let i = 1; i < grid.value.count; i++) {
const index = getIndex(i, page);
promises.push(getSave(index + 1));
}
const before = page;
const data = await Promise.all(promises);
if (now.value !== before) return;
data.forEach((v, i) => {
if (v) {
saveData[i] = v;
} else {
saveData[i] = null;
}
});
};
onMounted(() => {
const startIndex = getPosIndex(core.saves.saveIndex);
selected.value = startIndex - 1;
pageRef.value?.changePage(
Math.floor(core.saves.saveIndex / (grid.value.count - 1))
);
updateDataList(now.value);
});
//#region 逻辑操作
const exist = (index: number) => {
return saveData[index] !== null;
};
const deleteData = (index: number) => {
saveData[index] = null;
};
const emitSave = async (index: number) => {
const posIndex = getPosIndex(index);
if (inDelete.value) {
const confirm = await getConfirm(
props.controller,
`确认要删除存档 ${index + 1}?`,
[HALF_WIDTH, HALF_HEIGHT, void 0, void 0, 0.5, 0.5],
POP_BOX_WIDTH,
{ winskin: 'winskin.png' }
);
if (confirm) {
emit('delete', index, exist(posIndex));
deleteData(posIndex);
}
} else {
emit('emit', index, exist(posIndex));
}
if (index === -1) {
selected.value = 0;
} else {
selected.value = (index % (grid.value.count - 1)) + 1;
}
};
const toggleDelete = () => {
inDelete.value = !inDelete.value;
};
const exit = () => {
emit('exit');
props.controller.close(props.instance);
};
// #region 按键实现
const [key] = useKey();
key.realize('confirm', () => {
if (selected.value === 0) {
emitSave(-1);
} else {
emitSave(
(grid.value.count - 1) * now.value + selected.value - 1
);
}
})
.realize('exit', exit)
.realize('save', () => {
if (props.mode === SaveMode.Save) exit();
})
.realize('load', () => {
if (props.mode === SaveMode.Load) exit();
})
.realize(
'@save_pageUp',
() => {
pageRef.value?.movePage(1);
},
{ type: 'down-repeat' }
)
.realize(
'@save_pageDown',
() => {
pageRef.value?.movePage(-1);
},
{ type: 'down-repeat' }
)
.realize(
'@save_up',
() => {
if (!pageRef.value) return;
const cols = grid.value.cols;
const count = grid.value.count;
if (selected.value >= cols) {
selected.value -= cols;
} else {
if (now.value === 0) {
selected.value = 0;
} else {
const selectedCol = selected.value % cols;
selected.value = count - (cols - selectedCol);
pageRef.value?.movePage(-1);
}
}
},
{ type: 'down-repeat' }
)
.realize(
'@save_down',
() => {
const cols = grid.value.cols;
const count = grid.value.count;
if (selected.value < count - cols) {
selected.value += cols;
} else {
const selectedCol = selected.value % cols;
selected.value = selectedCol;
pageRef.value?.movePage(1);
}
},
{ type: 'down-repeat' }
)
.realize(
'@save_left',
() => {
if (!pageRef.value) return;
const count = grid.value.count;
if (selected.value > 0) {
selected.value--;
} else {
if (now.value > 0) {
selected.value = count;
pageRef.value?.movePage(-1);
}
}
},
{ type: 'down-repeat' }
)
.realize(
'@save_right',
() => {
const count = grid.value.count;
if (selected.value < count) {
selected.value++;
} else {
selected.value = 0;
pageRef.value?.movePage(1);
}
},
{ type: 'down-repeat' }
);
//#region 事件监听
// todo: 按住快速切换页码
const wheel = (ev: IWheelEvent) => {
const delta = Math.sign(ev.wheelY);
if (ev.ctrlKey) {
pageRef.value?.movePage(delta * 10);
} else {
pageRef.value?.movePage(delta);
}
};
return () => (
<container loc={props.loc}>
<Page
ref={pageRef}
loc={[0, 0, width.value, height.value - 10]}
pages={SAVE_PAGES}
font={pageFont}
v-model:page={now.value}
onWheel={wheel}
onPageChange={updateDataList}
>
{(page: number) => (
<container loc={contentLoc.value}>
{grid.value.locs.map((v, i) => {
const count = grid.value.count;
const rawIndex = (count - 1) * page + i;
const index = i === 0 ? 0 : rawIndex;
return (
<SaveItem
key={index}
loc={v}
index={index}
selected={selected.value === i}
inDelete={inDelete.value}
data={saveData[i]}
onClick={() => emitSave(index - 1)}
onEnter={() => (selected.value = i)}
/>
);
})}
</container>
)}
</Page>
<text
text="删除模式"
loc={deleteLoc.value}
font={font}
zIndex={10}
fillStyle={inDelete.value ? 'red' : 'white'}
onClick={toggleDelete}
cursor="pointer"
/>
<text
text="返回游戏"
loc={exitLoc.value}
font={font}
zIndex={10}
onClick={exit}
cursor="pointer"
/>
</container>
);
},
saveProps
);
export const SaveUI = new GameUI('save', Save);
export interface SaveValidation {
readonly valid: boolean;
readonly message: string;
}
export type SaveValidationFunction = (
index: number,
exist: boolean
) => SaveValidation;
/**
* 打开存读档界面并让用户选择一个存档。如果用户手动关闭了存档界面,返回 -2,否则返回用户选择的存档索引。
* 参数参考 {@link SaveProps},事件不可自定义。
*
* 使用示例:
* ```ts
* const index = await selectSave(props.controller, [0, 0, 416, 416]);
* if (index === -1) {
* // 如果用户未选择存档,而是关闭了存档。
* } else if (index === 0) {
* // 用户选择了自动存档。
* } else {
* // 用户选择了一个存档。
* }
* ```
* @param controller 在哪个控制器上打开
* @param loc 存读档界面的坐标
* @param props 传递给存读档界面的参数
* @returns 选择的存档索引
*/
export function selectSave(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
mode: SaveMode,
validate?: SaveValidationFunction,
props?: SaveProps
) {
const validateDelete = (index: number, exist: boolean): SaveValidation => {
if (index === -1) {
return { message: '不能删除自动存档!', valid: false };
} else {
return { message: '无法删除该存档!', valid: exist };
}
};
return new Promise<number>(res => {
const instance = controller.open(SaveUI, {
loc,
...props,
mode,
onEmit: (index: number, exist: boolean) => {
if (!validate) {
controller.close(instance);
res(index);
return;
}
const validation = validate(index, exist);
if (validation.valid) {
controller.close(instance);
res(index);
} else {
TipStore.get('main-tip')?.drawTip(validation.message);
}
},
onDelete: (index: number, exist: boolean) => {
if (!validate) return;
const validation = validateDelete(index, exist);
if (validation.valid) {
core.removeSave(index);
} else {
TipStore.get('main-tip')?.drawTip(validation.message);
}
},
onExit: () => {
res(-2);
}
});
});
}
export async function saveSave(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: SaveProps
) {
const validate = (index: number): SaveValidation => {
if (index === -1) {
return { message: '不能存档至自动存档!', valid: false };
} else {
return { message: '', valid: true };
}
};
const index = await selectSave(
controller,
loc,
SaveMode.Save,
validate,
props
);
if (index === -2) return false;
core.doSL(index + 1, 'save');
return true;
}
export async function saveLoad(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: SaveProps
) {
const validate = (_: number, exist: boolean): SaveValidation => {
return { message: '无效的存档!', valid: exist };
};
const index = await selectSave(
controller,
loc,
SaveMode.Load,
validate,
props
);
if (index === -2) return false;
if (index === -1) {
core.doSL('autoSave', 'load');
} else {
core.doSL(index + 1, 'load');
}
return true;
}
export async function saveWithExist(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: SaveProps
) {
const validate = (_: number, exist: boolean): SaveValidation => {
return { message: '无效的存档!', valid: exist };
};
const index = await selectSave(
controller,
loc,
SaveMode.Load,
validate,
props
);
return index;
}
+698
View File
@@ -0,0 +1,698 @@
import { ElementLocator } from '@motajs/render';
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
import { defineComponent } from 'vue';
import {
ChoiceItem,
ChoiceKey,
Choices,
ChoicesProps,
getConfirm,
waitbox,
IGameUIPropsBase
} from '@user/client-base';
import { gameKey, generateKeyboardEvent } from '@motajs/system';
import { getInput } from '@user/client-base';
import { openStatistics } from './statistics';
import { saveWithExist } from './save';
import { compressToBase64 } from 'lz-string';
import { ViewMapUI } from './viewmap';
import { CENTER_LOC, FULL_LOC, MAIN_HEIGHT, POP_BOX_WIDTH } from './constant';
import { IUIPropsBase, useKey } from '@user/client-modules';
export interface MainSettingsProps extends Partial<ChoicesProps>, IUIPropsBase {
loc: ElementLocator;
}
const mainSettingsProps = {
props: ['loc', 'controller', 'instance', 'state']
} satisfies SetupComponentOptions<MainSettingsProps>;
const enum MainChoice {
SystemSetting,
VirtualKey,
ViewMap,
/** @see {@link ReplaySettings} */
Replay,
/** @see {@link SyncSave} */
SyncSave,
/** @see {@link GameInfo} */
GameInfo,
Restart,
Back
}
export const MainSettings = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[MainChoice.SystemSetting, '系统设置'],
[MainChoice.VirtualKey, '虚拟键盘'],
[MainChoice.ViewMap, '浏览地图'],
[MainChoice.Replay, '录像回放'],
[MainChoice.SyncSave, '同步存档'],
[MainChoice.GameInfo, '游戏信息'],
[MainChoice.Restart, '返回标题'],
[MainChoice.Back, '返回游戏']
];
const controller: IUIController<IGameUIPropsBase> = props.controller;
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case MainChoice.SystemSetting: {
mainUi.open('settings');
break;
}
case MainChoice.VirtualKey: {
getVitualKeyOnce().then(value => {
gameKey.emitKey(
value.key,
value.assist,
'up',
generateKeyboardEvent(value.key, value.assist)
);
});
break;
}
case MainChoice.ViewMap: {
controller.open(ViewMapUI, {
loc: FULL_LOC
});
break;
}
case MainChoice.Replay: {
controller.open(ReplaySettingsUI, { loc: props.loc });
break;
}
case MainChoice.SyncSave: {
controller.open(SyncSaveUI, { loc: props.loc });
break;
}
case MainChoice.GameInfo: {
controller.open(GameInfoUI, { loc: props.loc });
break;
}
case MainChoice.Restart: {
const confirm = await getConfirm(
props.controller,
'确认要返回标题吗?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
props.controller.closeAll();
core.restart();
}
break;
}
case MainChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
choices={choices}
width={POP_BOX_WIDTH}
onChoose={choose}
maxHeight={MAIN_HEIGHT - 64}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
const enum ReplayChoice {
Start,
StartFromSave,
ResumeReplay,
ReplayRest,
ChooseReplay,
Download,
Back
}
export const ReplaySettings = defineComponent<MainSettingsProps>(props => {
const choice: ChoiceItem[] = [
[ReplayChoice.Start, '从头回放录像'],
[ReplayChoice.StartFromSave, '从存档开始回放'],
[ReplayChoice.ResumeReplay, '接续播放剩余录像'],
[ReplayChoice.ReplayRest, '播放存档剩余录像'],
[ReplayChoice.ChooseReplay, '选择录像文件'],
[ReplayChoice.Download, '下载当前录像'],
[ReplayChoice.Back, '返回游戏']
];
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case ReplayChoice.Start: {
props.controller.closeAll();
core.ui.closePanel();
const route = core.status.route.slice();
// TODO: flags 更新未接(当前无监听系统)
const flags = props.state.flags;
const seed = flags.getFieldValue<number>('__seed__');
core.startGame(core.status.hard, seed, route);
break;
}
case ReplayChoice.StartFromSave: {
const index = await saveWithExist(props.controller, FULL_LOC);
if (index === -2) break;
if (index === -1) {
core.doSL('autoSave', 'replayLoad');
} else {
core.doSL(index + 1, 'replayLoad');
}
props.controller.closeAll();
break;
}
case ReplayChoice.ResumeReplay: {
const index = await saveWithExist(props.controller, FULL_LOC);
if (index === -2) break;
const name = index === -1 ? 'autoSave' : index + 1;
const success = core.doSL(name, 'replayRemain');
if (!success) {
props.controller.closeAll();
break;
}
await getConfirm(
props.controller,
'[步骤2]请选择第二个存档。\n\\r[yellow]该存档必须是前一个存档的后续。\\r\n将尝试播放到此存档。',
CENTER_LOC,
POP_BOX_WIDTH
);
const index2 = await saveWithExist(props.controller, FULL_LOC);
if (index2 === -2) break;
const name2 = index2 === -1 ? 'autoSave' : index2 + 1;
core.doSL(name2, 'replayRemain');
props.controller.closeAll();
break;
}
case ReplayChoice.ReplayRest: {
const index = await saveWithExist(props.controller, FULL_LOC);
if (index === -2) break;
if (index === -1) {
core.doSL('autoSave', 'replaySince');
} else {
core.doSL(index + 1, 'replaySince');
}
props.controller.closeAll();
break;
}
case ReplayChoice.ChooseReplay: {
props.controller.closeAll();
core.chooseReplayFile();
break;
}
case ReplayChoice.Download: {
// TODO: flags 更新未接(当前无监听系统)
const flags = props.state.flags;
const seed = flags.getFieldValue<number>('__seed__');
core.download(
core.firstData.name + '_' + core.formatDate2() + '.h5route',
compressToBase64(
JSON.stringify({
name: core.firstData.name,
hard: core.status.hard,
seed,
route: core.encodeRoute(core.status.route)
})
)
);
break;
}
case ReplayChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
choices={choice}
width={POP_BOX_WIDTH}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
const enum GameInfoChoice {
Statistics,
Project,
Tower,
Help,
Download,
Back
}
export const GameInfo = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[GameInfoChoice.Statistics, '数据统计'],
[GameInfoChoice.Project, '查看工程'],
[GameInfoChoice.Tower, '游戏主页'],
[GameInfoChoice.Help, '操作帮助'],
[GameInfoChoice.Download, '下载离线版本'],
[GameInfoChoice.Back, '返回主菜单']
];
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case GameInfoChoice.Statistics: {
openStatistics(props.controller);
break;
}
case GameInfoChoice.Project: {
if (core.platform.isPC) window.open('editor.html', '_blank');
else {
const confirm = await getConfirm(
props.controller,
'即将离开本游戏,跳转至工程页面,确认跳转?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
window.location.href = 'editor-mobile.html';
}
}
break;
}
case GameInfoChoice.Tower: {
const name = core.firstData.name;
const href = `/tower/?name=${name}`;
if (core.platform.isPC) {
window.open(href, '_blank');
} else {
const confirm = await getConfirm(
props.controller,
'即将离开本游戏,跳转至评论页面,确认跳转?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
window.location.href = href;
}
}
break;
}
case GameInfoChoice.Download: {
const name = core.firstData.name;
const href = `/games/${name}/${name}.zip`;
if (core.platform.isPC) window.open(href);
else window.location.href = href;
break;
}
case GameInfoChoice.Help: {
// todo
break;
}
case GameInfoChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
choices={choices}
width={POP_BOX_WIDTH}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
const enum SyncSaveChoice {
// ----- 主菜单
ToServer,
FromServer,
ToLocal,
FromLocal,
ClearLocal,
Back,
// ----- 子菜单
AllSaves,
NowSave
}
export const SyncSave = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[SyncSaveChoice.ToServer, '同步存档至服务器'],
[SyncSaveChoice.FromServer, '从服务器加载存档'],
[SyncSaveChoice.ToLocal, '存档至本地文件'],
[SyncSaveChoice.FromLocal, '从本地文件读档'],
[SyncSaveChoice.ClearLocal, '清空本地存档'],
[SyncSaveChoice.Back, '返回上一级']
];
const controller: IUIController<IGameUIPropsBase> = props.controller;
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case SyncSaveChoice.ToServer: {
controller.open(SyncSaveSelectUI, { loc: props.loc });
break;
}
case SyncSaveChoice.FromServer: {
const replay = await getInput(
props.controller,
'请输入存档编号+密码',
CENTER_LOC,
POP_BOX_WIDTH
);
await syncFromServer(props.controller, replay);
break;
}
case SyncSaveChoice.ToLocal: {
controller.open(DownloadSaveSelectUI, { loc: props.loc });
break;
}
case SyncSaveChoice.FromLocal: {
// todo
break;
}
case SyncSaveChoice.ClearLocal: {
controller.open(ClearSaveSelectUI, { loc: props.loc });
break;
}
case SyncSaveChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
width={POP_BOX_WIDTH}
choices={choices}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
export const SyncSaveSelect = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[SyncSaveChoice.AllSaves, '同步全部存档'],
[SyncSaveChoice.NowSave, '同步当前存档'],
[SyncSaveChoice.Back, '返回上一级']
];
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case SyncSaveChoice.AllSaves: {
props.state.soundPlayer.play('confirm.opus');
const confirm = await getConfirm(
props.controller,
'你确定要同步全部存档么?这可能在存档较多的时候比较慢。',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
core.syncSave('all');
}
break;
}
case SyncSaveChoice.NowSave: {
props.state.soundPlayer.play('confirm.opus');
const confirm = await getConfirm(
props.controller,
'确定要同步当前存档吗?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
core.syncSave();
}
break;
}
case SyncSaveChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
width={POP_BOX_WIDTH}
choices={choices}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
export const DownloadSaveSelect = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[SyncSaveChoice.AllSaves, '下载全部存档'],
[SyncSaveChoice.NowSave, '下载当前存档'],
[SyncSaveChoice.Back, '返回上一级']
];
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case SyncSaveChoice.AllSaves: {
const confirm = await getConfirm(
props.controller,
'确认要下载所有存档吗?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
const data = await waitbox(
props.controller,
CENTER_LOC,
POP_BOX_WIDTH,
getAllSavesData(),
{ text: '请等待处理完毕' }
);
core.download(
`${core.firstData.name}_${core.formatDate2(
new Date()
)}.h5save`,
data
);
}
break;
}
case SyncSaveChoice.NowSave: {
const confirm = await getConfirm(
props.controller,
'确认要下载当前存档吗?',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
const data = await getSaveData(core.saves.saveIndex);
core.download(
`${core.firstData.name}_${core.formatDate2(
new Date()
)}.h5save`,
data
);
}
break;
}
case SyncSaveChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
width={POP_BOX_WIDTH}
choices={choices}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
export const ClearSaveSelect = defineComponent<MainSettingsProps>(props => {
const choices: ChoiceItem[] = [
[SyncSaveChoice.AllSaves, '清空全部塔存档'],
[SyncSaveChoice.NowSave, '清空当前塔存档'],
[SyncSaveChoice.Back, '返回上一级']
];
const [key, scope] = useKey();
key.realize('exit', () => props.controller.close(props.instance));
const choose = async (key: ChoiceKey) => {
switch (key) {
case SyncSaveChoice.AllSaves: {
const confirm = await getConfirm(
props.controller,
'你确定要清除【全部游戏】的所有本地存档?此行为不可逆!!!',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
await waitbox(
props.controller,
CENTER_LOC,
POP_BOX_WIDTH,
new Promise<void>(res => {
core.clearLocalForage(() => {
core.saves.ids = {};
core.saves.autosave.data = null;
core.saves.autosave.updated = false;
core.saves.autosave.now = 0;
// @ts-expect-error 沙比样板
core.saves.cache = {};
core.saves.saveIndex = 1;
core.saves.favorite = [];
core.saves.favoriteName = {};
// @ts-expect-error 沙比样板
core.control._updateFavoriteSaves();
core.removeLocalStorage('saveIndex');
res();
});
}),
{ text: '正在情况,请稍后...' }
);
await getConfirm(
props.controller,
'所有塔的存档已经全部清空',
CENTER_LOC,
POP_BOX_WIDTH
);
}
break;
}
case SyncSaveChoice.NowSave: {
const confirm = await getConfirm(
props.controller,
'你确定要清除【当前游戏】的所有本地存档?此行为不可逆!!!',
CENTER_LOC,
POP_BOX_WIDTH
);
if (confirm) {
await waitbox(
props.controller,
CENTER_LOC,
POP_BOX_WIDTH,
new Promise<void>(res => {
Object.keys(core.saves.ids).forEach(function (v) {
core.removeLocalForage('save' + v);
});
core.removeLocalForage('autoSave', () => {
core.saves.ids = {};
core.saves.autosave.data = null;
core.saves.autosave.updated = false;
core.saves.autosave.now = 0;
core.ui.closePanel();
core.saves.saveIndex = 1;
core.saves.favorite = [];
core.saves.favoriteName = {};
// @ts-expect-error 沙比样板
core.control._updateFavoriteSaves();
core.removeLocalStorage('saveIndex');
res();
});
}),
{ text: '正在情况,请稍后...' }
);
await getConfirm(
props.controller,
'当前塔的存档已被清空',
CENTER_LOC,
POP_BOX_WIDTH
);
}
break;
}
case SyncSaveChoice.Back: {
props.controller.close(props.instance);
break;
}
}
};
return () => (
<Choices
loc={props.loc}
width={240}
choices={choices}
onChoose={choose}
interval={8}
scope={scope}
/>
);
}, mainSettingsProps);
/** @see {@link MainSettings} */
export const MainSettingsUI = new GameUI('main-settings', MainSettings);
/** @see {@link ReplaySettings} */
export const ReplaySettingsUI = new GameUI('replay-settings', ReplaySettings);
/** @see {@link GameInfo} */
export const GameInfoUI = new GameUI('game-info', GameInfo);
/** @see {@link SyncSave} */
export const SyncSaveUI = new GameUI('sync-save', SyncSave);
/** @see {@link SyncSaveSelect} */
export const SyncSaveSelectUI = new GameUI('sync-save-select', SyncSaveSelect);
/** @see {@link DownloadSaveSelect} */
export const DownloadSaveSelectUI = new GameUI(
'download-save-select',
DownloadSaveSelect
);
/** @see {@link ClearSaveSelect} */
export const ClearSaveSelectUI = new GameUI(
'clear-save-select',
ClearSaveSelect
);
export function openSettings(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: MainSettingsProps
) {
controller.open(MainSettingsUI, {
...props,
loc
});
}
export function openReplay(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: MainSettingsProps
) {
controller.open(ReplaySettingsUI, {
...props,
loc
});
}
+309
View File
@@ -0,0 +1,309 @@
import {
GameUI,
IUIController,
SetupComponentOptions,
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent } from 'vue';
import {
waitbox,
ListPage,
TextContent,
IGameUIPropsBase
} from '@user/client-base';
import { DefaultProps } from '@motajs/render-vue';
// @ts-expect-error 需要重构
import { ItemState } from '@user/data-state';
// 未完工
export interface StatisticsDataOneFloor {
enemyCount: number;
potionCount: number;
gemCount: number;
potionValue: number;
atkValue: number;
defValue: number;
mdefValue: number;
atkGemCount: number;
defGemCount: number;
mdefGemCount: number;
}
export interface StatisticsDataPotionGem {
atkGemCount: number;
defGemCount: number;
mdefGemCount: number;
potionCount: number;
}
export interface StatisticsData {
total: StatisticsDataOneFloor;
floors: Map<FloorIds, StatisticsDataOneFloor>;
potionGem: StatisticsDataPotionGem;
}
export interface StatisticsProps extends IUIDefaultPropsBase, DefaultProps {
data: StatisticsData;
}
const statisticsProps = {
props: ['data', 'controller', 'instance']
} satisfies SetupComponentOptions<StatisticsProps>;
export const Statistics = defineComponent<StatisticsProps>(props => {
const list: [string, string][] = [
['total', '总览'],
['floor', '楼层'],
['enemy', '怪物'],
['potion', '血瓶宝石']
];
const close = () => {
props.controller.close(props.instance);
};
return () => (
<ListPage
list={list}
selected="total"
loc={[180, 0, 480, 480]}
height={470}
close
onClose={close}
lineHeight={24}
closeLoc={[10, 470, void 0, void 0, 0, 1]}
>
{{
total: () => <TotalStatistics data={props.data} />,
floor: () => <FloorStatistics data={props.data} />,
enemy: () => <EnemyStatistics data={props.data} />,
potion: () => <PotionStatistics data={props.data} />
}}
</ListPage>
);
}, statisticsProps);
interface StatisticsPanelProps extends DefaultProps {
data: StatisticsData;
}
const statisticsPanelProps = {
props: ['data']
} satisfies SetupComponentOptions<StatisticsPanelProps>;
const TotalStatistics = defineComponent<StatisticsPanelProps>(props => {
return () => {
const total = props.data.total;
const text1 = `全塔地图中,共有怪物${total.enemyCount}个。`;
const text2 = `共有宝石${total.gemCount}个,共加攻击力${total.atkValue}点、防御力${total.defValue}点,魔防${total.mdefValue}点。`;
const text3 = `共有血瓶${total.potionCount}个,共加生命值${total.potionValue}点。`;
return (
<TextContent
text={`${text1}${text2}${text3}`}
width={330}
loc={[0, 5, 330, 470]}
height={470}
lineHeight={9}
></TextContent>
);
};
}, statisticsPanelProps);
const FloorStatistics = defineComponent<StatisticsPanelProps>(props => {
return () => <container></container>;
}, statisticsPanelProps);
const EnemyStatistics = defineComponent<StatisticsPanelProps>(props => {
return () => <container></container>;
}, statisticsPanelProps);
const PotionStatistics = defineComponent<StatisticsPanelProps>(props => {
return () => {
const gemPotion = props.data.potionGem;
const text1 = `全塔地图中,共有红宝石${gemPotion.atkGemCount}个,共有蓝宝石${gemPotion.defGemCount}个,共有缘宝石${gemPotion.mdefGemCount}个。`;
const text2 = `共有血瓶${gemPotion.potionCount}个。`;
return (
<TextContent
text={`${text1}${text2}`}
width={330}
loc={[0, 5, 330, 470]}
height={470}
lineHeight={9}
></TextContent>
);
};
}, statisticsPanelProps);
export function calculateStatisticsOne(
floorId: FloorIds,
diff?: Map<string, number>
) {
core.setFlag('__statistics__', true);
const hasDiff = !!diff;
if (!hasDiff) {
diff = new Map();
const hero = core.status.hero;
const handler: ProxyHandler<HeroStatus> = {
set(target, p, newValue) {
if (typeof p !== 'string') return true;
if (typeof newValue === 'number') {
const value = diff!.get(p) ?? 0;
const delta =
newValue - (target[p as keyof HeroStatus] as number);
diff!.set(p, value + delta);
}
return true;
}
};
const proxy = new Proxy(hero, handler);
core.status.hero = proxy;
}
core.extractBlocks(floorId);
const statistics: StatisticsDataOneFloor = {
enemyCount: 0,
potionCount: 0,
gemCount: 0,
potionValue: 0,
atkValue: 0,
defValue: 0,
mdefValue: 0,
atkGemCount: 0,
defGemCount: 0,
mdefGemCount: 0
};
if (!diff) return statistics;
core.status.maps[floorId].blocks.forEach(v => {
if (v.event.cls === 'enemys' || v.event.cls === 'enemy48') {
statistics.enemyCount++;
} else if (v.event.cls === 'items') {
const item = ItemState.items.get(v.event.id as AllIdsOf<'items'>);
if (!item) return;
if (item.cls === 'items') {
try {
item.itemEffectFn?.();
} catch {
// pass
}
const hp = diff.get('hp') ?? 0;
const atk = diff.get('atk') ?? 0;
const def = diff.get('def') ?? 0;
const mdef = diff.get('mdef') ?? 0;
if (hp > 0) {
statistics.potionCount++;
statistics.potionValue += hp;
}
if (atk > 0 || def > 0 || mdef > 0) {
statistics.gemCount++;
}
if (atk > 0) {
statistics.atkValue += atk;
statistics.atkGemCount++;
}
if (def > 0) {
statistics.defValue += def;
statistics.defGemCount++;
}
if (mdef > 0) {
statistics.mdefValue += mdef;
statistics.mdefGemCount++;
}
}
}
diff.clear();
});
if (!hasDiff) {
core.status.hero = hero;
window.hero = hero;
window.flags = core.status.hero.flags;
}
core.removeFlag('__statistics__');
return statistics;
}
export function calculateStatistics(): StatisticsData {
core.setFlag('__statistics__', true);
const hero = core.status.hero;
const diff = new Map<string, number>();
const handler: ProxyHandler<HeroStatus> = {
set(target, p, newValue) {
if (typeof p !== 'string') return true;
if (typeof newValue === 'number') {
const value = diff!.get(p) ?? 0;
const delta =
newValue - (target[p as keyof HeroStatus] as number);
diff!.set(p, value + delta);
}
return true;
}
};
const proxy = new Proxy(hero, handler);
core.status.hero = proxy;
const floors = new Map<FloorIds, StatisticsDataOneFloor>();
core.floorIds.forEach(v => {
const statistics = calculateStatisticsOne(v, diff);
floors.set(v, statistics);
});
core.status.hero = hero;
window.hero = hero;
window.flags = core.status.hero.flags;
core.removeFlag('__statistics__');
const total = floors.values().reduce((prev, curr) => {
prev.atkValue += curr.atkValue;
prev.defValue += curr.defValue;
prev.enemyCount += curr.enemyCount;
prev.gemCount += curr.gemCount;
prev.mdefValue += curr.mdefValue;
prev.potionCount += curr.potionCount;
prev.potionValue += curr.potionValue;
return prev;
});
const potionGem = floors.values().reduce((prev, curr) => {
prev.atkGemCount += curr.atkGemCount;
prev.defGemCount += curr.defGemCount;
prev.mdefGemCount += curr.mdefGemCount;
prev.potionCount += curr.potionCount;
return prev;
});
return {
total,
floors,
potionGem
};
}
/**
* 打开数据统计界面
* @param controller 要在哪个 UI 控制器上打开
*/
export async function openStatistics(
controller: IUIController<IGameUIPropsBase>
) {
const cal = Promise.resolve().then<StatisticsData>(() => {
return new Promise(res => {
const data = calculateStatistics();
res(data);
});
});
const data = await waitbox(
controller,
[240 + 180, 240, void 0, void 0, 0.5, 0.5],
240,
cal,
{
text: '正在统计...'
}
);
controller.open(StatisticsUI, { data: data });
}
export const StatisticsUI = new GameUI('statistics', Statistics);
+557
View File
@@ -0,0 +1,557 @@
import { GameUI, SetupComponentOptions } from '@motajs/system';
import {
computed,
ComputedRef,
defineComponent,
onUnmounted,
reactive,
shallowReactive
} from 'vue';
import { TextContent } from '@user/client-base';
import { ElementLocator, Font, ITexture } from '@motajs/render';
import { MixedToolbar, ReplayingStatus } from './toolbar';
import { openViewMap } from './viewmap';
import {
MAIN_HEIGHT,
MAIN_WIDTH,
STATUS_BAR_HEIGHT,
STATUS_BAR_WIDTH
} from './constant';
import { IUIPropsBase } from '@user/client-modules';
import { DefaultProps } from '@motajs/render-vue';
export interface ILeftHeroStatus {
/** 楼层 id */
floor: FloorIds;
/** 等级名称 */
lv: string;
/** 生命值 */
hp: number;
/** 生命上限 */
hpmax: number;
/** 魔力值 */
mana: number;
/** 魔力上限 */
manamax: number;
/** 攻击力 */
atk: number;
/** 防御力 */
def: number;
/** 魔防(护盾) */
mdef: number;
/** 金币 */
money: number;
/** 经验值 */
exp: number;
/** 距离升级剩余经验 */
up: number;
/** 黄钥匙数量 */
yellowKey: number;
/** 蓝钥匙数量 */
blueKey: number;
/** 红钥匙数量 */
redKey: number;
/** 绿钥匙数量 */
greenKey: number;
/** 破数量 */
pickaxe: number;
/** 炸数量 */
bomb: number;
/** 飞数量 */
centerFly: number;
/** 是否中毒 */
poison: boolean;
/** 是否中衰 */
weak: boolean;
/** 是否中咒 */
curse: boolean;
/** 录像状态 */
replay: ReplayingStatus;
}
export interface IRightHeroStatus {
/** 示例属性,以游戏难度作为示例 */
exampleHard: number;
}
interface StatusInfo {
/** 图标 */
readonly icon: ITexture | null;
/** 属性值,经过格式化 */
readonly value: ComputedRef<string>;
/** 字体 */
readonly font: Font;
/** 文字颜色 */
readonly color: CanvasStyle;
}
interface KeyLikeItem {
/** 属性值,经过格式化 */
readonly value: ComputedRef<string>;
/** 字体 */
readonly font: Font;
/** 文字颜色 */
readonly color: CanvasStyle;
}
interface KeyLikeInfo {
/** 这一行包含的内容 */
readonly items: KeyLikeItem[];
}
interface StatusBarProps extends IUIPropsBase, DefaultProps {
loc: ElementLocator;
hidden: boolean;
}
const statusBarProps = {
props: ['loc', 'hidden', 'state']
} satisfies SetupComponentOptions<StatusBarProps>;
export const LeftStatusBar = defineComponent<StatusBarProps>(p => {
//#region 参数定义
/** 属性文字的横坐标 */
const TEXT_X = 54;
/** 图标的横坐标 */
const STATUS_PAD = 8;
/** 楼层名称的高度 */
const TITLE_HEIGHT = 36;
/** 状态属性的开始纵坐标 */
const STATUS_Y = TITLE_HEIGHT + STATUS_PAD;
const materials = p.state.materials;
// 可以换成 materials.getImageByAlias('xxx.png') 来使用全塔属性注册的图片
const hpIcon = materials.getImageByAlias('icon-hp');
const atkIcon = materials.getImageByAlias('icon-atk');
const defIcon = materials.getImageByAlias('icon-def');
const mdefIcon = materials.getImageByAlias('icon-mdef');
const moneyIcon = materials.getImageByAlias('icon-money');
const expIcon = materials.getImageByAlias('icon-exp');
const manaIcon = materials.getImageByAlias('icon-mana');
const lvIcon = materials.getImageByAlias('icon-lv');
const leftStatus: ILeftHeroStatus = reactive({
hp: 0,
hpmax: 0,
mana: 0,
manamax: 0,
atk: 0,
def: 0,
mdef: 0,
money: 0,
exp: 0,
// TODO: 缺少「距升级所需经验」钩子(legacy core.getNextLvUpNeed 无新对应)
up: 0,
// TODO: 缺少道具数量变更钩子(IHeroItems 未实现 IHookable)
yellowKey: 0,
blueKey: 0,
redKey: 0,
greenKey: 0,
pickaxe: 0,
bomb: 0,
centerFly: 0,
// TODO: flags 更新未接(当前无监听系统)
poison: false,
weak: false,
curse: false,
floor: 'MT0',
lv: '',
// TODO: 缺少聚合录像状态钩子(replaySystem 仅提供沙箱级钩子 onCreateSandbox/onStartReplay 等);原值来源 legacy core.status.replay
replay: reactive<ReplayingStatus>({
replaying: false,
playing: false,
speed: 1,
played: 0,
total: 0
})
});
const s = leftStatus;
const refreshAttribute = () => {
s.atk = p.state.hero.attribute.getFinalAttribute('atk');
s.hp = p.state.hero.attribute.getFinalAttribute('hp');
s.hpmax = p.state.hero.attribute.getFinalAttribute('hpmax');
s.mana = p.state.hero.attribute.getFinalAttribute('mana');
s.manamax = p.state.hero.attribute.getFinalAttribute('manamax');
s.def = p.state.hero.attribute.getFinalAttribute('def');
s.mdef = p.state.hero.attribute.getFinalAttribute('mdef');
s.money = p.state.hero.attribute.getFinalAttribute('money');
s.exp = p.state.hero.attribute.getFinalAttribute('exp');
s.lv = core.getLvName(
p.state.hero.attribute.getFinalAttribute('level')
);
};
const attributeHook = p.state.hero.attribute.addHook({
onUpdateAttribute: () => refreshAttribute()
});
const locationHook = p.state.hero.location.addHook({
onSetFloor: map => {
if (map) s.floor = core.status.floorId;
}
});
onUnmounted(() => {
attributeHook.unload();
locationHook.unload();
});
/** 常规字体 */
const font1 = Font.defaults({ size: 18 });
/** 加粗字体 */
const font2 = Font.defaults({ size: 18, weight: 700 });
/** 楼层名 */
const floorName = computed(() => core.floors[s.floor]?.title ?? '');
/** 钥匙显示文字 */
const key = (num: number) => {
return num.toString().padStart(2, '0');
};
//#region 属性显示
/** 一般属性 */
const statusInfo: StatusInfo[] = shallowReactive([]);
/** 钥匙属性 */
const keyLike: KeyLikeInfo[] = shallowReactive([]);
// 根据全塔属性配置显示属性
// 如果你想修改状态栏显示,不建议修改这里的内容,而建议直接新增标签
// 这里的内容是为使用样板默认状态栏的人准备的,对于新增属性来说过于复杂,而且功能有限,对于新手来说也难以理解
// 可以参考说明文档中的常见需求指南中的新增状态教程
const list = core.flags.statusBarItems;
// 等级
if (list.includes('enableLv')) {
statusInfo.push({
icon: lvIcon,
value: computed(() => s.lv),
font: font1,
color: '#fff'
});
}
// 生命值
if (list.includes('enableHP')) {
if (list.includes('enableHPMax')) {
// 如果启用血限
statusInfo.push({
icon: hpIcon,
value: computed(() => {
const hp = core.formatBigNumber(s.hp);
const hpmax = core.formatBigNumber(s.hpmax);
return `${hp} / ${hpmax}`;
}),
font: font1,
color: '#fff'
});
} else {
// 如果禁用血限
statusInfo.push({
icon: hpIcon,
value: computed(() => core.formatBigNumber(s.hp)),
font: font1,
color: '#fff'
});
}
}
// 魔力
if (list.includes('enableMana')) {
statusInfo.push({
icon: manaIcon,
value: computed(() => {
const mana = core.formatBigNumber(s.mana);
const manamax = core.formatBigNumber(s.manamax);
if (s.manamax > 0) {
// 如果启用魔力上限
return `${mana} / ${manamax}`;
} else {
// 如果禁用魔力上限
return mana;
}
}),
font: font1,
color: '#fff'
});
}
// 攻击力
if (list.includes('enableAtk')) {
statusInfo.push({
icon: atkIcon,
value: computed(() => core.formatBigNumber(s.atk)),
font: font1,
color: '#fff'
});
}
// 防御力
if (list.includes('enableDef')) {
statusInfo.push({
icon: defIcon,
value: computed(() => core.formatBigNumber(s.def)),
font: font1,
color: '#fff'
});
}
// 魔防(护盾)
if (list.includes('enableMdef')) {
statusInfo.push({
icon: mdefIcon,
value: computed(() => core.formatBigNumber(s.mdef)),
font: font1,
color: '#fff'
});
}
// 金币
if (list.includes('enableMoney')) {
statusInfo.push({
icon: moneyIcon,
value: computed(() => core.formatBigNumber(s.money)),
font: font1,
color: '#fff'
});
}
// 经验值
if (list.includes('enableExp')) {
if (list.includes('enableLevelUp')) {
// 升级模式
statusInfo.push({
icon: expIcon,
value: computed(() => core.formatBigNumber(s.up)),
font: font1,
color: '#fff'
});
} else {
// 非升级模式
statusInfo.push({
icon: expIcon,
value: computed(() => core.formatBigNumber(s.exp)),
font: font1,
color: '#fff'
});
}
}
// 钥匙
if (list.includes('enableKeys')) {
const keys: KeyLikeItem[] = [];
keyLike.push({ items: keys });
// 黄钥匙
keys.push({
value: computed(() => key(s.yellowKey)),
font: font2,
color: '#fca'
});
// 蓝钥匙
keys.push({
value: computed(() => key(s.blueKey)),
font: font2,
color: '#aad'
});
// 红钥匙
keys.push({
value: computed(() => key(s.redKey)),
font: font2,
color: '#f88'
});
// 绿钥匙
if (list.includes('enableGreenKey')) {
keys.push({
value: computed(() => key(s.greenKey)),
font: font2,
color: '#8f8'
});
}
}
// 破炸飞
if (list.includes('enablePZF')) {
const items: KeyLikeItem[] = [];
keyLike.push({ items });
items.push({
value: computed(() => `破 ${s.pickaxe}`),
font: font1,
color: '#bc6e27'
});
items.push({
value: computed(() => `炸 ${s.bomb}`),
font: font1,
color: '#fa14b9'
});
items.push({
value: computed(() => `飞 ${s.centerFly}`),
font: font1,
color: '#8db600'
});
}
// 毒衰咒
if (list.includes('enableDebuff')) {
const debuffs: KeyLikeItem[] = [];
keyLike.push({ items: debuffs });
debuffs.push({
value: computed(() => (s.poison ? '毒' : '')),
font: font1,
color: '#affca8'
});
debuffs.push({
value: computed(() => (s.weak ? '衰' : '')),
font: font1,
color: '#feccd0'
});
debuffs.push({
value: computed(() => (s.curse ? '咒' : '')),
font: font1,
color: '#c2f4e7'
});
}
//#region 布局控制
/** 用于显示状态的高度,高度=状态栏高度-工具栏高度-楼层名高度-填充高度 */
const statusHeight =
STATUS_BAR_HEIGHT - 113 - TITLE_HEIGHT - STATUS_PAD * 2;
/** 每一行的高度 */
const rowHeight = computed(() => {
const length = statusInfo.length + keyLike.length;
return statusHeight / length;
});
/** 钥匙、破炸飞、毒衰咒开始显示的纵坐标 */
const keyStart = computed(() => {
const statusHeight = statusInfo.length * rowHeight.value;
return STATUS_Y + statusHeight;
});
/**
* 左右居中布局
* @param y 纵坐标
*/
const central = (y: number): ElementLocator => {
const width = p.loc[2] ?? 200;
return [width / 2, y, void 0, void 0, 0.5, 0.5];
};
/**
* 点击楼层名打开浏览地图
*/
const viewMap = () => {
openViewMap(p.state.mainUIController, [0, 0, MAIN_WIDTH, MAIN_HEIGHT]);
};
return () => (
<container loc={p.loc} hidden={p.hidden}>
<text
text={floorName.value}
loc={central(18)}
font={font1}
cursor="pointer"
onClick={viewMap}
></text>
<g-line
lineWidth={1}
strokeStyle="#888"
line={[0, TITLE_HEIGHT, STATUS_BAR_WIDTH, TITLE_HEIGHT]}
/>
{statusInfo
.map((v, i) => {
const h = rowHeight.value;
const y = STATUS_Y + i * h;
const cy = y + h / 2;
const iconSize = Math.min(32, h * 0.8);
const pad = (h - iconSize) / 2;
return [
<image
loc={[STATUS_PAD, y + pad, iconSize, iconSize]}
image={v.icon}
noanti // 取消抗锯齿
/>,
<text
loc={[TEXT_X, cy]}
anc={[0, 0.5]}
text={v.value.value}
fillStyle={v.color}
font={v.font}
/>
];
})
.flat()}
{keyLike
.map(({ items }, i) => {
const h = rowHeight.value;
const y = keyStart.value + i * h;
const cy = y + h / 2;
const rw = STATUS_BAR_WIDTH / (items.length + 1);
return items.map((v, i) => {
const x = rw * (i + 1);
return (
<text
loc={[x, cy]}
anc={[0.5, 0.5]}
text={v.value.value}
font={v.font}
fillStyle={v.color}
/>
);
});
})
.flat()}
<g-line
lineWidth={1}
strokeStyle="#888"
line={[
0,
MAIN_HEIGHT - 113,
STATUS_BAR_WIDTH,
MAIN_HEIGHT - 113
]}
/>
<MixedToolbar
loc={[0, MAIN_HEIGHT - 113, STATUS_BAR_WIDTH, 113]}
status={s.replay}
state={p.state}
/>
</container>
);
}, statusBarProps);
export const RightStatusBar = defineComponent<StatusBarProps>(p => {
// p.status 就是你在 main.tsx 中传入的属性内容,用法与左侧状态栏完全一致
// TODO: flags 更新未接(当前无监听系统)
const rightStatus: IRightHeroStatus = reactive({ exampleHard: 0 });
const text = `这里是右侧状态栏,如果左侧状态栏不够用可以在 \\r[gold]statusBar.tsx\\r 中编写内容,如果不需要此状态栏,可以在 \\r[gold]shared.ts\\r 中关闭此状态栏。`;
return () => {
return (
<container loc={p.loc} hidden={p.hidden}>
<TextContent
loc={[8, 8]}
text={text}
width={STATUS_BAR_WIDTH - 16}
autoHeight
lineHeight={8}
/>
<text loc={[8, 270]} text="示例内容" />
<text
loc={[8, 300]}
text={`游戏难度:${rightStatus.exampleHard}`}
/>
</container>
);
};
}, statusBarProps);
export const leftStatusBarUI = new GameUI('left-status-bar', LeftStatusBar);
export const rightStatusBarUI = new GameUI('right-status-bar', RightStatusBar);
+492
View File
@@ -0,0 +1,492 @@
import { DefaultProps } from '@motajs/render-vue';
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { defineComponent, nextTick, onMounted, ref } from 'vue';
import { HALF_HEIGHT, HALF_WIDTH, MAIN_HEIGHT, MAIN_WIDTH } from './constant';
import { ElementLocator, Font } from '@motajs/render';
import {
ExitFullscreen,
Fullscreen,
SoundVolume,
triggerFullscreen
} from '@user/client-base';
import { saveLoad } from './save';
import { MainSceneUI } from './main';
import { adjustCover } from '@user/client-base';
import { cosh, CurveMode, linear } from '@motajs/animate';
import { sleep } from '@motajs/common';
import {
ITransitionedController,
IUIPropsBase,
transitioned,
transitionedColor,
useKey
} from '@user/client-modules';
/** 标题图 */
export const TITLE_BACKGROUND_IMAGE = 'bg.jpg';
/** 标题文字中心横坐标 */
export const TITLE_X = HALF_WIDTH;
/** 标题文字中心纵坐标 */
export const TITLE_Y = 100;
/** 标题文字的填充颜色 */
export const TITLE_FILL = 'white';
/** 标题文字的描边颜色 */
export const TITLE_STROKE = 'black';
/** 标题文字的描边宽度 */
export const TITLE_STROKE_WIDTH = 2;
/** 标题界面按钮宽度,如果文字被裁剪可以考虑扩大此值 */
export const BUTTONS_WIDTH = 160;
/** 标题界面按钮高度,如果文字被裁剪可以考虑扩大此值 */
export const BUTTONS_HEIGHT = 200;
/** 标题界面按钮中心横坐标 */
export const BUTTONS_X = HALF_WIDTH;
/** 标题界面按钮左上角纵坐标 */
export const BUTTONS_Y = MAIN_HEIGHT - BUTTONS_HEIGHT;
const enum TitleButton {
StartGame,
LoadGame,
Replay
}
interface ButtonItem {
code: TitleButton;
name: string;
color: string;
}
interface ButtonOption {
code: number;
color: string;
name: string;
hard: string;
colorTrans: ITransitionedController<string>;
}
export interface GameTitleProps extends DefaultProps, IUIPropsBase {}
const gameTitleProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<GameTitleProps>;
export const GameTitle = defineComponent<GameTitleProps>(props => {
const materials = props.state.materials;
const bg = materials.getImageByAlias(TITLE_BACKGROUND_IMAGE);
//#region 计算背景图
const [width, height] = adjustCover(
bg?.width ?? MAIN_WIDTH,
bg?.height ?? MAIN_HEIGHT,
MAIN_WIDTH,
MAIN_HEIGHT
);
//#region 标题设置
/** 当前是否全屏 */
const fullscreen = ref(!!document.fullscreenElement);
/** 是否开启了声音 */
const soundOpened = ref(true);
/** 是否在选择难度界面 */
const selectHard = ref(false);
/** 开始界面按钮定义,你可以在这新增自己的按钮 */
const buttonItems: ButtonItem[] = [
{
code: TitleButton.StartGame,
color: 'rgb(40, 194, 255)',
name: '开始游戏'
},
{
code: TitleButton.LoadGame,
color: 'rgb(0, 255, 55)',
name: '读取存档'
},
{
code: TitleButton.Replay,
color: 'rgb(255, 251, 0)',
name: '录像回放'
}
];
/** 开始界面按钮 */
const buttons = buttonItems.map<ButtonOption>(v => {
return {
code: v.code,
color: v.color,
name: v.name,
hard: '',
colorTrans: transitionedColor(
'#fff',
400,
cosh(2, CurveMode.EaseOut)
)!,
scale: transitioned(1, 400, cosh(2, CurveMode.EaseOut))!
};
});
/** 选择难度界面按钮 */
const hard = main.levelChoose.map<ButtonOption>(v => {
return {
code: v.hard,
color: core.arrayToRGBA(v.color!),
name: v.title,
hard: v.name,
colorTrans: transitionedColor(
'#fff',
400,
cosh(2, CurveMode.EaseOut)
)!,
scale: transitioned(1, 400, cosh(2, CurveMode.EaseOut))!
};
});
// 返回按钮
hard.push({
code: -1,
color: '#aaa',
name: '返回',
hard: '',
colorTrans: transitionedColor('#fff', 400, cosh(2, CurveMode.EaseOut))!
});
/** 声音设置按钮的颜色 */
const soundColor = transitionedColor(
'#ddd',
400,
cosh(2, CurveMode.EaseOut)
)!;
/** 开始界面按钮的不透明度,选择难度界面的不透明度使用 `1-buttonsAlpha` 计算 */
const buttonsAlpha = transitioned(1, 300, linear())!;
/** 开始界面的不透明度 */
const mainAlpha = transitioned(0, 600, linear())!;
const buttonFilter = `
drop-shadow(3px 3px 5px rgba(0, 0, 0, 0.4))
drop-shadow(0px 0px 2px rgba(255, 255, 255, 0.5))
`;
const titleFont = Font.defaults({ size: 72 });
const buttonFont = Font.defaults({ size: 24, weight: 600 });
/** 开始界面按钮的高度 */
const buttonHeight = (buttons.length - 1) * 40 + 60;
/** 选择难度界面按钮的高度 */
const hardHeight = (hard.length - 1) * 40 + 60;
/** 按钮的背景框高度 */
const rectHeight = transitioned(
buttonHeight,
600,
cosh(2, CurveMode.EaseOut)
)!;
//#region 按钮功能
/**
* 在开始界面和选择难度界面切换
*/
const toggleHard = async () => {
if (selectHard.value) {
// 当前在难度界面,将要切换至开始界面
enterMain(0);
rectHeight.set(buttonHeight);
} else {
// 当前在开始界面,将要切换至难度界面
enterHard(0);
rectHeight.set(hardHeight);
}
buttonsAlpha.set(0);
await sleep(300);
selectHard.value = !selectHard.value;
buttonsAlpha.set(1);
};
/**
* 点击读取存档按钮
*/
const loadGame = async () => {
const loc: ElementLocator = [0, 0, MAIN_WIDTH, MAIN_HEIGHT];
const success = await saveLoad(props.controller, loc);
if (success) {
props.controller.close(props.instance);
props.controller.open(MainSceneUI, {});
}
};
/**
* 点击录像回放按钮
*/
const replay = () => {
core.chooseReplayFile();
};
/**
* 选择难度并开始游戏
* @param hard 选择的难度
*/
const startGame = async (hard: string) => {
mainAlpha.set(0);
await sleep(600);
props.controller.close(props.instance);
props.controller.open(MainSceneUI, {});
nextTick(() => {
core.startGame(hard);
});
};
/**
* 点击按钮时触发
* @param code 选中的按钮的代码
*/
const clickButton = (code: number, index: number) => {
if (selectHard.value) {
if (index === hard.length - 1) {
// 选中了最后一个按钮
toggleHard();
return;
}
const item = hard[index];
startGame(item.name);
} else {
switch (code) {
case TitleButton.StartGame:
toggleHard();
break;
case TitleButton.LoadGame:
loadGame();
break;
case TitleButton.Replay:
replay();
break;
}
}
};
//#region 键盘操作
const selected = ref(0);
const [key] = useKey();
key.realize(
'@start_up',
() => {
selected.value--;
if (selected.value < 0) {
selected.value = 0;
}
if (selectHard.value) {
enterHard(selected.value);
} else {
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize(
'@start_down',
() => {
selected.value++;
if (selectHard.value) {
if (selected.value > hard.length - 1) {
selected.value = hard.length - 1;
}
enterHard(selected.value);
} else {
if (selected.value > buttons.length - 1) {
selected.value = buttons.length - 1;
}
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize('confirm', () => {
if (selectHard.value) {
clickButton(hard[selected.value].code, selected.value);
} else {
clickButton(buttons[selected.value].code, selected.value);
}
});
//#region 鼠标操作
soundOpened.value = mainSetting.getValue('audio.bgmEnabled', true);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
const enterMain = (index: number) => {
buttons.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
} else {
v.colorTrans.set(v.color);
}
});
selected.value = index;
};
const enterHard = (index: number) => {
hard.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
} else {
v.colorTrans.set(v.color);
}
});
selected.value = index;
};
const toggleSound = () => {
soundOpened.value = !soundOpened.value;
mainSetting.setValue('audio.bgmEnabled', soundOpened.value);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
};
const toggleFullscreen = async () => {
await triggerFullscreen(!fullscreen.value);
fullscreen.value = !!document.fullscreenElement;
};
onMounted(() => {
enterMain(0);
mainAlpha.set(1);
});
return () => (
<container
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
alpha={mainAlpha.ref.value}
>
<image
image={bg}
loc={[HALF_WIDTH, HALF_HEIGHT, width, height]}
anc={[0.5, 0.5]}
zIndex={0}
/>
<text
text={core.firstData.title}
loc={[TITLE_X, TITLE_Y]}
anc={[0.5, 0.5]}
fillStyle={TITLE_FILL}
strokeStyle={TITLE_STROKE}
font={titleFont}
strokeWidth={TITLE_STROKE_WIDTH}
/>
<container
zIndex={15}
loc={[BUTTONS_X, BUTTONS_Y, BUTTONS_WIDTH, BUTTONS_HEIGHT]}
anc={[0.5, 0]}
>
<g-rectr
loc={[2, BUTTONS_HEIGHT / 2]}
width={BUTTONS_WIDTH - 4}
height={rectHeight.ref.value - 4}
anc={[0, 0.5]}
circle={[16]}
fill
stroke
fillStyle="rgba(50, 54, 159, 0.7)"
strokeStyle="rgba(255, 204, 0, 1)"
lineWidth={3}
zIndex={0}
/>
<container
hidden={selectHard.value}
loc={[0, BUTTONS_HEIGHT / 2, BUTTONS_WIDTH, buttonHeight]}
anc={[0, 0.5]}
alpha={buttonsAlpha.ref.value}
zIndex={5}
>
{buttons.map((v, i) => {
const x = BUTTONS_WIDTH / 2;
const y = 30 + i * 40;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0.5, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
onEnter={() => enterMain(i)}
onClick={() => clickButton(v.code, i)}
/>
);
})}
</container>
<container
hidden={!selectHard.value}
loc={[0, BUTTONS_HEIGHT / 2, BUTTONS_WIDTH, hardHeight]}
anc={[0, 0.5]}
alpha={buttonsAlpha.ref.value}
>
{hard.map((v, i) => {
const x = BUTTONS_WIDTH / 2;
const y = 30 + i * 40;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0.5, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
onEnter={() => enterHard(i)}
onClick={() => clickButton(v.code, i)}
/>
);
})}
</container>
</container>
<container
zIndex={15}
loc={[MAIN_WIDTH - 20, MAIN_HEIGHT - 20, 120, 40, 1, 1]}
>
<g-rectr
loc={[0, 0, 120, 40]}
circle={[20]}
fillStyle="rgba(0, 0, 0, 0.72)"
/>
<SoundVolume
loc={[20, 0, 40, 40]}
cursor="pointer"
strokeStyle={soundColor.ref.value}
onClick={toggleSound}
/>
{!fullscreen.value ? (
<Fullscreen
loc={[60, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
) : (
<ExitFullscreen
loc={[60, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
)}
<g-line
line={[25, 35, 55, 5]}
strokeStyle="gray"
lineWidth={3}
lineCap="round"
zIndex={5}
noevent
hidden={soundOpened.value}
/>
</container>
</container>
);
}, gameTitleProps);
export const GameTitleUI = new GameUI('game-title', GameTitle);
+412
View File
@@ -0,0 +1,412 @@
import { ElementLocator, Font } from '@motajs/render';
import { computed, defineComponent, ref } from 'vue';
import {
DoubleArrow,
NumpadIcon,
PauseIcon,
PlayIcon,
ReplayIcon,
RetweetIcon,
RollbackIcon,
StepForward,
ViewMapIcon
} from '@user/client-base';
import { gameKey, generateKeyboardEvent } from '@motajs/system';
import { linear } from 'mutate-animate';
import { KeyCode } from '@motajs/client-base';
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 './constant';
import { openReplay, openSettings } from './settings';
import { openViewMap } from './viewmap';
import { DefaultProps } from '@motajs/render-vue';
import { IClientCoreExtended, transitioned } from '@user/client-modules';
interface ToolbarProps extends DefaultProps, IClientCoreExtended {
loc?: ElementLocator;
}
type ToolbarEmits = {
numpad: () => void;
};
const toolbarProps = {
props: ['loc', 'state'],
emits: ['numpad']
} satisfies SetupComponentOptions<
ToolbarProps,
ToolbarEmits,
keyof ToolbarEmits
>;
const im = (col: number, row: number): ElementLocator => {
return [5 + 34 * col, 5 + 36 * row, 32, 32];
};
const ic = (col: number, row: number): ElementLocator => {
return [7 + 34 * col, 7 + 36 * row, 28, 28];
};
const ic2 = (col: number, row: number): ElementLocator => {
return [9 + 34 * col, 9 + 36 * row, 24, 24];
};
const middle = (col: number, row: number): ElementLocator => {
return [21 + 34 * col, 21 + 36 * row, void 0, void 0, 0.5, 0.5];
};
const middle2 = (
col: number,
row: number,
width: number,
height: number
): ElementLocator => {
return [21 + 34 * col, 21 + 36 * row, width, height, 0.5, 0.5];
};
export const PlayingToolbar = defineComponent<
ToolbarProps,
ToolbarEmits,
keyof ToolbarEmits
>((props, { emit }) => {
const materials = props.state.materials;
const bookIcon = materials.getImageByAlias('icon-book');
const flyIcon = materials.getImageByAlias('icon-fly');
const toolIcon = materials.getImageByAlias('icon-toolbox');
const equipIcon = materials.getImageByAlias('icon-equipbox');
const keyIcon = materials.getImageByAlias('icon-keyboard');
const shopIcon = materials.getImageByAlias('icon-shop');
const saveIcon = materials.getImageByAlias('icon-save');
const loadIcon = materials.getImageByAlias('icon-load');
const setIcon = materials.getImageByAlias('icon-settings');
const iconFont = new Font('Verdana', 12);
const book = () => core.openBook(true);
const tool = () => core.openToolbox(true);
const fly = () => core.useFly(true);
const save = () => {
saveSave(props.state.mainUIController, FULL_LOC);
};
const load = () => {
saveLoad(props.state.mainUIController, FULL_LOC);
};
const equip = () => core.openEquipbox(true);
const shop = () => core.openQuickShop(true);
const key = () => {
getVitualKeyOnce().then(value => {
gameKey.emitKey(
value.key,
value.assist,
'up',
generateKeyboardEvent(value.key, value.assist)
);
});
};
const undo = () => core.doSL('autoSave', 'load');
const redo = () => core.doSL('autoSave', 'reload');
const numpad = () => emit('numpad');
const view = () => {
openViewMap(props.state.mainUIController, FULL_LOC);
};
const replay = () => {
const loc = CENTER_LOC.slice() as ElementLocator;
loc[2] = POP_BOX_WIDTH;
openReplay(props.state.mainUIController, loc);
};
const settings = () => {
const loc = CENTER_LOC.slice() as ElementLocator;
loc[2] = POP_BOX_WIDTH;
loc[3] = MAIN_HEIGHT - 72;
openSettings(props.state.mainUIController, loc);
};
return () => (
<container loc={props.loc} cursor="pointer">
<image image={bookIcon} loc={im(0, 0)} noanti onClick={book} />
<image image={toolIcon} loc={im(1, 0)} noanti onClick={tool} />
<image image={flyIcon} loc={im(2, 0)} noanti onClick={fly} />
<image image={saveIcon} loc={im(3, 0)} noanti onClick={save} />
<image image={loadIcon} loc={im(4, 0)} noanti onClick={load} />
<image image={equipIcon} loc={im(0, 1)} noanti onClick={equip} />
<image image={shopIcon} loc={im(1, 1)} noanti onClick={shop} />
<image image={keyIcon} loc={im(2, 1)} noanti onClick={key} />
<RollbackIcon loc={ic(3, 1)} strokeStyle="#eee" onClick={undo} />
<RetweetIcon loc={ic(4, 1)} strokeStyle="#eee" onClick={redo} />
<NumpadIcon loc={ic(0, 2)} strokeStyle="#eee" onClick={numpad} />
<ViewMapIcon loc={ic(1, 2)} strokeStyle="#eee" onClick={view} />
<ReplayIcon loc={ic(3, 2)} strokeStyle="#eee" onClick={replay} />
<text text="R" loc={middle(3, 2)} font={iconFont} noevent />
<image image={setIcon} loc={im(4, 2)} noanti onClick={settings} />
</container>
);
}, toolbarProps);
export interface ReplayingStatus {
/** 是否处在录像播放状态 */
replaying: boolean;
/** 是否正在播放 */
playing: boolean;
/** 录像播放速度 */
speed: number;
/** 已播放的长度 */
played: number;
/** 总长度 */
total: number;
}
export interface ReplayingProps extends ToolbarProps {
/** 录像播放状态 */
status: ReplayingStatus;
}
const replayingProps = {
props: ['status', 'loc', 'state']
} satisfies SetupComponentOptions<ReplayingProps>;
export const ReplayingToolbar = defineComponent<ReplayingProps>(props => {
const status = props.status;
const materials = props.state.materials;
const bookIcon = materials.getImageByAlias('icon-book');
const saveIcon = materials.getImageByAlias('icon-save');
const font1 = Font.defaults({ size: 16 });
const font2 = new Font('Verdana', 12);
const speedText = computed(() => `${status.speed}速`);
const progress = computed(() => status.played / status.total);
const progressText1 = computed(() => `${status.played}/${status.total}`);
const progressText2 = computed(
() => `${(progress.value * 100).toFixed(2)}%`
);
const play = () => core.resumeReplay();
const pause = () => core.pauseReplay();
const stop = () => core.stopReplay(true);
const speedDown = () => core.speedDownReplay();
const speedUp = () => core.speedUpReplay();
const book = () => core.openBook(true);
const save = () => {
saveSave(props.state.mainUIController, FULL_LOC);
};
const view = () => {
if (core.isPlaying() && !core.isMoving() && !core.status.lockControl) {
core.ui._drawViewMaps();
}
};
const rewind = () => core.rewindReplay();
const step = () => core.stepReplay();
return () => {
return (
<container loc={props.loc} cursor="pointer">
{status.playing ? (
<PauseIcon loc={ic(0, 0)} onClick={pause} />
) : (
<PlayIcon loc={ic(0, 0)} onClick={play} />
)}
<g-rectr loc={[47, 13, 16, 16]} circle={[2]} onClick={stop} />
<DoubleArrow
loc={middle2(2, 0, 28, 28)}
scale={[-1, 1]}
onClick={speedDown}
/>
<text text={speedText.value} loc={middle(3, 0)} font={font1} />
<DoubleArrow loc={ic(4, 0)} onClick={speedUp} />
<image image={bookIcon} loc={im(0, 1)} noanti onClick={book} />
<image image={saveIcon} loc={im(1, 1)} noanti onClick={save} />
<ViewMapIcon loc={ic(2, 1)} onClick={view} />
<StepForward
loc={middle2(3, 1, 28, 28)}
scale={[-1, 1]}
onClick={rewind}
/>
<StepForward loc={ic(4, 1)} onClick={step} />
<text
text={progressText1.value}
loc={[12, 98, void 0, void 0, 0, 1]}
font={font2}
/>
<text
text={progressText2.value}
loc={[168, 98, void 0, void 0, 1, 1]}
font={font2}
/>
<Progress
loc={[12, 101, 156, 4]}
progress={progress.value}
success="lightgreen"
/>
</container>
);
};
}, replayingProps);
export const NumpadToolbar = defineComponent<
ToolbarProps,
ToolbarEmits,
keyof ToolbarEmits
>((props, { emit }) => {
const numpad = () => emit('numpad');
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const font = new Font('Verdana', 14);
const ctrlEnabled = ref(false);
const shiftEnabled = ref(false);
const altEnabled = ref(false);
const ctrlAlpha = transitioned(0, 100, linear())!;
const shiftAlpha = transitioned(0, 100, linear())!;
const altAlpha = transitioned(0, 100, linear())!;
const ctrlColor = computed(
() => `rgba(221,221,221,${ctrlAlpha.ref.value})`
);
const ctrlTextColor = computed(() => {
const rgb = Math.floor(255 - ctrlAlpha.ref.value * 255);
return `rgba(${rgb},${rgb},${rgb},1)`;
});
const shiftColor = computed(
() => `rgba(221,221,221,${shiftAlpha.ref.value})`
);
const shiftTextColor = computed(() => {
const rgb = Math.floor(255 - shiftAlpha.ref.value * 255);
return `rgba(${rgb},${rgb},${rgb},1)`;
});
const altColor = computed(() => `rgba(221,221,221,${altAlpha.ref.value})`);
const altTextColor = computed(() => {
const rgb = Math.floor(255 - altAlpha.ref.value * 255);
return `rgba(${rgb},${rgb},${rgb},1)`;
});
const clickCtrl = () => {
ctrlEnabled.value = !ctrlEnabled.value;
ctrlAlpha.set(ctrlEnabled.value ? 1 : 0);
};
const clickShift = () => {
shiftEnabled.value = !shiftEnabled.value;
shiftAlpha.set(shiftEnabled.value ? 1 : 0);
};
const clickAlt = () => {
altEnabled.value = !altEnabled.value;
altAlpha.set(altEnabled.value ? 1 : 0);
};
const clickNum = (num: number) => {
const bin = generateBinary([
ctrlEnabled.value,
shiftEnabled.value,
altEnabled.value
]);
const code = (KeyCode.Digit0 + num) as KeyCode;
gameKey.emitKey(code, bin, 'up', generateKeyboardEvent(code, bin));
};
return () => (
<container loc={props.loc} cursor="pointer">
<container loc={[0, 0, 180, 81]}>
{nums
.map((v, i) => {
const col = i % 5;
const row = Math.floor(i / 5);
return [
<g-rectr
loc={ic2(col, row)}
circle={[4]}
stroke
onClick={() => clickNum(v)}
/>,
<text
text={v.toString()}
loc={middle(col, row)}
font={font}
noevent
/>
];
})
.flat()}
</container>
<g-rectr
loc={[41, 81, 36, 24]}
circle={[4]}
stroke
fill
fillStyle={ctrlColor.value}
onClick={clickCtrl}
></g-rectr>
<text
text="Ctrl"
loc={[59, 93, void 0, void 0, 0.5, 0.5]}
fillStyle={ctrlTextColor.value}
font={font}
noevent
/>
<g-rectr
loc={[86, 81, 44, 24]}
circle={[4]}
stroke
fill
fillStyle={shiftColor.value}
onClick={clickShift}
></g-rectr>
<text
text="Shift"
loc={[108, 93, void 0, void 0, 0.5, 0.5]}
fillStyle={shiftTextColor.value}
font={font}
noevent
/>
<g-rectr
loc={[139, 81, 30, 24]}
circle={[4]}
stroke
fill
fillStyle={altColor.value}
onClick={clickAlt}
></g-rectr>
<text
text="Alt"
loc={[154, 93, void 0, void 0, 0.5, 0.5]}
fillStyle={altTextColor.value}
font={font}
noevent
/>
<NumpadIcon loc={ic(0, 2)} strokeStyle="gold" onClick={numpad} />
</container>
);
}, toolbarProps);
export const MixedToolbar = defineComponent<ReplayingProps>(props => {
const inNumpad = ref(false);
const onNumpad = () => {
inNumpad.value = !inNumpad.value;
};
return () =>
inNumpad.value ? (
<NumpadToolbar
loc={props.loc}
onNumpad={onNumpad}
state={props.state}
/>
) : props.status.replaying ? (
<ReplayingToolbar
loc={props.loc}
status={props.status}
state={props.state}
/>
) : (
<PlayingToolbar
loc={props.loc}
onNumpad={onNumpad}
state={props.state}
/>
);
}, replayingProps);
+278
View File
@@ -0,0 +1,278 @@
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { computed, defineComponent, ref } from 'vue';
import { Font } from '@motajs/render';
import { Scroll, TextContent } 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 './constant';
import { IUIPropsBase } from '@user/client-modules';
export interface ToolBoxProps extends IUIPropsBase {
/** 勇士状态对象 */
heroState: IHeroState<IHeroAttr>;
}
/** 道具栏组件的 props 声明 */
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>) => {
if (selected.value === item.num) {
hero.items.useItem(item.num);
return;
}
selected.value = item.num;
};
/** 关闭当前道具栏界面 */
const exit = () => props.controller.close(props.instance);
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 ? 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 && (
<text
text="未选择道具"
loc={[8, 4]}
anc={[0, 0]}
font={descFont}
fillStyle="#888888"
/>
)}
{current && (
<TextContent
text={current.raw.text}
width={COLUMN_WIDTH - 16}
loc={[8, 4, COLUMN_WIDTH - 16, DESC_HEIGHT - 8]}
font={descFont}
fillStyle="#dddddd"
/>
)}
</Scroll>
{/* 右下角退出按钮 */}
<text
text="退出"
loc={[MAIN_WIDTH - 12, MAIN_HEIGHT - 10]}
anc={[1, 1]}
font={titleFont}
cursor="pointer"
fillStyle="#ffffff"
onClick={exit}
/>
</container>
);
};
}, toolBoxProps);
/** 道具栏的 UI 控制器 */
export const ToolBoxUI = new GameUI('toolbox', ToolBox);
+568
View File
@@ -0,0 +1,568 @@
import {
ElementLocator,
IActionEvent,
IActionEventBase,
IWheelEvent,
MotaOffscreenCanvas2D,
Font
} from '@motajs/render';
import { BaseProps } from '@motajs/render-vue';
import {
GameUI,
IUIController,
SetupComponentOptions,
IUIDefaultPropsBase
} from '@motajs/system';
import {
computed,
defineComponent,
markRaw,
onMounted,
onUnmounted,
ref,
shallowRef,
watch
} from 'vue';
import { FloorSelector } from '@user/client-base';
import { clamp, mean } from 'lodash-es';
import { StatisticsDataOneFloor } from './statistics';
import { Tip, TipExpose, IGameUIPropsBase } from '@user/client-base';
import {
ENABLE_RIGHT_STATUS_BAR,
FULL_LOC,
HALF_MAP_WIDTH,
HALF_STATUS_WIDTH,
MAIN_HEIGHT,
MAP_HEIGHT,
MAP_WIDTH,
RIGHT_STATUS_POS,
STATUS_BAR_HEIGHT,
STATUS_BAR_WIDTH
} from './constant';
import { useKey } from '@user/client-modules';
export interface ViewMapProps extends IUIDefaultPropsBase, BaseProps {
loc: ElementLocator;
floorId?: FloorIds;
}
const viewMapProps = {
props: ['loc', 'floorId', 'controller', 'instance']
} satisfies SetupComponentOptions<ViewMapProps>;
export const ViewMap = defineComponent<ViewMapProps>(props => {
const nowFloorId = core.status.floorId;
// const layerGroupExtends: ILayerGroupRenderExtends[] = [
// new FloorDamageExtends(),
// new FloorItemDetail(),
// new LayerGroupAnimate()
// ];
const restHeight = STATUS_BAR_HEIGHT - 292;
const col = restHeight / 4;
const loc1: ElementLocator = [HALF_STATUS_WIDTH, col * 1 + 292];
const loc2: ElementLocator = [HALF_STATUS_WIDTH, col * 2 + 292];
const loc3: ElementLocator = [HALF_STATUS_WIDTH, col * 3 + 292];
const rightFont = new Font(Font.defaultFamily, 15);
const viewableFloor = markRaw(
core.floorIds.filter(v => {
return (
!core.floors[v].cannotViewMap &&
!core.status?.hero?.flags?.__removed__?.includes(v)
);
})
);
// const group = ref<LayerGroup>();
const tip = ref<TipExpose>();
const statistics = shallowRef<StatisticsDataOneFloor>();
const now = ref(0);
if (props.floorId) {
const index = viewableFloor.indexOf(props.floorId);
if (index !== -1) now.value = index;
}
const floorId = computed(() => viewableFloor[now.value]);
//#region 按键实现
const [key] = useKey();
key.realize('@viewMap_up', () => changeFloor(1), { type: 'down-repeat' })
.realize('@viewMap_down', () => changeFloor(-1), {
type: 'down-repeat'
})
.realize('@viewMap_up_ten', () => changeFloor(10))
.realize('@viewMap_down_ten', () => changeFloor(-10))
.realize('@viewMap_book', () => openBook())
.realize('@viewMap_fly', () => fly())
// .realize('@viewMap_reset', () => resetCamera())
.realize('confirm', () => close())
.realize('exit', (_, code, assist) => {
// 如果按键不能触发怪物手册,则关闭界面,因为怪物手册和退出默认使用同一个按键,需要特判
if (!key.willEmit(code, assist, 'book')) {
props.controller.close(props.instance);
}
});
//#region 功能函数
const close = () => {
props.controller.close(props.instance);
};
const format = (num?: number) => {
return core.formatBigNumber(num ?? 0, 6);
};
const changeTo = (index: number) => {
const res = clamp(index, 0, viewableFloor.length - 1);
now.value = res;
};
const changeFloor = (delta: number) => {
changeTo(now.value + delta);
};
const openBook = () => core.openBook(true);
const fly = () => {
if (!core.hasItem('fly')) return;
const id = viewableFloor[now.value];
const success = core.flyTo(id);
if (success) close();
else tip.value?.drawTip(`无法飞往${core.floors[id].title}`);
};
// const resetCamera = () => {
// group.value?.camera.reset();
// group.value?.update();
// };
//#region 渐变渲染
const topAlpha = ref(0.7);
const bottomAlpha = ref(0.7);
let topGradient: CanvasGradient | null = null;
let bottomGradient: CanvasGradient | null = null;
const getTopGradient = (ctx: CanvasRenderingContext2D) => {
if (topGradient) return topGradient;
topGradient = ctx.createLinearGradient(0, 0, 0, 64);
topGradient.addColorStop(0, 'rgba(0,0,0,1)');
topGradient.addColorStop(0.75, 'rgba(0,0,0,0.5)');
topGradient.addColorStop(1, 'rgba(0,0,0,0)');
return topGradient;
};
const getBottomGradient = (ctx: CanvasRenderingContext2D) => {
if (bottomGradient) return bottomGradient;
bottomGradient = ctx.createLinearGradient(0, 64, 0, 0);
bottomGradient.addColorStop(0, 'rgba(0,0,0,1)');
bottomGradient.addColorStop(0.75, 'rgba(0,0,0,0.5)');
bottomGradient.addColorStop(1, 'rgba(0,0,0,0)');
return bottomGradient;
};
const renderTop = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.fillStyle = getTopGradient(ctx);
ctx.fillRect(0, 0, MAP_WIDTH, 64);
};
const renderBottom = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.fillStyle = getBottomGradient(ctx);
ctx.fillRect(0, 0, MAP_HEIGHT, 64);
};
const enterTop = () => (topAlpha.value = 0.9);
const enterBottom = () => (bottomAlpha.value = 0.9);
const leaveTop = () => (topAlpha.value = 0.7);
const leaveBottom = () => (bottomAlpha.value = 0.7);
//#region 地图渲染
// const renderLayer = (floorId: FloorIds) => {
// const binder = group.value?.getExtends(
// 'floor-binder'
// ) as LayerGroupFloorBinder;
// binder.bindFloor(floorId);
// group.value?.camera.reset();
// core.status.floorId = floorId;
// core.status.thisMap = core.status.maps[floorId];
// statistics.value = calculateStatisticsOne(floorId);
// };
// const moveCamera = (dx: number, dy: number) => {
// const camera = group.value?.camera;
// if (!camera) return;
// camera.translate(dx / camera.scaleX, dy / camera.scaleX);
// group.value?.update();
// };
// const scaleCamera = (scale: number, x: number, y: number) => {
// const camera = group.value?.camera;
// if (!camera) return;
// const [cx, cy] = camera.untransformed(x, y);
// camera.translate(cx, cy);
// camera.scale(scale);
// camera.translate(-cx, -cy);
// group.value?.update();
// };
//#region 事件监听
const clickTop = (ev: IActionEvent) => {
const col = MAP_WIDTH / 3;
if (ev.offsetX < col * 2) {
changeFloor(1);
} else {
// resetCamera();
}
};
const clickBottom = (ev: IActionEvent) => {
const col = MAP_WIDTH / 3;
if (ev.offsetX < col) {
openBook();
} else if (ev.offsetX < col * 2) {
changeFloor(-1);
} else {
fly();
}
};
//#region 地图交互
let mouseDown = false;
let moved = false;
let scaled = false;
let lastMoveX = 0;
let lastMoveY = 0;
let lastDis = 0;
let movement = 0;
const touches = new Map<number, IActionEvent>();
const downMap = (ev: IActionEvent) => {
moved = false;
lastMoveX = ev.offsetX;
lastMoveY = ev.offsetY;
movement = 0;
if (ev.touch) {
touches.set(ev.identifier, ev);
if (touches.size >= 2) {
const [touch1, touch2] = touches.values();
lastDis = Math.hypot(
touch1.offsetX - touch2.offsetX,
touch1.offsetY - touch2.offsetY
);
}
} else {
mouseDown = true;
}
};
const upMap = (ev: IActionEvent) => {
if (ev.touch) {
touches.delete(ev.identifier);
} else {
mouseDown = false;
}
if (touches.size === 0) {
scaled = false;
}
};
const move = (ev: IActionEvent) => {
if (moved) {
const dx = ev.offsetX - lastMoveX;
const dy = ev.offsetY - lastMoveY;
movement += Math.hypot(dx, dy);
// moveCamera(dx, dy);
}
moved = true;
lastMoveX = ev.offsetX;
lastMoveY = ev.offsetY;
};
const moveMap = (ev: IActionEvent) => {
if (ev.touch) {
if (touches.size === 0) return;
else if (touches.size === 1) {
// 移动
if (scaled) return;
move(ev);
} else {
// 缩放
const [touch1, touch2] = touches.values();
const cx = mean([touch1.offsetX, touch2.offsetX]);
const cy = mean([touch1.offsetY, touch2.offsetY]);
const dis = Math.hypot(
touch1.offsetX - touch2.offsetX,
touch1.offsetY - touch2.offsetY
);
const scale = dis / lastDis;
if (!scaled) {
lastDis = dis;
return;
}
if (!isFinite(scale) || scale === 0) return;
// scaleCamera(scale, cx, cy);
}
} else {
if (mouseDown) {
move(ev);
}
}
};
const leaveMap = (ev: IActionEventBase) => {
if (ev.touch) {
touches.delete(ev.identifier);
} else {
mouseDown = false;
}
};
const wheelMap = (ev: IWheelEvent) => {
if (ev.altKey) {
// const scale = ev.wheelY < 0 ? 1.1 : 0.9;
// scaleCamera(scale, ev.offsetX, ev.offsetY);
} else if (ev.ctrlKey) {
changeFloor(-Math.sign(ev.wheelY) * 10);
} else {
changeFloor(-Math.sign(ev.wheelY));
}
};
const clickMap = (ev: IActionEvent) => {
if (movement > 5) return;
if (ev.touch) {
if (touches.size === 0) {
close();
}
} else {
close();
}
};
onMounted(() => {
// renderLayer(floorId.value);
});
onUnmounted(() => {
core.status.floorId = nowFloorId;
core.status.thisMap = core.status.maps[nowFloorId];
});
watch(floorId, value => {
// renderLayer(value);
});
//#region 组件树
return () => (
<container loc={props.loc} nocache>
<g-rect fillStyle="black" fill loc={FULL_LOC} />
<g-rect stroke zIndex={100} loc={FULL_LOC} noevent />
<g-line
line={[STATUS_BAR_WIDTH, 0, STATUS_BAR_WIDTH, MAIN_HEIGHT]}
lineWidth={1}
/>
<g-line
line={[RIGHT_STATUS_POS, 0, RIGHT_STATUS_POS, MAIN_HEIGHT]}
lineWidth={1}
/>
<FloorSelector
loc={[0, 0, STATUS_BAR_WIDTH, MAIN_HEIGHT]}
floors={viewableFloor}
v-model:now={now.value}
onClose={close}
/>
{/* <layer-group
ref={group}
ex={layerGroupExtends}
loc={[STATUS_BAR_WIDTH, 0, MAP_WIDTH, MAP_HEIGHT]}
onDown={downMap}
onMove={moveMap}
onUp={upMap}
onLeave={leaveMap}
onWheel={wheelMap}
onClick={clickMap}
>
<layer layer="bg" zIndex={10}></layer>
<layer layer="bg2" zIndex={20}></layer>
<layer layer="event" zIndex={30}></layer>
<layer layer="fg" zIndex={40}></layer>
<layer layer="fg2" zIndex={50}></layer>
</layer-group> */}
<Tip
ref={tip}
zIndex={40}
loc={[STATUS_BAR_WIDTH + 8, 8, 200, 32]}
pad={[12, 6]}
corner={16}
/>
<custom
loc={[STATUS_BAR_WIDTH, 0, MAP_WIDTH, 64]}
render={renderTop}
alpha={topAlpha.value}
zIndex={10}
cursor="pointer"
onEnter={enterTop}
onLeave={leaveTop}
onClick={clickTop}
/>
<custom
loc={[STATUS_BAR_WIDTH, MAP_HEIGHT - 64, MAP_WIDTH, 64]}
render={renderBottom}
alpha={bottomAlpha.value}
zIndex={10}
cursor="pointer"
onEnter={enterBottom}
onLeave={leaveBottom}
onClick={clickBottom}
/>
<text
text="上移地图"
loc={[HALF_MAP_WIDTH + STATUS_BAR_WIDTH, 24]}
anc={[0.5, 0.5]}
zIndex={20}
noevent
/>
<text
text="下移地图"
loc={[HALF_MAP_WIDTH + STATUS_BAR_WIDTH, MAP_HEIGHT - 24]}
anc={[0.5, 0.5]}
zIndex={20}
noevent
/>
<text
text="「 怪物手册 」"
loc={[32 + STATUS_BAR_WIDTH, MAP_HEIGHT - 24]}
anc={[0, 0.5]}
zIndex={20}
noevent
/>
<text
text="「 传送至此 」"
loc={[RIGHT_STATUS_POS - 32, MAP_HEIGHT - 24]}
anc={[1, 0.5]}
zIndex={20}
noevent
/>
<text
text="「 重置视角 」"
loc={[RIGHT_STATUS_POS - 32, 24]}
anc={[1, 0.5]}
zIndex={20}
noevent
/>
<container
loc={[RIGHT_STATUS_POS, 0, STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT]}
hidden={!ENABLE_RIGHT_STATUS_BAR}
>
<text
text="鼠标 / 单指拖动地图"
font={rightFont}
loc={[90, 24]}
anc={[0.5, 0.5]}
fillStyle="yellow"
/>
<text
text="Alt+滚轮 / 双指缩放地图"
font={rightFont}
loc={[90, 48]}
anc={[0.5, 0.5]}
fillStyle="yellow"
/>
<text
text="Ctrl+滚轮 / 滚轮切换地图"
font={rightFont}
loc={[90, 72]}
anc={[0.5, 0.5]}
fillStyle="yellow"
/>
<g-line line={[12, 96, 168, 96]} lineWidth={1} />
<text
text={`怪物数量:${statistics.value?.enemyCount}`}
loc={[20, 120]}
anc={[0, 0.5]}
/>
<text
text={`血瓶数量:${statistics.value?.potionCount}`}
loc={[20, 144]}
anc={[0, 0.5]}
/>
<text
text={`宝石数量:${statistics.value?.gemCount}`}
loc={[20, 168]}
anc={[0, 0.5]}
/>
<text
text={`血瓶数值:${format(statistics.value?.potionValue)}`}
loc={[20, 192]}
anc={[0, 0.5]}
/>
<text
text={`攻击数值:${format(statistics.value?.atkValue)}`}
loc={[20, 216]}
anc={[0, 0.5]}
/>
<text
text={`防御数值:${format(statistics.value?.defValue)}`}
loc={[20, 240]}
anc={[0, 0.5]}
/>
<text
text={`智慧数值:${format(statistics.value?.mdefValue)}`}
loc={[20, 264]}
anc={[0, 0.5]}
/>
<g-line line={[12, 292, 168, 292]} lineWidth={1} />
<text
text="「 怪物手册 」"
loc={loc1}
anc={[0.5, 0.5]}
cursor="pointer"
onClick={openBook}
/>
<text
text="「 传送至此 」"
loc={loc2}
anc={[0.5, 0.5]}
cursor="pointer"
onClick={fly}
/>
<text
text="「 重置视角 」"
loc={loc3}
anc={[0.5, 0.5]}
cursor="pointer"
// onClick={resetCamera}
/>
</container>
</container>
);
}, viewMapProps);
export const ViewMapUI = new GameUI('view-map', ViewMap);
export function openViewMap(
controller: IUIController<IGameUIPropsBase>,
loc: ElementLocator,
props?: ViewMapProps
) {
controller.open(ViewMapUI, {
...props,
loc,
floorId: core.status.floorId
});
}
+1 -5
View File
@@ -1,5 +1 @@
import { createData } from '@user/entry-data';
// 逻辑端入口,仅在录像验证时运行
createData();
export { createCoreState } from './data/entry';
+8
View File
@@ -0,0 +1,8 @@
import { FaceGroup } from '@user/data-common';
// 本文件是游戏的底层核心配置,如果不懂的话千万不要动
/** 数据端核心配置对象,不懂的话就不要动 */
export const CORE_URL = 'content/core.jsonc';
/** 勇士的默认朝向组,用于控制勇士的移动,不懂的话就不要动 */
export const HERO_FACE = FaceGroup.Dir8;
+1 -1
View File
@@ -1,5 +1,5 @@
import { CoreState, ICoreState, ICoreStateConfig } from '@user/data-state';
import { CORE_URL, HERO_FACE } from '../constant';
import { CORE_URL, HERO_FACE } from './constant';
import { ILoadTaskStarter } from '@motajs/loader';
import { MainDataPlugin } from './initialize';
import { dataPlugins } from './plugin';
+4
View File
@@ -0,0 +1,4 @@
export * from './constant';
export * from './entry';
export * from './initialize';
export * from './types';
-13
View File
@@ -1,13 +0,0 @@
import { createApp } from 'vue';
import './styles.less';
import { createGame } from '@user/entry-client';
import App from './App.vue';
// 客户端入口,在玩家游玩时运行
// 创建游戏实例
createGame();
createApp(App).mount('#root');
main.init('play');
main.listen();
+1 -3
View File
@@ -2,8 +2,6 @@
"name": "@user/main",
"dependencies": {
"@user/data-common": "workspace:*",
"@user/data-state": "workspace:*",
"@user/entry-client": "workspace:*",
"@user/entry-data": "workspace:*"
"@user/data-state": "workspace:*"
}
}