mirror of
https://github.com/motajs/template.git
synced 2026-11-02 06:34:31 +08:00
init: 2.B
This commit is contained in:
commit
6bf32174d8
610 files changed
+151444
No files matched your search
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@user/client-modules",
|
||||
"dependencies": {
|
||||
"@motajs/client-base": "workspace:*",
|
||||
"@motajs/common": "workspace:*",
|
||||
"@motajs/render": "workspace:*",
|
||||
"@motajs/render-core": "workspace:*",
|
||||
"@motajs/legacy-common": "workspace:*",
|
||||
"@motajs/legacy-ui": "workspace:*",
|
||||
"@motajs/types": "workspace:*",
|
||||
"@motajs/system-action": "workspace:*",
|
||||
"@motajs/system-ui": "workspace:*",
|
||||
"@user/data-base": "workspace:*",
|
||||
"@user/data-state": "workspace:*",
|
||||
"@user/legacy-plugin-data": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { KeyCode } from '@motajs/client-base';
|
||||
import { gameKey, HotkeyJSON } from '@motajs/system-action';
|
||||
import { GameStorage } from '@motajs/legacy-system';
|
||||
|
||||
export const mainScope = Symbol.for('@key_main');
|
||||
|
||||
// todo: 读取上一个手动存档,存档至下一个存档栏
|
||||
// ----- Register
|
||||
gameKey
|
||||
//#region 游戏按键
|
||||
.group('game', '游戏按键')
|
||||
.register({
|
||||
id: 'moveUp',
|
||||
name: '上移',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: 'moveDown',
|
||||
name: '下移',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: 'moveLeft',
|
||||
name: '左移',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: 'moveRight',
|
||||
name: '右移',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
//#region ui界面
|
||||
.group('ui', 'ui界面')
|
||||
.register({
|
||||
id: 'book',
|
||||
name: '怪物手册',
|
||||
defaults: KeyCode.KeyX
|
||||
})
|
||||
.register({
|
||||
id: 'save',
|
||||
name: '存档界面',
|
||||
defaults: KeyCode.KeyS
|
||||
})
|
||||
.register({
|
||||
id: 'load',
|
||||
name: '读档界面',
|
||||
defaults: KeyCode.KeyD
|
||||
})
|
||||
.register({
|
||||
id: 'toolbox',
|
||||
name: '道具栏',
|
||||
defaults: KeyCode.KeyT
|
||||
})
|
||||
.register({
|
||||
id: 'equipbox',
|
||||
name: '装备栏',
|
||||
defaults: KeyCode.KeyQ
|
||||
})
|
||||
.register({
|
||||
id: 'fly',
|
||||
name: '楼层传送',
|
||||
defaults: KeyCode.KeyG
|
||||
})
|
||||
.register({
|
||||
id: 'menu',
|
||||
name: '菜单',
|
||||
defaults: KeyCode.Escape
|
||||
})
|
||||
.register({
|
||||
id: 'replay',
|
||||
name: '录像回放',
|
||||
defaults: KeyCode.KeyR
|
||||
})
|
||||
.register({
|
||||
id: 'shop',
|
||||
name: '快捷商店',
|
||||
defaults: KeyCode.KeyV
|
||||
})
|
||||
.register({
|
||||
id: 'viewMap_1',
|
||||
name: '浏览地图_1',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
.register({
|
||||
id: 'viewMap_2',
|
||||
name: '浏览地图_2',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
//#region 功能按键
|
||||
.group('function', '功能按键')
|
||||
.register({
|
||||
id: 'undo_1',
|
||||
name: '回退_1',
|
||||
defaults: KeyCode.KeyA
|
||||
})
|
||||
.register({
|
||||
id: 'undo_2',
|
||||
name: '回退_2',
|
||||
defaults: KeyCode.Digit5
|
||||
})
|
||||
.register({
|
||||
id: 'redo_1',
|
||||
name: '恢复_1',
|
||||
defaults: KeyCode.KeyW
|
||||
})
|
||||
.register({
|
||||
id: 'redo_2',
|
||||
name: '恢复_2',
|
||||
defaults: KeyCode.Digit6
|
||||
})
|
||||
.register({
|
||||
id: 'turn',
|
||||
name: '勇士转向',
|
||||
defaults: KeyCode.KeyZ
|
||||
})
|
||||
.register({
|
||||
id: 'getNext_1',
|
||||
name: '轻按_1',
|
||||
defaults: KeyCode.Space
|
||||
})
|
||||
.register({
|
||||
id: 'getNext_2',
|
||||
name: '轻按_2',
|
||||
defaults: KeyCode.Digit7
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_1',
|
||||
name: '切换/保存套装_1',
|
||||
defaults: KeyCode.Digit1,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_2',
|
||||
name: '切换/保存套装_2',
|
||||
defaults: KeyCode.Digit2,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_3',
|
||||
name: '切换/保存套装_3',
|
||||
defaults: KeyCode.Digit3,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_4',
|
||||
name: '切换/保存套装_4',
|
||||
defaults: KeyCode.Digit4,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_5',
|
||||
name: '切换/保存套装_5',
|
||||
defaults: KeyCode.Digit5,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_6',
|
||||
name: '切换/保存套装_6',
|
||||
defaults: KeyCode.Digit6,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_7',
|
||||
name: '切换/保存套装_7',
|
||||
defaults: KeyCode.Digit7,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_8',
|
||||
name: '切换/保存套装_8',
|
||||
defaults: KeyCode.Digit8,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_9',
|
||||
name: '切换/保存套装_9',
|
||||
defaults: KeyCode.Digit9,
|
||||
alt: true
|
||||
})
|
||||
.register({
|
||||
id: 'quickEquip_0',
|
||||
name: '切换/保存套装_0',
|
||||
defaults: KeyCode.Digit0,
|
||||
alt: true
|
||||
})
|
||||
//#region 系统按键
|
||||
.group('system', '系统按键')
|
||||
.register({
|
||||
id: 'restart',
|
||||
name: '回到开始界面',
|
||||
defaults: KeyCode.KeyN
|
||||
})
|
||||
.register({
|
||||
id: 'comment',
|
||||
name: '评论区',
|
||||
defaults: KeyCode.KeyP
|
||||
})
|
||||
.register({
|
||||
id: 'debug',
|
||||
name: '调试模式',
|
||||
defaults: KeyCode.F8
|
||||
})
|
||||
//#region 通用按键
|
||||
.group('general', '通用按键')
|
||||
.register({
|
||||
id: 'exit_1',
|
||||
name: '退出ui界面_1',
|
||||
defaults: KeyCode.KeyX
|
||||
})
|
||||
.register({
|
||||
id: 'exit_2',
|
||||
name: '退出ui界面_2',
|
||||
defaults: KeyCode.Escape
|
||||
})
|
||||
.register({
|
||||
id: 'confirm_1',
|
||||
name: '确认_1',
|
||||
defaults: KeyCode.Enter
|
||||
})
|
||||
.register({
|
||||
id: 'confirm_2',
|
||||
name: '确认_2',
|
||||
defaults: KeyCode.Space
|
||||
})
|
||||
.register({
|
||||
id: 'confirm_3',
|
||||
name: '确认_3',
|
||||
defaults: KeyCode.KeyC
|
||||
})
|
||||
//#region 开始界面
|
||||
.group('@ui_start', '开始界面')
|
||||
.register({
|
||||
id: '@start_up',
|
||||
name: '上移光标',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@start_down',
|
||||
name: '下移光标',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
//#region 怪物手册
|
||||
.group('@ui_book', '怪物手册')
|
||||
.register({
|
||||
id: '@book_up',
|
||||
name: '上移光标',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@book_down',
|
||||
name: '下移光标',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@book_pageDown_1',
|
||||
name: '下移5个怪物_1',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
.register({
|
||||
id: '@book_pageDown_2',
|
||||
name: '下移5个怪物_2',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
.register({
|
||||
id: '@book_pageUp_1',
|
||||
name: '上移5个怪物_1',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: '@book_pageUp_2',
|
||||
name: '上移5个怪物_2',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
//#region 道具栏
|
||||
.group('@ui_toolbox', '道具栏')
|
||||
.register({
|
||||
id: '@toolbox_right',
|
||||
name: '光标右移',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
.register({
|
||||
id: '@toolbox_left',
|
||||
name: '光标左移',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: '@toolbox_up',
|
||||
name: '光标上移',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@toolbox_down',
|
||||
name: '光标下移',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
//#region 商店
|
||||
.group('@ui_shop', '商店')
|
||||
.register({
|
||||
id: '@shop_up',
|
||||
name: '上移光标',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@shop_down',
|
||||
name: '下移光标',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@shop_add',
|
||||
name: '增加购买量',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
.register({
|
||||
id: '@shop_min',
|
||||
name: '减少购买量',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
//#region 楼层传送
|
||||
.group('@ui_fly', '楼层传送')
|
||||
.register({
|
||||
id: '@fly_left',
|
||||
name: '左移地图',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_right',
|
||||
name: '右移地图',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_up',
|
||||
name: '上移地图',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_down',
|
||||
name: '下移地图',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_last',
|
||||
name: '上一张地图',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
.register({
|
||||
id: '@fly_next',
|
||||
name: '下一张地图',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
//#region 传统楼传
|
||||
.group('@ui_fly_tradition', '楼层传送-传统按键')
|
||||
.register({
|
||||
id: '@fly_down_t',
|
||||
name: '上一张地图',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_up_t',
|
||||
name: '下一张地图',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_left_t_1',
|
||||
name: '前10张地图_1',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_left_t_2',
|
||||
name: '前10张地图_2',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
.register({
|
||||
id: '@fly_right_t_1',
|
||||
name: '后10张地图_1',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
.register({
|
||||
id: '@fly_right_t_2',
|
||||
name: '后10张地图_2',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
// #region 存档界面
|
||||
.group('@ui_save', '存档界面')
|
||||
.register({
|
||||
id: '@save_pageUp',
|
||||
name: '向后翻页',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
.register({
|
||||
id: '@save_pageDown',
|
||||
name: '向前翻页',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
.register({
|
||||
id: '@save_up',
|
||||
name: '选择框向上',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@save_down',
|
||||
name: '选择框向下',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@save_left',
|
||||
name: '选择框向左',
|
||||
defaults: KeyCode.LeftArrow
|
||||
})
|
||||
.register({
|
||||
id: '@save_right',
|
||||
name: '选择框向右',
|
||||
defaults: KeyCode.RightArrow
|
||||
})
|
||||
//#region 浏览地图
|
||||
.group('@ui_viewMap', '浏览地图')
|
||||
.register({
|
||||
id: '@viewMap_up_1',
|
||||
name: '下一层地图_1',
|
||||
defaults: KeyCode.UpArrow
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_up_2',
|
||||
name: '下一层地图_2',
|
||||
defaults: KeyCode.PageUp
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_down_1',
|
||||
name: '上一层地图_1',
|
||||
defaults: KeyCode.DownArrow
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_down_2',
|
||||
name: '上一层地图_2',
|
||||
defaults: KeyCode.PageDown
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_up_ten',
|
||||
name: '下十层地图',
|
||||
defaults: KeyCode.UpArrow,
|
||||
ctrl: true
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_down_ten',
|
||||
name: '上十层地图',
|
||||
defaults: KeyCode.DownArrow,
|
||||
ctrl: true
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_book',
|
||||
name: '怪物手册',
|
||||
defaults: KeyCode.KeyX
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_fly',
|
||||
name: '传送至',
|
||||
defaults: KeyCode.KeyG
|
||||
})
|
||||
.register({
|
||||
id: '@viewMap_reset',
|
||||
name: '重置视角',
|
||||
defaults: KeyCode.KeyR
|
||||
});
|
||||
// #endregion
|
||||
|
||||
gameKey.enable();
|
||||
gameKey.use(mainScope);
|
||||
|
||||
//#region 按键实现
|
||||
|
||||
gameKey
|
||||
.when(
|
||||
() =>
|
||||
!core.status.lockControl && !core.isMoving() && !core.isReplaying()
|
||||
)
|
||||
.realize('book', () => {
|
||||
core.openBook(true);
|
||||
})
|
||||
.realize('toolbox', () => {
|
||||
core.openToolbox(true);
|
||||
})
|
||||
.realize('equipbox', () => {
|
||||
core.openEquipbox(true);
|
||||
})
|
||||
.realize('fly', () => {
|
||||
core.useFly(true);
|
||||
})
|
||||
.realize('shop', () => {
|
||||
core.openQuickShop(true);
|
||||
})
|
||||
.realize('undo', () => {
|
||||
core.doSL('autoSave', 'load');
|
||||
})
|
||||
.realize('redo', () => {
|
||||
core.doSL('autoSave', 'reload');
|
||||
})
|
||||
.realize('turn', () => {
|
||||
core.turnHero();
|
||||
})
|
||||
.realize('getNext', () => {
|
||||
core.getNextItem();
|
||||
})
|
||||
.realize('restart', () => {
|
||||
core.confirmRestart();
|
||||
})
|
||||
.realize('comment', () => {
|
||||
core.actions._clickGameInfo_openComments();
|
||||
})
|
||||
.realize('debug', () => {
|
||||
core.debug();
|
||||
});
|
||||
|
||||
// ----- Storage
|
||||
const keyStorage = new GameStorage<Record<string, HotkeyJSON>>(
|
||||
GameStorage.fromAuthor('AncTe', 'gameKey')
|
||||
);
|
||||
keyStorage.data = {};
|
||||
keyStorage.read();
|
||||
gameKey.on('set', (id, key, assist) => {
|
||||
keyStorage.setValue(id, { key, assist });
|
||||
});
|
||||
gameKey.fromJSON(keyStorage.toJSON());
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './move';
|
||||
export * from './hotkey';
|
||||
@@ -0,0 +1,182 @@
|
||||
import { KeyCode } from '@motajs/client-base';
|
||||
import { Hotkey, HotkeyData } from '@motajs/system-action';
|
||||
import { HeroMover, IMoveController } from '@user/data-state';
|
||||
import { Ticker } from 'mutate-animate';
|
||||
import { mainScope } from './hotkey';
|
||||
|
||||
type MoveKey = Record<Dir, HotkeyData>;
|
||||
type MoveKeyConfig = Record<Dir, string>;
|
||||
|
||||
export class HeroKeyMover {
|
||||
/** 当前按下的键 */
|
||||
private pressedKey: Set<Dir> = new Set();
|
||||
/** 当前的移动方向 */
|
||||
private moveDir: Dir = 'down';
|
||||
/** 当前是否正在使用按键移动 */
|
||||
private moving: boolean = false;
|
||||
/** 当前移动的控制器 */
|
||||
private controller?: IMoveController;
|
||||
|
||||
/** 按键接续ticker */
|
||||
private ticker = new Ticker();
|
||||
|
||||
/** 当前移动实例绑定的热键 */
|
||||
hotkey: Hotkey;
|
||||
/** 当前热键的移动按键信息 */
|
||||
hotkeyData: MoveKey;
|
||||
/** 移动实例 */
|
||||
mover: HeroMover;
|
||||
/** 移动可触发的作用域 */
|
||||
scope: symbol = mainScope;
|
||||
|
||||
constructor(hotkey: Hotkey, mover: HeroMover, config?: MoveKeyConfig) {
|
||||
this.hotkey = hotkey;
|
||||
this.mover = mover;
|
||||
hotkey.on('press', this.onPressKey);
|
||||
hotkey.on('release', this.onReleaseKey);
|
||||
|
||||
const data = hotkey.data;
|
||||
|
||||
this.hotkeyData = {
|
||||
left: data[config?.left ?? 'moveLeft'],
|
||||
right: data[config?.right ?? 'moveRight'],
|
||||
up: data[config?.up ?? 'moveUp'],
|
||||
down: data[config?.down ?? 'moveDown']
|
||||
};
|
||||
|
||||
// 静止时尝试启动移动
|
||||
this.ticker.add(() => {
|
||||
if (!this.moving) {
|
||||
if (this.pressedKey.size > 0) {
|
||||
const dir = [...this.pressedKey].at(-1);
|
||||
if (!dir) return;
|
||||
this.moveDir = dir;
|
||||
this.tryStartMove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按键移动
|
||||
* @param code 按键码
|
||||
*/
|
||||
private onPressKey = (code: KeyCode) => {
|
||||
if (core.isReplaying() || !core.isPlaying()) return;
|
||||
core.waitHeroToStop();
|
||||
if (code === this.hotkeyData.left.key) this.press('left');
|
||||
else if (code === this.hotkeyData.right.key) this.press('right');
|
||||
else if (code === this.hotkeyData.up.key) this.press('up');
|
||||
else if (code === this.hotkeyData.down.key) this.press('down');
|
||||
};
|
||||
|
||||
/**
|
||||
* 释放按键
|
||||
* @param code 按键码
|
||||
*/
|
||||
private onReleaseKey = (code: KeyCode) => {
|
||||
if (code === this.hotkeyData.left.key) this.release('left');
|
||||
else if (code === this.hotkeyData.right.key) this.release('right');
|
||||
else if (code === this.hotkeyData.up.key) this.release('up');
|
||||
else if (code === this.hotkeyData.down.key) this.release('down');
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置按键触发作用域
|
||||
*/
|
||||
setScope(scope: symbol) {
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按下某个方向键
|
||||
* @param dir 移动方向
|
||||
*/
|
||||
press(dir: Dir) {
|
||||
if (this.hotkey.scope !== this.scope || core.status.lockControl) return;
|
||||
this.pressedKey.add(dir);
|
||||
this.moveDir = dir;
|
||||
if (!this.moving) {
|
||||
this.tryStartMove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 松开方向键
|
||||
* @param dir 移动方向
|
||||
*/
|
||||
release(dir: Dir) {
|
||||
this.pressedKey.delete(dir);
|
||||
if (this.pressedKey.size > 0) {
|
||||
this.moveDir = [...this.pressedKey][0];
|
||||
} else {
|
||||
this.endMove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试开始移动
|
||||
* @returns 是否成功开始移动
|
||||
*/
|
||||
tryStartMove() {
|
||||
if (this.moving || core.status.lockControl) return false;
|
||||
|
||||
this.mover.oneStep(this.moveDir);
|
||||
const controller = this.mover.startMove(false, false, false, true);
|
||||
if (!controller) return false;
|
||||
|
||||
this.controller = controller;
|
||||
controller.onEnd.then(() => {
|
||||
this.moving = false;
|
||||
this.controller = void 0;
|
||||
this.mover.off('stepEnd', this.onStepEnd);
|
||||
});
|
||||
this.moving = true;
|
||||
|
||||
this.mover.on('stepEnd', this.onStepEnd);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本次按键移动
|
||||
*/
|
||||
endMove() {
|
||||
this.controller?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动结束
|
||||
*/
|
||||
private onStepEnd = () => {
|
||||
const con = this.controller;
|
||||
if (!con) return;
|
||||
|
||||
// 被禁止操作时
|
||||
if (core.status.lockControl) {
|
||||
con.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// 未移动时
|
||||
if (!this.moving) {
|
||||
con.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试移动
|
||||
if (this.pressedKey.size > 0) {
|
||||
if (con.queue.length === 0) {
|
||||
con.push({ type: 'dir', value: this.moveDir });
|
||||
}
|
||||
} else {
|
||||
con.stop();
|
||||
}
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.hotkey.off('press', this.onPressKey);
|
||||
this.hotkey.off('release', this.onReleaseKey);
|
||||
this.mover.off('stepEnd', this.onStepEnd);
|
||||
this.ticker.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { audioPlayer, AudioPlayer, AudioRoute, AudioStatus } from './player';
|
||||
import { guessTypeByExt, isAudioSupport } from './support';
|
||||
import { logger } from '@motajs/common';
|
||||
import { StreamLoader } from '../loader';
|
||||
import { linear, sleep, Transition } from 'mutate-animate';
|
||||
import { VolumeEffect } from './effect';
|
||||
|
||||
interface BgmVolume {
|
||||
effect: VolumeEffect;
|
||||
transition: Transition;
|
||||
}
|
||||
|
||||
interface BgmControllerEvent {
|
||||
play: [];
|
||||
pause: [];
|
||||
resume: [];
|
||||
stop: [];
|
||||
}
|
||||
|
||||
export class BgmController<
|
||||
T extends string = BgmIds
|
||||
> extends EventEmitter<BgmControllerEvent> {
|
||||
/** bgm音频名称的前缀 */
|
||||
prefix: string = 'bgms.';
|
||||
/** 每个 bgm 的音量控制器 */
|
||||
readonly gain: Map<T, BgmVolume> = new Map();
|
||||
|
||||
/** 正在播放的 bgm */
|
||||
playingBgm?: T;
|
||||
/** 是否正在播放 */
|
||||
playing: boolean = false;
|
||||
|
||||
/** 是否已经启用 */
|
||||
enabled: boolean = true;
|
||||
/** 主音量控制器 */
|
||||
private readonly mainGain: VolumeEffect;
|
||||
/** 是否屏蔽所有的音乐切换 */
|
||||
private blocking: boolean = false;
|
||||
/** 渐变时长 */
|
||||
private transitionTime: number = 2000;
|
||||
|
||||
constructor(public readonly player: AudioPlayer) {
|
||||
super();
|
||||
this.mainGain = player.createVolumeEffect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频渐变时长
|
||||
* @param time 渐变时长
|
||||
*/
|
||||
setTransitionTime(time: number) {
|
||||
this.transitionTime = time;
|
||||
for (const [, value] of this.gain) {
|
||||
value.transition.time(time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 屏蔽音乐切换
|
||||
*/
|
||||
blockChange() {
|
||||
this.blocking = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消屏蔽音乐切换
|
||||
*/
|
||||
unblockChange() {
|
||||
this.blocking = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置总音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.mainGain.setVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取总音量大小
|
||||
*/
|
||||
getVolume() {
|
||||
return this.mainGain.getVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
setEnabled(enabled: boolean) {
|
||||
if (enabled) this.resume();
|
||||
else this.stop();
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 bgm 音频名称的前缀
|
||||
*/
|
||||
setPrefix(prefix: string) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
private getId(name: T) {
|
||||
return `${this.prefix}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 bgm 名称获取其 AudioRoute 实例
|
||||
* @param id 音频名称
|
||||
*/
|
||||
get(id: T) {
|
||||
return this.player.getRoute(this.getId(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个 bgm
|
||||
* @param id 要添加的 bgm 的名称
|
||||
* @param url 指定 bgm 的加载地址
|
||||
*/
|
||||
addBgm(id: T, url: string = `project/bgms/${id}`) {
|
||||
const type = guessTypeByExt(id);
|
||||
if (!type) {
|
||||
logger.warn(50, id.split('.').slice(0, -1).join('.'));
|
||||
return;
|
||||
}
|
||||
const gain = this.player.createVolumeEffect();
|
||||
if (isAudioSupport(type)) {
|
||||
const source = audioPlayer.createElementSource();
|
||||
source.setSource(url);
|
||||
source.setLoop(true);
|
||||
const route = new AudioRoute(source, audioPlayer);
|
||||
route.addEffect([gain, this.mainGain]);
|
||||
audioPlayer.addRoute(this.getId(id), route);
|
||||
this.setTransition(id, route, gain);
|
||||
} else {
|
||||
const source = audioPlayer.createStreamSource();
|
||||
const stream = new StreamLoader(url);
|
||||
stream.pipe(source);
|
||||
source.setLoop(true);
|
||||
const route = new AudioRoute(source, audioPlayer);
|
||||
route.addEffect([gain, this.mainGain]);
|
||||
audioPlayer.addRoute(this.getId(id), route);
|
||||
this.setTransition(id, route, gain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个 bgm
|
||||
* @param id 要移除的 bgm 的名称
|
||||
*/
|
||||
removeBgm(id: T) {
|
||||
this.player.removeRoute(this.getId(id));
|
||||
const gain = this.gain.get(id);
|
||||
gain?.transition.ticker.destroy();
|
||||
this.gain.delete(id);
|
||||
}
|
||||
|
||||
private setTransition(id: T, route: AudioRoute, gain: VolumeEffect) {
|
||||
const transition = new Transition();
|
||||
transition
|
||||
.time(this.transitionTime)
|
||||
.mode(linear())
|
||||
.transition('volume', 0);
|
||||
|
||||
const tick = () => {
|
||||
gain.setVolume(transition.value.volume);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param expect 在结束时应该是正在播放还是停止
|
||||
*/
|
||||
const setTick = async (expect: AudioStatus) => {
|
||||
transition.ticker.remove(tick);
|
||||
transition.ticker.add(tick);
|
||||
const identifier = route.stopIdentifier;
|
||||
await sleep(this.transitionTime + 500);
|
||||
if (
|
||||
route.status === expect &&
|
||||
identifier === route.stopIdentifier
|
||||
) {
|
||||
transition.ticker.remove(tick);
|
||||
if (route.status === AudioStatus.Playing) {
|
||||
gain.setVolume(1);
|
||||
} else {
|
||||
gain.setVolume(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
route.onStart(async () => {
|
||||
transition.transition('volume', 1);
|
||||
setTick(AudioStatus.Playing);
|
||||
});
|
||||
route.onEnd(() => {
|
||||
transition.transition('volume', 0);
|
||||
setTick(AudioStatus.Paused);
|
||||
});
|
||||
route.setEndTime(this.transitionTime);
|
||||
|
||||
this.gain.set(id, { effect: gain, transition });
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放一个 bgm
|
||||
* @param id 要播放的 bgm 名称
|
||||
*/
|
||||
play(id: T, when?: number) {
|
||||
if (this.blocking) return;
|
||||
if (id !== this.playingBgm && this.playingBgm) {
|
||||
this.player.pause(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playingBgm = id;
|
||||
if (!this.enabled) return;
|
||||
this.player.play(this.getId(id), when);
|
||||
this.playing = true;
|
||||
this.emit('play');
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续当前的 bgm
|
||||
*/
|
||||
resume() {
|
||||
if (this.blocking || !this.enabled || this.playing) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.resume(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = true;
|
||||
this.emit('resume');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停当前的 bgm
|
||||
*/
|
||||
pause() {
|
||||
if (this.blocking || !this.enabled) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.pause(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = false;
|
||||
this.emit('pause');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前的 bgm
|
||||
*/
|
||||
stop() {
|
||||
if (this.blocking || !this.enabled) return;
|
||||
if (this.playingBgm) {
|
||||
this.player.stop(this.getId(this.playingBgm));
|
||||
}
|
||||
this.playing = false;
|
||||
this.emit('stop');
|
||||
}
|
||||
}
|
||||
|
||||
export const bgmController = new BgmController<BgmIds>(audioPlayer);
|
||||
|
||||
export function loadAllBgm() {
|
||||
const { loading } = Mota.require('@user/data-base');
|
||||
loading.once('coreInit', () => {
|
||||
const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
|
||||
for (const bgm of data.main.bgms) {
|
||||
bgmController.addBgm(bgm);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import { OggVorbisDecoderWebWorker } from '@wasm-audio-decoders/ogg-vorbis';
|
||||
import { OggOpusDecoderWebWorker } from 'ogg-opus-decoder';
|
||||
import { AudioType, isAudioSupport } from './support';
|
||||
import type { AudioPlayer } from './player';
|
||||
|
||||
const fileSignatures: [AudioType, number[]][] = [
|
||||
[AudioType.Mp3, [0x49, 0x44, 0x33]],
|
||||
[AudioType.Ogg, [0x4f, 0x67, 0x67, 0x53]],
|
||||
[AudioType.Wav, [0x52, 0x49, 0x46, 0x46]],
|
||||
[AudioType.Flac, [0x66, 0x4c, 0x61, 0x43]],
|
||||
[AudioType.Aac, [0xff, 0xf1]],
|
||||
[AudioType.Aac, [0xff, 0xf9]]
|
||||
];
|
||||
const oggHeaders: [AudioType, number[]][] = [
|
||||
[AudioType.Opus, [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]]
|
||||
];
|
||||
|
||||
export function checkAudioType(data: Uint8Array) {
|
||||
let audioType: AudioType | '' = '';
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
for (const [type, value] of fileSignatures) {
|
||||
if (value.every((v, i) => toCheck[i] === v)) {
|
||||
audioType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (audioType === AudioType.Ogg) {
|
||||
// 如果是ogg的话,进一步判断是不是opus
|
||||
for (const [key, value] of oggHeaders) {
|
||||
const has = toCheck.some((_, i) => {
|
||||
return value.every((v, ii) => toCheck[i + ii] === v);
|
||||
});
|
||||
if (has) {
|
||||
audioType = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return audioType;
|
||||
}
|
||||
|
||||
export interface IAudioDecodeError {
|
||||
/** 错误信息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IAudioDecodeData {
|
||||
/** 每个声道的音频信息 */
|
||||
channelData: Float32Array<ArrayBuffer>[];
|
||||
/** 已经被解码的 PCM 采样数 */
|
||||
samplesDecoded: number;
|
||||
/** 音频采样率 */
|
||||
sampleRate: number;
|
||||
/** 解码错误信息 */
|
||||
errors: IAudioDecodeError[];
|
||||
}
|
||||
|
||||
export abstract class AudioDecoder {
|
||||
static readonly decoderMap: Map<AudioType, new () => AudioDecoder> =
|
||||
new Map();
|
||||
|
||||
/**
|
||||
* 注册一个解码器
|
||||
* @param type 要注册的解码器允许解码的类型
|
||||
* @param decoder 解码器对象
|
||||
*/
|
||||
static registerDecoder(type: AudioType, decoder: new () => AudioDecoder) {
|
||||
if (this.decoderMap.has(type)) {
|
||||
logger.warn(47, type);
|
||||
return;
|
||||
}
|
||||
this.decoderMap.set(type, decoder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码音频数据
|
||||
* @param data 音频文件数据
|
||||
* @param player AudioPlayer实例
|
||||
*/
|
||||
static async decodeAudioData(data: Uint8Array, player: AudioPlayer) {
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
const type = checkAudioType(data);
|
||||
if (type === '') {
|
||||
logger.error(
|
||||
25,
|
||||
[...toCheck]
|
||||
.map(v => v.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
.toUpperCase()
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (isAudioSupport(type)) {
|
||||
if (data.buffer instanceof ArrayBuffer) {
|
||||
return player.ac.decodeAudioData(data.buffer);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const Decoder = this.decoderMap.get(type);
|
||||
if (!Decoder) {
|
||||
return null;
|
||||
} else {
|
||||
const decoder = new Decoder();
|
||||
await decoder.create();
|
||||
const decodedData = await decoder.decodeAll(data);
|
||||
if (!decodedData) return null;
|
||||
const buffer = player.ac.createBuffer(
|
||||
decodedData.channelData.length,
|
||||
decodedData.channelData[0].length,
|
||||
decodedData.sampleRate
|
||||
);
|
||||
decodedData.channelData.forEach((v, i) => {
|
||||
buffer.copyToChannel(v, i);
|
||||
});
|
||||
decoder.destroy();
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建音频解码器
|
||||
*/
|
||||
abstract create(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 摧毁这个解码器
|
||||
*/
|
||||
abstract destroy(): void;
|
||||
|
||||
/**
|
||||
* 解码流数据
|
||||
* @param data 流数据
|
||||
*/
|
||||
abstract decode(data: Uint8Array): Promise<IAudioDecodeData | undefined>;
|
||||
|
||||
/**
|
||||
* 解码整个文件
|
||||
* @param data 文件数据
|
||||
*/
|
||||
abstract decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined>;
|
||||
|
||||
/**
|
||||
* 当音频解码完成后,会调用此函数,需要返回之前还未解析或未返回的音频数据。调用后,该解码器将不会被再次使用
|
||||
*/
|
||||
abstract flush(): Promise<IAudioDecodeData | undefined>;
|
||||
}
|
||||
|
||||
export class VorbisDecoder extends AudioDecoder {
|
||||
decoder?: OggVorbisDecoderWebWorker;
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.decoder = new OggVorbisDecoderWebWorker();
|
||||
await this.decoder.ready;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.decoder?.free();
|
||||
}
|
||||
|
||||
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decode(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decodeFile(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.flush() as Promise<IAudioDecodeData>;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpusDecoder extends AudioDecoder {
|
||||
decoder?: OggOpusDecoderWebWorker;
|
||||
|
||||
async create(): Promise<void> {
|
||||
this.decoder = new OggOpusDecoderWebWorker({
|
||||
speechQualityEnhancement: 'none'
|
||||
});
|
||||
await this.decoder.ready;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.decoder?.free();
|
||||
}
|
||||
|
||||
async decode(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decode(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async decodeAll(data: Uint8Array): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.decodeFile(data) as Promise<IAudioDecodeData>;
|
||||
}
|
||||
|
||||
async flush(): Promise<IAudioDecodeData | undefined> {
|
||||
return this.decoder?.flush() as Promise<IAudioDecodeData>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { isNil } from 'lodash-es';
|
||||
import { sleep } from 'mutate-animate';
|
||||
|
||||
export interface IAudioInput {
|
||||
/** 输入节点 */
|
||||
input: AudioNode;
|
||||
}
|
||||
|
||||
export interface IAudioOutput {
|
||||
/** 输出节点 */
|
||||
output: AudioNode;
|
||||
}
|
||||
|
||||
export abstract class AudioEffect implements IAudioInput, IAudioOutput {
|
||||
/** 输出节点 */
|
||||
abstract output: AudioNode;
|
||||
/** 输入节点 */
|
||||
abstract input: AudioNode;
|
||||
|
||||
constructor(public readonly ac: AudioContext) {}
|
||||
|
||||
/**
|
||||
* 当音频播放结束时触发,可以用于节点结束后处理
|
||||
*/
|
||||
abstract end(): void;
|
||||
|
||||
/**
|
||||
* 当音频开始播放时触发,可以用于节点初始化
|
||||
*/
|
||||
abstract start(): void;
|
||||
|
||||
/**
|
||||
* 连接至其他效果器
|
||||
* @param target 目标输入
|
||||
* @param output 当前效果器输出通道
|
||||
* @param input 目标效果器的输入通道
|
||||
*/
|
||||
connect(target: IAudioInput, output?: number, input?: number) {
|
||||
this.output.connect(target.input, output, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与其他效果器取消连接
|
||||
* @param target 目标输入
|
||||
* @param output 当前效果器输出通道
|
||||
* @param input 目标效果器的输入通道
|
||||
*/
|
||||
disconnect(target?: IAudioInput, output?: number, input?: number) {
|
||||
if (!target) {
|
||||
if (!isNil(output)) {
|
||||
this.output.disconnect(output);
|
||||
} else {
|
||||
this.output.disconnect();
|
||||
}
|
||||
} else {
|
||||
if (!isNil(output)) {
|
||||
if (!isNil(input)) {
|
||||
this.output.disconnect(target.input, output, input);
|
||||
} else {
|
||||
this.output.disconnect(target.input, output);
|
||||
}
|
||||
} else {
|
||||
this.output.disconnect(target.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class StereoEffect extends AudioEffect {
|
||||
output: PannerNode;
|
||||
input: PannerNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const panner = ac.createPanner();
|
||||
this.input = panner;
|
||||
this.output = panner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频朝向,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 朝向x坐标
|
||||
* @param y 朝向y坐标
|
||||
* @param z 朝向z坐标
|
||||
*/
|
||||
setOrientation(x: number, y: number, z: number) {
|
||||
this.output.orientationX.value = x;
|
||||
this.output.orientationY.value = y;
|
||||
this.output.orientationZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频位置,x正方形水平向右,y正方形垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 位置x坐标
|
||||
* @param y 位置y坐标
|
||||
* @param z 位置z坐标
|
||||
*/
|
||||
setPosition(x: number, y: number, z: number) {
|
||||
this.output.positionX.value = x;
|
||||
this.output.positionY.value = y;
|
||||
this.output.positionZ.value = z;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class VolumeEffect extends AudioEffect {
|
||||
output: GainNode;
|
||||
input: GainNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const gain = ac.createGain();
|
||||
this.input = gain;
|
||||
this.output = gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.output.gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量大小
|
||||
*/
|
||||
getVolume(): number {
|
||||
return this.output.gain.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class ChannelVolumeEffect extends AudioEffect {
|
||||
output: ChannelMergerNode;
|
||||
input: ChannelSplitterNode;
|
||||
|
||||
/** 所有的音量控制节点 */
|
||||
private readonly gain: GainNode[] = [];
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const splitter = ac.createChannelSplitter();
|
||||
const merger = ac.createChannelMerger();
|
||||
this.output = merger;
|
||||
this.input = splitter;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const gain = ac.createGain();
|
||||
splitter.connect(gain, i);
|
||||
gain.connect(merger, 0, i);
|
||||
this.gain.push(gain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置某个声道的音量大小
|
||||
* @param channel 要设置的声道,可填0-5
|
||||
* @param volume 这个声道的音量大小
|
||||
*/
|
||||
setVolume(channel: number, volume: number) {
|
||||
if (!this.gain[channel]) return;
|
||||
this.gain[channel].gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某个声道的音量大小,可填0-5
|
||||
* @param channel 要获取的声道
|
||||
*/
|
||||
getVolume(channel: number): number {
|
||||
if (!this.gain[channel]) return 0;
|
||||
return this.gain[channel].gain.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class DelayEffect extends AudioEffect {
|
||||
output: DelayNode;
|
||||
input: DelayNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const delay = ac.createDelay();
|
||||
this.input = delay;
|
||||
this.output = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置延迟时长
|
||||
* @param delay 延迟时长,单位秒
|
||||
*/
|
||||
setDelay(delay: number) {
|
||||
this.output.delayTime.value = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取延迟时长
|
||||
*/
|
||||
getDelay() {
|
||||
return this.output.delayTime.value;
|
||||
}
|
||||
|
||||
end(): void {}
|
||||
|
||||
start(): void {}
|
||||
}
|
||||
|
||||
export class EchoEffect extends AudioEffect {
|
||||
output: GainNode;
|
||||
input: GainNode;
|
||||
|
||||
/** 延迟节点 */
|
||||
private readonly delay: DelayNode;
|
||||
/** 反馈增益节点 */
|
||||
private readonly gainNode: GainNode;
|
||||
/** 当前增益 */
|
||||
private gain: number = 0.5;
|
||||
/** 是否正在播放 */
|
||||
private playing: boolean = false;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
const delay = ac.createDelay();
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = 0.5;
|
||||
delay.delayTime.value = 0.05;
|
||||
delay.connect(gain);
|
||||
gain.connect(delay);
|
||||
this.delay = delay;
|
||||
this.gainNode = gain;
|
||||
this.input = gain;
|
||||
this.output = gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置回声反馈增益大小
|
||||
* @param gain 增益大小,范围 0-1,大于等于1的视为0.5,小于0的视为0
|
||||
*/
|
||||
setFeedbackGain(gain: number) {
|
||||
const resolved = gain >= 1 ? 0.5 : gain < 0 ? 0 : gain;
|
||||
this.gain = resolved;
|
||||
if (this.playing) this.gainNode.gain.value = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置回声间隔时长
|
||||
* @param delay 回声时长,范围 0.01-Infinity,小于0.01的视为0.01
|
||||
*/
|
||||
setEchoDelay(delay: number) {
|
||||
const resolved = delay < 0.01 ? 0.01 : delay;
|
||||
this.delay.delayTime.value = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取反馈节点增益
|
||||
*/
|
||||
getFeedbackGain() {
|
||||
return this.gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回声间隔时长
|
||||
*/
|
||||
getEchoDelay() {
|
||||
return this.delay.delayTime.value;
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.playing = false;
|
||||
const echoTime = Math.ceil(Math.log(0.001) / Math.log(this.gain)) + 10;
|
||||
sleep(this.delay.delayTime.value * echoTime).then(() => {
|
||||
if (!this.playing) this.gainNode.gain.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.playing = true;
|
||||
this.gainNode.gain.value = this.gain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { loadAllBgm } from './bgm';
|
||||
import { OpusDecoder, VorbisDecoder } from './decoder';
|
||||
import { AudioType } from './support';
|
||||
import { AudioDecoder } from './decoder';
|
||||
|
||||
export function createAudio() {
|
||||
loadAllBgm();
|
||||
AudioDecoder.registerDecoder(AudioType.Ogg, VorbisDecoder);
|
||||
AudioDecoder.registerDecoder(AudioType.Opus, OpusDecoder);
|
||||
}
|
||||
|
||||
export * from './support';
|
||||
export * from './effect';
|
||||
export * from './player';
|
||||
export * from './source';
|
||||
export * from './bgm';
|
||||
export * from './decoder';
|
||||
export * from './sound';
|
||||
@@ -0,0 +1,605 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import {
|
||||
AudioBufferSource,
|
||||
AudioElementSource,
|
||||
AudioSource,
|
||||
AudioStreamSource
|
||||
} from './source';
|
||||
import {
|
||||
AudioEffect,
|
||||
ChannelVolumeEffect,
|
||||
DelayEffect,
|
||||
EchoEffect,
|
||||
IAudioOutput,
|
||||
StereoEffect,
|
||||
VolumeEffect
|
||||
} from './effect';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { logger } from '@motajs/common';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { AudioDecoder } from './decoder';
|
||||
|
||||
interface AudioPlayerEvent {}
|
||||
|
||||
export class AudioPlayer extends EventEmitter<AudioPlayerEvent> {
|
||||
/** 音频播放上下文 */
|
||||
readonly ac: AudioContext;
|
||||
|
||||
/** 所有的音频播放路由 */
|
||||
readonly audioRoutes: Map<string, AudioRoute> = new Map();
|
||||
/** 音量节点 */
|
||||
readonly gain: GainNode;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.ac = new AudioContext();
|
||||
this.gain = this.ac.createGain();
|
||||
this.gain.connect(this.ac.destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码音频数据
|
||||
* @param data 音频数据
|
||||
*/
|
||||
decodeAudioData(data: Uint8Array) {
|
||||
return AudioDecoder.decodeAudioData(data, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量
|
||||
* @param volume 音量
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.gain.gain.value = volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量
|
||||
*/
|
||||
getVolume() {
|
||||
return this.gain.gain.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频源
|
||||
* @param Source 音频源类
|
||||
*/
|
||||
createSource<T extends AudioSource>(
|
||||
Source: new (ac: AudioContext) => T
|
||||
): T {
|
||||
return new Source(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个兼容流式音频源,可以与流式加载相结合,主要用于处理 opus ogg 不兼容的情况
|
||||
*/
|
||||
createStreamSource() {
|
||||
return new AudioStreamSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个通过 audio 元素播放的音频源
|
||||
*/
|
||||
createElementSource() {
|
||||
return new AudioElementSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个通过 AudioBuffer 播放的音频源
|
||||
*/
|
||||
createBufferSource() {
|
||||
return new AudioBufferSource(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频目的地
|
||||
*/
|
||||
getDestination() {
|
||||
return this.gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频效果器
|
||||
* @param Effect 效果器类
|
||||
*/
|
||||
createEffect<T extends AudioEffect>(
|
||||
Effect: new (ac: AudioContext) => T
|
||||
): T {
|
||||
return new Effect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个修改音量的效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* Input ----> | GainNode | ----> Output
|
||||
* |----------|
|
||||
* ```
|
||||
*/
|
||||
createVolumeEffect() {
|
||||
return new VolumeEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个立体声效果器
|
||||
* ```txt
|
||||
* |------------|
|
||||
* Input ----> | PannerNode | ----> Output
|
||||
* |------------|
|
||||
* ```
|
||||
*/
|
||||
createStereoEffect() {
|
||||
return new StereoEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个修改单个声道音量的效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* -> | GainNode | \
|
||||
* |--------------| / |----------| -> |------------|
|
||||
* Input ----> | SplitterNode | ...... | MergerNode | ----> Output
|
||||
* |--------------| \ |----------| -> |------------|
|
||||
* -> | GainNode | /
|
||||
* |----------|
|
||||
* ```
|
||||
*/
|
||||
createChannelVolumeEffect() {
|
||||
return new ChannelVolumeEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个延迟效果器
|
||||
* ```txt
|
||||
* |-----------|
|
||||
* Input ----> | DelayNode | ----> Output
|
||||
* |-----------|
|
||||
* ```
|
||||
*/
|
||||
createDelayEffect() {
|
||||
return new DelayEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个回声效果器
|
||||
* ```txt
|
||||
* |----------|
|
||||
* Input ----> | GainNode | ----> Output
|
||||
* ^ |----------| |
|
||||
* | |
|
||||
* | |------------| ↓
|
||||
* |-- | Delay Node | <--
|
||||
* |------------|
|
||||
* ```
|
||||
*/
|
||||
createEchoEffect() {
|
||||
return new EchoEffect(this.ac);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个音频播放路由
|
||||
* @param source 音频源
|
||||
*/
|
||||
createRoute(source: AudioSource) {
|
||||
return new AudioRoute(source, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个音频播放路由,可以直接被播放
|
||||
* @param id 这个音频播放路由的名称
|
||||
* @param route 音频播放路由对象
|
||||
*/
|
||||
addRoute(id: string, route: AudioRoute) {
|
||||
if (this.audioRoutes.has(id)) {
|
||||
logger.warn(45, id);
|
||||
}
|
||||
this.audioRoutes.set(id, route);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据名称获取音频播放路由对象
|
||||
* @param id 音频播放路由的名称
|
||||
*/
|
||||
getRoute(id: string) {
|
||||
return this.audioRoutes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个音频播放路由
|
||||
* @param id 要移除的播放路由的名称
|
||||
*/
|
||||
removeRoute(id: string) {
|
||||
const route = this.audioRoutes.get(id);
|
||||
if (route) {
|
||||
route.destroy();
|
||||
}
|
||||
this.audioRoutes.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音频
|
||||
* @param id 音频名称
|
||||
* @param when 从音频的哪个位置开始播放,单位秒
|
||||
*/
|
||||
play(id: string, when: number = 0) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'play', id);
|
||||
return;
|
||||
}
|
||||
route.play(when);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停音频播放
|
||||
* @param id 音频名称
|
||||
* @returns 当音乐真正停止时兑现
|
||||
*/
|
||||
pause(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'pause', id);
|
||||
return;
|
||||
}
|
||||
return route.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止音频播放
|
||||
* @param id 音频名称
|
||||
* @returns 当音乐真正停止时兑现
|
||||
*/
|
||||
stop(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'stop', id);
|
||||
return;
|
||||
}
|
||||
return route.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续音频播放
|
||||
* @param id 音频名称
|
||||
*/
|
||||
resume(id: string) {
|
||||
const route = this.getRoute(id);
|
||||
if (!route) {
|
||||
logger.warn(53, 'resume', id);
|
||||
return;
|
||||
}
|
||||
route.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者位置,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 位置x坐标
|
||||
* @param y 位置y坐标
|
||||
* @param z 位置z坐标
|
||||
*/
|
||||
setListenerPosition(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.positionX.value = x;
|
||||
listener.positionY.value = y;
|
||||
listener.positionZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 朝向x坐标
|
||||
* @param y 朝向y坐标
|
||||
* @param z 朝向z坐标
|
||||
*/
|
||||
setListenerOrientation(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.forwardX.value = x;
|
||||
listener.forwardY.value = y;
|
||||
listener.forwardZ.value = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置听者头顶朝向,x正方向水平向右,y正方向垂直于地面向上,z正方向垂直屏幕远离用户
|
||||
* @param x 头顶朝向x坐标
|
||||
* @param y 头顶朝向y坐标
|
||||
* @param z 头顶朝向z坐标
|
||||
*/
|
||||
setListenerUp(x: number, y: number, z: number) {
|
||||
const listener = this.ac.listener;
|
||||
listener.upX.value = x;
|
||||
listener.upY.value = y;
|
||||
listener.upZ.value = z;
|
||||
}
|
||||
}
|
||||
|
||||
export const enum AudioStatus {
|
||||
Playing,
|
||||
Pausing,
|
||||
Paused,
|
||||
Stoping,
|
||||
Stoped
|
||||
}
|
||||
|
||||
type AudioStartHook = (route: AudioRoute) => void;
|
||||
type AudioEndHook = (time: number, route: AudioRoute) => void;
|
||||
|
||||
interface AudioRouteEvent {
|
||||
updateEffect: [];
|
||||
play: [];
|
||||
stop: [];
|
||||
pause: [];
|
||||
resume: [];
|
||||
}
|
||||
|
||||
export class AudioRoute
|
||||
extends EventEmitter<AudioRouteEvent>
|
||||
implements IAudioOutput
|
||||
{
|
||||
output: AudioNode;
|
||||
|
||||
/** 效果器路由图 */
|
||||
readonly effectRoute: AudioEffect[] = [];
|
||||
|
||||
/** 结束时长,当音频暂停或停止时,会经过这么长时间之后才真正终止播放,期间可以做音频淡入淡出等效果 */
|
||||
endTime: number = 0;
|
||||
|
||||
/** 当前播放状态 */
|
||||
status: AudioStatus = AudioStatus.Stoped;
|
||||
/** 暂停时刻 */
|
||||
private pauseTime: number = 0;
|
||||
/** 暂停时播放了多长时间 */
|
||||
private pauseCurrentTime: number = 0;
|
||||
|
||||
/** 音频时长,单位秒 */
|
||||
get duration() {
|
||||
return this.source.duration;
|
||||
}
|
||||
/** 当前播放了多长时间,单位秒 */
|
||||
get currentTime() {
|
||||
if (this.status === AudioStatus.Paused) {
|
||||
return this.pauseCurrentTime;
|
||||
} else {
|
||||
return this.source.currentTime;
|
||||
}
|
||||
}
|
||||
set currentTime(time: number) {
|
||||
this.source.stop();
|
||||
this.source.play(time);
|
||||
}
|
||||
|
||||
private shouldStop: boolean = false;
|
||||
/**
|
||||
* 每次暂停或停止时自增,用于判断当前正在处理的情况。
|
||||
* 假如暂停后很快播放,然后很快暂停,那么需要根据这个来判断实际是否应该执行暂停后操作
|
||||
*/
|
||||
stopIdentifier: number = 0;
|
||||
|
||||
private audioStartHook?: AudioStartHook;
|
||||
private audioEndHook?: AudioEndHook;
|
||||
|
||||
constructor(
|
||||
public readonly source: AudioSource,
|
||||
public readonly player: AudioPlayer
|
||||
) {
|
||||
super();
|
||||
this.output = source.output;
|
||||
source.on('end', () => {
|
||||
if (this.status === AudioStatus.Playing) {
|
||||
this.status = AudioStatus.Stoped;
|
||||
}
|
||||
});
|
||||
source.on('play', () => {
|
||||
if (this.status !== AudioStatus.Playing) {
|
||||
this.status = AudioStatus.Playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置结束时间,暂停或停止时,会经过这么长时间才终止音频的播放,这期间可以做一下音频淡出的效果。
|
||||
* @param time 暂停或停止时,经过多长时间之后才会结束音频的播放
|
||||
*/
|
||||
setEndTime(time: number) {
|
||||
this.endTime = time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当音频播放时执行的函数,可以用于音频淡入效果
|
||||
* @param fn 音频开始播放时执行的函数
|
||||
*/
|
||||
onStart(fn?: AudioStartHook) {
|
||||
this.audioStartHook = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当音频暂停或停止时执行的函数,可以用于音频淡出效果
|
||||
* @param fn 音频在暂停或停止时执行的函数,不填时表示取消这个钩子。
|
||||
* 包含两个参数,第一个参数是结束时长,第二个参数是当前音频播放路由对象
|
||||
*/
|
||||
onEnd(fn?: AudioEndHook) {
|
||||
this.audioEndHook = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始播放这个音频
|
||||
* @param when 从音频的什么时候开始播放,单位秒
|
||||
*/
|
||||
async play(when: number = 0) {
|
||||
if (this.status === AudioStatus.Playing) return;
|
||||
this.link();
|
||||
await this.player.ac.resume();
|
||||
if (this.effectRoute.length > 0) {
|
||||
const first = this.effectRoute[0];
|
||||
this.source.connect(first);
|
||||
const last = this.effectRoute.at(-1)!;
|
||||
last.connect({ input: this.player.getDestination() });
|
||||
} else {
|
||||
this.source.connect({ input: this.player.getDestination() });
|
||||
}
|
||||
this.source.play(when);
|
||||
this.status = AudioStatus.Playing;
|
||||
this.pauseTime = 0;
|
||||
this.audioStartHook?.(this);
|
||||
this.startAllEffect();
|
||||
this.emit('play');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停音频播放
|
||||
*/
|
||||
async pause() {
|
||||
if (this.status !== AudioStatus.Playing) return;
|
||||
this.status = AudioStatus.Pausing;
|
||||
this.stopIdentifier++;
|
||||
const identifier = this.stopIdentifier;
|
||||
if (this.audioEndHook) {
|
||||
this.audioEndHook(this.endTime, this);
|
||||
await sleep(this.endTime);
|
||||
}
|
||||
if (
|
||||
this.status !== AudioStatus.Pausing ||
|
||||
this.stopIdentifier !== identifier
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pauseCurrentTime = this.source.currentTime;
|
||||
const time = this.source.stop();
|
||||
this.pauseTime = time;
|
||||
if (this.shouldStop) {
|
||||
this.status = AudioStatus.Stoped;
|
||||
this.endAllEffect();
|
||||
this.emit('stop');
|
||||
this.shouldStop = false;
|
||||
} else {
|
||||
this.status = AudioStatus.Paused;
|
||||
this.endAllEffect();
|
||||
this.emit('pause');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续音频播放
|
||||
*/
|
||||
resume() {
|
||||
if (this.status === AudioStatus.Playing) return;
|
||||
if (
|
||||
this.status === AudioStatus.Pausing ||
|
||||
this.status === AudioStatus.Stoping
|
||||
) {
|
||||
this.audioStartHook?.(this);
|
||||
this.emit('resume');
|
||||
return;
|
||||
}
|
||||
if (this.status === AudioStatus.Paused) {
|
||||
this.play(this.pauseTime);
|
||||
} else {
|
||||
this.play(0);
|
||||
}
|
||||
this.status = AudioStatus.Playing;
|
||||
this.pauseTime = 0;
|
||||
this.audioStartHook?.(this);
|
||||
this.startAllEffect();
|
||||
this.emit('resume');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止音频播放
|
||||
*/
|
||||
async stop() {
|
||||
if (this.status !== AudioStatus.Playing) {
|
||||
if (this.status === AudioStatus.Pausing) {
|
||||
this.shouldStop = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.status = AudioStatus.Stoping;
|
||||
this.stopIdentifier++;
|
||||
const identifier = this.stopIdentifier;
|
||||
if (this.audioEndHook) {
|
||||
this.audioEndHook(this.endTime, this);
|
||||
await sleep(this.endTime);
|
||||
}
|
||||
if (
|
||||
this.status !== AudioStatus.Stoping ||
|
||||
this.stopIdentifier !== identifier
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.source.stop();
|
||||
this.status = AudioStatus.Stoped;
|
||||
this.pauseTime = 0;
|
||||
this.endAllEffect();
|
||||
this.emit('stop');
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加效果器
|
||||
* @param effect 要添加的效果,可以是数组,表示一次添加多个
|
||||
* @param index 从哪个位置开始添加,如果大于数组长度,那么加到末尾,如果小于0,那么将会从后面往前数。默认添加到末尾
|
||||
*/
|
||||
addEffect(effect: AudioEffect | AudioEffect[], index?: number) {
|
||||
if (isNil(index)) {
|
||||
if (effect instanceof Array) {
|
||||
this.effectRoute.push(...effect);
|
||||
} else {
|
||||
this.effectRoute.push(effect);
|
||||
}
|
||||
} else {
|
||||
if (effect instanceof Array) {
|
||||
this.effectRoute.splice(index, 0, ...effect);
|
||||
} else {
|
||||
this.effectRoute.splice(index, 0, effect);
|
||||
}
|
||||
}
|
||||
this.setOutput();
|
||||
if (this.source.playing) this.link();
|
||||
this.emit('updateEffect');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个效果器
|
||||
* @param effect 要移除的效果
|
||||
*/
|
||||
removeEffect(effect: AudioEffect) {
|
||||
const index = this.effectRoute.indexOf(effect);
|
||||
if (index === -1) return;
|
||||
this.effectRoute.splice(index, 1);
|
||||
effect.disconnect();
|
||||
this.setOutput();
|
||||
if (this.source.playing) this.link();
|
||||
this.emit('updateEffect');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.effectRoute.forEach(v => v.disconnect());
|
||||
}
|
||||
|
||||
private setOutput() {
|
||||
const effect = this.effectRoute.at(-1);
|
||||
if (!effect) this.output = this.source.output;
|
||||
else this.output = effect.output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接音频路由图
|
||||
*/
|
||||
private link() {
|
||||
this.effectRoute.forEach(v => v.disconnect());
|
||||
this.effectRoute.forEach((v, i) => {
|
||||
const next = this.effectRoute[i + 1];
|
||||
if (next) {
|
||||
v.connect(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startAllEffect() {
|
||||
this.effectRoute.forEach(v => v.start());
|
||||
}
|
||||
|
||||
private endAllEffect() {
|
||||
this.effectRoute.forEach(v => v.end());
|
||||
}
|
||||
}
|
||||
|
||||
export const audioPlayer = new AudioPlayer();
|
||||
// window.audioPlayer = audioPlayer;
|
||||
@@ -0,0 +1,135 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { audioPlayer, AudioPlayer } from './player';
|
||||
import { logger } from '@motajs/common';
|
||||
import { VolumeEffect } from './effect';
|
||||
|
||||
type LocationArray = [number, number, number];
|
||||
|
||||
interface SoundPlayerEvent {}
|
||||
|
||||
export class SoundPlayer<
|
||||
T extends string = SoundIds
|
||||
> extends EventEmitter<SoundPlayerEvent> {
|
||||
/** 每个音效的唯一标识符 */
|
||||
private num: number = 0;
|
||||
|
||||
/** 每个音效的数据 */
|
||||
readonly buffer: Map<T, AudioBuffer> = new Map();
|
||||
/** 所有正在播放的音乐 */
|
||||
readonly playing: Set<number> = new Set();
|
||||
/** 音量节点 */
|
||||
readonly gain: VolumeEffect;
|
||||
|
||||
/** 是否已经启用 */
|
||||
enabled: boolean = true;
|
||||
|
||||
constructor(public readonly player: AudioPlayer) {
|
||||
super();
|
||||
this.gain = player.createVolumeEffect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用音效
|
||||
* @param enabled 是否启用音效
|
||||
*/
|
||||
setEnabled(enabled: boolean) {
|
||||
if (!enabled) this.stopAllSounds();
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音量大小
|
||||
* @param volume 音量大小
|
||||
*/
|
||||
setVolume(volume: number) {
|
||||
this.gain.setVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音量大小
|
||||
*/
|
||||
getVolume() {
|
||||
return this.gain.getVolume();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个音效
|
||||
* @param id 音效名称
|
||||
* @param data 音效的Uint8Array数据
|
||||
*/
|
||||
async add(id: T, data: Uint8Array) {
|
||||
const buffer = await this.player.decodeAudioData(data);
|
||||
if (!buffer) {
|
||||
logger.warn(51, id);
|
||||
return;
|
||||
}
|
||||
this.buffer.set(id, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放一个音效
|
||||
* @param id 音效名称
|
||||
* @param position 音频位置,[0, 0, 0]表示正中心,x轴指向水平向右,y轴指向水平向上,z轴指向竖直向上
|
||||
* @param orientation 音频朝向,[0, 1, 0]表示朝向前方
|
||||
*/
|
||||
play(
|
||||
id: T,
|
||||
position: LocationArray = [0, 0, 0],
|
||||
orientation: LocationArray = [1, 0, 0]
|
||||
) {
|
||||
if (!this.enabled) return -1;
|
||||
const buffer = this.buffer.get(id);
|
||||
if (!buffer) {
|
||||
logger.warn(52, id);
|
||||
return -1;
|
||||
}
|
||||
const soundNum = this.num++;
|
||||
const source = this.player.createBufferSource();
|
||||
source.setBuffer(buffer);
|
||||
const route = this.player.createRoute(source);
|
||||
const stereo = this.player.createStereoEffect();
|
||||
stereo.setPosition(position[0], position[1], position[2]);
|
||||
stereo.setOrientation(orientation[0], orientation[1], orientation[2]);
|
||||
route.addEffect([stereo, this.gain]);
|
||||
this.player.addRoute(`sounds.${soundNum}`, route);
|
||||
route.play();
|
||||
// 清理垃圾
|
||||
source.output.addEventListener('ended', () => {
|
||||
this.playing.delete(soundNum);
|
||||
this.player.removeRoute(`sounds.${soundNum}`);
|
||||
});
|
||||
this.playing.add(soundNum);
|
||||
return soundNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止一个音效
|
||||
* @param num 音效的唯一 id
|
||||
*/
|
||||
stop(num: number) {
|
||||
const id = `sounds.${num}`;
|
||||
const route = this.player.getRoute(id);
|
||||
if (route) {
|
||||
route.stop();
|
||||
this.player.removeRoute(id);
|
||||
this.playing.delete(num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止播放所有音效
|
||||
*/
|
||||
stopAllSounds() {
|
||||
this.playing.forEach(v => {
|
||||
const id = `sounds.${v}`;
|
||||
const route = this.player.getRoute(id);
|
||||
if (route) {
|
||||
route.stop();
|
||||
this.player.removeRoute(id);
|
||||
}
|
||||
});
|
||||
this.playing.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const soundPlayer = new SoundPlayer<SoundIds>(audioPlayer);
|
||||
@@ -0,0 +1,562 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { IStreamController, IStreamReader } from '../loader';
|
||||
import { IAudioInput, IAudioOutput } from './effect';
|
||||
import { logger } from '@motajs/common';
|
||||
import { AudioType } from './support';
|
||||
import CodecParser, { CodecFrame, MimeType, OggPage } from 'codec-parser';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { IAudioDecodeData, AudioDecoder, checkAudioType } from './decoder';
|
||||
|
||||
interface AudioSourceEvent {
|
||||
play: [];
|
||||
end: [];
|
||||
}
|
||||
|
||||
export abstract class AudioSource
|
||||
extends EventEmitter<AudioSourceEvent>
|
||||
implements IAudioOutput
|
||||
{
|
||||
/** 音频源的输出节点 */
|
||||
abstract readonly output: AudioNode;
|
||||
|
||||
/** 是否正在播放 */
|
||||
playing: boolean = false;
|
||||
|
||||
/** 获取音频时长 */
|
||||
abstract get duration(): number;
|
||||
/** 获取当前音频播放了多长时间 */
|
||||
abstract get currentTime(): number;
|
||||
|
||||
constructor(public readonly ac: AudioContext) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始播放这个音频源
|
||||
*/
|
||||
abstract play(when?: number): void;
|
||||
|
||||
/**
|
||||
* 停止播放这个音频源
|
||||
* @returns 音频暂停的时刻
|
||||
*/
|
||||
abstract stop(): number;
|
||||
|
||||
/**
|
||||
* 连接到音频路由图上,每次调用播放的时候都会执行一次
|
||||
* @param target 连接至的目标
|
||||
*/
|
||||
abstract connect(target: IAudioInput): void;
|
||||
|
||||
/**
|
||||
* 设置是否循环播放
|
||||
* @param loop 是否循环
|
||||
*/
|
||||
abstract setLoop(loop: boolean): void;
|
||||
}
|
||||
|
||||
const mimeTypeMap: Record<AudioType, MimeType> = {
|
||||
[AudioType.Aac]: 'audio/aac',
|
||||
[AudioType.Flac]: 'audio/flac',
|
||||
[AudioType.Mp3]: 'audio/mpeg',
|
||||
[AudioType.Ogg]: 'application/ogg',
|
||||
[AudioType.Opus]: 'application/ogg',
|
||||
[AudioType.Wav]: 'application/ogg'
|
||||
};
|
||||
|
||||
function isOggPage(data: any): data is OggPage {
|
||||
return !isNil(data.isFirstPage);
|
||||
}
|
||||
|
||||
export class AudioStreamSource extends AudioSource implements IStreamReader {
|
||||
output: AudioBufferSourceNode;
|
||||
|
||||
/** 音频数据 */
|
||||
buffer?: AudioBuffer;
|
||||
|
||||
/** 是否已经完全加载完毕 */
|
||||
loaded: boolean = false;
|
||||
/** 已经缓冲了多长时间,如果缓冲完那么跟歌曲时长一致 */
|
||||
buffered: number = 0;
|
||||
/** 已经缓冲的采样点数量 */
|
||||
bufferedSamples: number = 0;
|
||||
/** 歌曲时长,加载完毕之前保持为 0 */
|
||||
duration: number = 0;
|
||||
/** 当前已经播放了多长时间 */
|
||||
get currentTime(): number {
|
||||
return this.ac.currentTime - this.lastStartTime + this.lastStartWhen;
|
||||
}
|
||||
/** 在流传输阶段,至少缓冲多长时间的音频之后才开始播放,单位秒 */
|
||||
bufferPlayDuration: number = 1;
|
||||
/** 音频的采样率,未成功解析出之前保持为 0 */
|
||||
sampleRate: number = 0;
|
||||
|
||||
private controller?: IStreamController;
|
||||
private loop: boolean = false;
|
||||
|
||||
private target?: IAudioInput;
|
||||
|
||||
/** 上一次播放是从何时开始的 */
|
||||
private lastStartWhen: number = 0;
|
||||
/** 开始播放时刻 */
|
||||
private lastStartTime: number = 0;
|
||||
/** 上一次播放的缓存长度 */
|
||||
private lastBufferSamples: number = 0;
|
||||
|
||||
/** 是否已经获取到头文件 */
|
||||
private headerRecieved: boolean = false;
|
||||
/** 音频类型 */
|
||||
private audioType: AudioType | '' = '';
|
||||
/** 音频解码器 */
|
||||
private decoder?: AudioDecoder;
|
||||
/** 音频解析器 */
|
||||
private parser?: CodecParser;
|
||||
/** 每多长时间组成一个缓存 Float32Array */
|
||||
private bufferChunkSize: number = 10;
|
||||
/** 缓存音频数据,每 bufferChunkSize 秒钟组成一个 Float32Array,用于流式解码 */
|
||||
private audioData: Float32Array[][] = [];
|
||||
|
||||
private errored: boolean = false;
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
this.output = context.createBufferSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置每个缓存数据的大小,默认为10秒钟一个缓存数据
|
||||
* @param size 每个缓存数据的时长,单位秒
|
||||
*/
|
||||
setChunkSize(size: number) {
|
||||
if (this.controller?.loading || this.loaded) return;
|
||||
this.bufferChunkSize = size;
|
||||
}
|
||||
|
||||
piped(controller: IStreamController): void {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
async pump(data: Uint8Array | undefined, done: boolean): Promise<void> {
|
||||
if (!data || this.errored) return;
|
||||
if (!this.headerRecieved) {
|
||||
// 检查头文件获取音频类型,仅检查前256个字节
|
||||
const toCheck = data.slice(0, 256);
|
||||
this.audioType = checkAudioType(data);
|
||||
if (!this.audioType) {
|
||||
logger.error(
|
||||
25,
|
||||
[...toCheck]
|
||||
.map(v => v.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
.toUpperCase()
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 创建解码器
|
||||
const Decoder = AudioDecoder.decoderMap.get(this.audioType);
|
||||
if (!Decoder) {
|
||||
this.errored = true;
|
||||
logger.error(24, this.audioType);
|
||||
return Promise.reject(
|
||||
`Cannot decode stream source type of '${this.audioType}', since there is no registered decoder for that type.`
|
||||
);
|
||||
}
|
||||
this.decoder = new Decoder();
|
||||
// 创建数据解析器
|
||||
const mime = mimeTypeMap[this.audioType];
|
||||
const parser = new CodecParser(mime);
|
||||
this.parser = parser;
|
||||
await this.decoder.create();
|
||||
this.headerRecieved = true;
|
||||
}
|
||||
|
||||
const decoder = this.decoder;
|
||||
const parser = this.parser;
|
||||
if (!decoder || !parser) {
|
||||
this.errored = true;
|
||||
return Promise.reject(
|
||||
'No parser or decoder attached in this AudioStreamSource'
|
||||
);
|
||||
}
|
||||
|
||||
await this.decodeData(data, decoder, parser);
|
||||
if (done) await this.decodeFlushData(decoder, parser);
|
||||
this.checkBufferedPlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查采样率,如果还未解析出采样率,那么将设置采样率,如果当前采样率与之前不同,那么发出警告
|
||||
*/
|
||||
private checkSampleRate(info: (OggPage | CodecFrame)[]) {
|
||||
for (const one of info) {
|
||||
const frame = isOggPage(one) ? one.codecFrames[0] : one;
|
||||
if (frame) {
|
||||
const rate = frame.header.sampleRate;
|
||||
if (this.sampleRate === 0) {
|
||||
this.sampleRate = rate;
|
||||
break;
|
||||
} else {
|
||||
if (rate !== this.sampleRate) {
|
||||
logger.warn(48);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析音频数据
|
||||
*/
|
||||
private async decodeData(
|
||||
data: Uint8Array,
|
||||
decoder: AudioDecoder,
|
||||
parser: CodecParser
|
||||
) {
|
||||
// 解析音频数据
|
||||
const audioData = await decoder.decode(data);
|
||||
if (!audioData) return;
|
||||
// @ts-expect-error 库类型声明错误
|
||||
const audioInfo = [...parser.parseChunk(data)] as (
|
||||
| OggPage
|
||||
| CodecFrame
|
||||
)[];
|
||||
|
||||
// 检查采样率
|
||||
this.checkSampleRate(audioInfo);
|
||||
// 追加音频数据
|
||||
this.appendDecodedData(audioData, audioInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码剩余数据
|
||||
*/
|
||||
private async decodeFlushData(decoder: AudioDecoder, parser: CodecParser) {
|
||||
const audioData = await decoder.flush();
|
||||
if (!audioData) return;
|
||||
// @ts-expect-error 库类型声明错误
|
||||
const audioInfo = [...parser.flush()] as (OggPage | CodecFrame)[];
|
||||
|
||||
this.checkSampleRate(audioInfo);
|
||||
this.appendDecodedData(audioData, audioInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加音频数据
|
||||
*/
|
||||
private appendDecodedData(
|
||||
data: IAudioDecodeData,
|
||||
info: (CodecFrame | OggPage)[]
|
||||
) {
|
||||
const channels = data.channelData.length;
|
||||
if (channels === 0) return;
|
||||
if (this.audioData.length !== channels) {
|
||||
this.audioData = [];
|
||||
for (let i = 0; i < channels; i++) {
|
||||
this.audioData.push([]);
|
||||
}
|
||||
}
|
||||
// 计算出应该放在哪
|
||||
const chunk = this.sampleRate * this.bufferChunkSize;
|
||||
const sampled = this.bufferedSamples;
|
||||
const pushIndex = Math.floor(sampled / chunk);
|
||||
const bufferIndex = sampled % chunk;
|
||||
const dataLength = data.channelData[0].length;
|
||||
let buffered = 0;
|
||||
let nowIndex = pushIndex;
|
||||
let toBuffer = bufferIndex;
|
||||
while (buffered < dataLength) {
|
||||
const rest = toBuffer !== 0 ? chunk - bufferIndex : chunk;
|
||||
|
||||
for (let i = 0; i < channels; i++) {
|
||||
const audioData = this.audioData[i];
|
||||
if (!audioData[nowIndex]) {
|
||||
audioData.push(new Float32Array(chunk));
|
||||
}
|
||||
const toPush = data.channelData[i].slice(
|
||||
buffered,
|
||||
buffered + rest
|
||||
);
|
||||
|
||||
audioData[nowIndex].set(toPush, toBuffer);
|
||||
}
|
||||
buffered += rest;
|
||||
nowIndex++;
|
||||
toBuffer = 0;
|
||||
}
|
||||
|
||||
this.buffered +=
|
||||
info.reduce((prev, curr) => prev + curr.duration, 0) / 1000;
|
||||
this.bufferedSamples += info.reduce(
|
||||
(prev, curr) => prev + curr.samples,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查已缓冲内容,并在未开始播放时播放
|
||||
*/
|
||||
private checkBufferedPlay() {
|
||||
if (this.playing || this.sampleRate === 0) return;
|
||||
const played = this.lastBufferSamples / this.sampleRate;
|
||||
const dt = this.buffered - played;
|
||||
if (this.loaded) {
|
||||
this.playAudio(played);
|
||||
return;
|
||||
}
|
||||
if (dt < this.bufferPlayDuration) return;
|
||||
this.lastBufferSamples = this.bufferedSamples;
|
||||
// 需要播放
|
||||
this.mergeBuffers();
|
||||
if (!this.buffer) return;
|
||||
if (this.playing) this.output.stop();
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.loop = false;
|
||||
this.output.start(0, played);
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
this.playing = true;
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.checkBufferedPlay();
|
||||
});
|
||||
}
|
||||
|
||||
private mergeBuffers() {
|
||||
const buffer = this.ac.createBuffer(
|
||||
this.audioData.length,
|
||||
this.bufferedSamples,
|
||||
this.sampleRate
|
||||
);
|
||||
const chunk = this.sampleRate * this.bufferChunkSize;
|
||||
const bufferedChunks = Math.floor(this.bufferedSamples / chunk);
|
||||
const restLength = this.bufferedSamples % chunk;
|
||||
for (let i = 0; i < this.audioData.length; i++) {
|
||||
const audio = this.audioData[i];
|
||||
const data = new Float32Array(this.bufferedSamples);
|
||||
for (let j = 0; j < bufferedChunks; j++) {
|
||||
data.set(audio[j], chunk * j);
|
||||
}
|
||||
if (restLength !== 0) {
|
||||
data.set(
|
||||
audio[bufferedChunks].slice(0, restLength),
|
||||
chunk * bufferedChunks
|
||||
);
|
||||
}
|
||||
|
||||
buffer.copyToChannel(data, i, 0);
|
||||
}
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
async start() {
|
||||
delete this.buffer;
|
||||
this.headerRecieved = false;
|
||||
this.audioType = '';
|
||||
this.errored = false;
|
||||
this.buffered = 0;
|
||||
this.sampleRate = 0;
|
||||
this.bufferedSamples = 0;
|
||||
this.duration = 0;
|
||||
this.loaded = false;
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
}
|
||||
|
||||
end(done: boolean, reason?: string): void {
|
||||
if (done && this.buffer) {
|
||||
this.loaded = true;
|
||||
delete this.controller;
|
||||
this.mergeBuffers();
|
||||
this.duration = this.buffered;
|
||||
this.audioData = [];
|
||||
this.decoder?.destroy();
|
||||
delete this.decoder;
|
||||
delete this.parser;
|
||||
} else {
|
||||
logger.warn(44, reason ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
private playAudio(when?: number) {
|
||||
if (!this.buffer) return;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
if (this.playing) this.output.stop();
|
||||
this.emit('play');
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.start(0, when);
|
||||
this.playing = true;
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
if (this.loop && !this.output.loop) this.play(0);
|
||||
});
|
||||
}
|
||||
|
||||
play(when?: number): void {
|
||||
if (this.playing || this.errored) return;
|
||||
if (this.loaded && this.buffer) {
|
||||
this.playing = true;
|
||||
this.playAudio(when);
|
||||
} else {
|
||||
this.controller?.start();
|
||||
}
|
||||
}
|
||||
|
||||
private createSourceNode(buffer: AudioBuffer) {
|
||||
if (!this.target) return;
|
||||
const node = this.ac.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
this.output = node;
|
||||
node.connect(this.target.input);
|
||||
node.loop = this.loop;
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
if (this.playing) this.output.stop();
|
||||
this.playing = false;
|
||||
return this.ac.currentTime - this.lastStartTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.loop = loop;
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioElementSource extends AudioSource {
|
||||
output: MediaElementAudioSourceNode;
|
||||
|
||||
/** audio 元素 */
|
||||
readonly audio: HTMLAudioElement;
|
||||
|
||||
get duration(): number {
|
||||
return this.audio.duration;
|
||||
}
|
||||
get currentTime(): number {
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
const audio = new Audio();
|
||||
audio.preload = 'none';
|
||||
this.output = context.createMediaElementSource(audio);
|
||||
this.audio = audio;
|
||||
audio.addEventListener('play', () => {
|
||||
this.playing = true;
|
||||
this.emit('play');
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频源的路径
|
||||
* @param url 音频路径
|
||||
*/
|
||||
setSource(url: string) {
|
||||
this.audio.src = url;
|
||||
}
|
||||
|
||||
play(when: number = 0): void {
|
||||
if (this.playing) return;
|
||||
this.audio.currentTime = when;
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
this.audio.pause();
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.output.connect(target.input);
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.audio.loop = loop;
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioBufferSource extends AudioSource {
|
||||
output: AudioBufferSourceNode;
|
||||
|
||||
/** 音频数据 */
|
||||
buffer?: AudioBuffer;
|
||||
/** 是否循环 */
|
||||
private loop: boolean = false;
|
||||
|
||||
duration: number = 0;
|
||||
get currentTime(): number {
|
||||
return this.ac.currentTime - this.lastStartTime + this.lastStartWhen;
|
||||
}
|
||||
|
||||
/** 上一次播放是从何时开始的 */
|
||||
private lastStartWhen: number = 0;
|
||||
/** 播放开始时刻 */
|
||||
private lastStartTime: number = 0;
|
||||
private target?: IAudioInput;
|
||||
|
||||
constructor(context: AudioContext) {
|
||||
super(context);
|
||||
this.output = context.createBufferSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频源数据
|
||||
* @param buffer 音频源,可以是未解析的 ArrayBuffer,也可以是已解析的 AudioBuffer
|
||||
*/
|
||||
async setBuffer(buffer: ArrayBuffer | AudioBuffer) {
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
this.buffer = await this.ac.decodeAudioData(buffer);
|
||||
} else {
|
||||
this.buffer = buffer;
|
||||
}
|
||||
this.duration = this.buffer.duration;
|
||||
}
|
||||
|
||||
play(when?: number): void {
|
||||
if (this.playing || !this.buffer) return;
|
||||
this.playing = true;
|
||||
this.lastStartTime = this.ac.currentTime;
|
||||
this.emit('play');
|
||||
this.createSourceNode(this.buffer);
|
||||
this.output.start(0, when);
|
||||
this.output.addEventListener('ended', () => {
|
||||
this.playing = false;
|
||||
this.emit('end');
|
||||
if (this.loop && !this.output.loop) this.play(0);
|
||||
});
|
||||
}
|
||||
|
||||
private createSourceNode(buffer: AudioBuffer) {
|
||||
if (!this.target) return;
|
||||
const node = this.ac.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
this.output = node;
|
||||
node.connect(this.target.input);
|
||||
node.loop = this.loop;
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
this.output.stop();
|
||||
return this.ac.currentTime - this.lastStartTime;
|
||||
}
|
||||
|
||||
connect(target: IAudioInput): void {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
setLoop(loop: boolean): void {
|
||||
this.loop = loop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const audio = new Audio();
|
||||
|
||||
const supportMap = new Map<string, boolean>();
|
||||
|
||||
export const enum AudioType {
|
||||
Mp3 = 'audio/mpeg',
|
||||
Wav = 'audio/wav; codecs="1"',
|
||||
Flac = 'audio/flac',
|
||||
Opus = 'audio/ogg; codecs="opus"',
|
||||
Ogg = 'audio/ogg; codecs="vorbis"',
|
||||
Aac = 'audio/aac'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一种音频类型是否能被播放
|
||||
* @param type 音频类型
|
||||
*/
|
||||
export function isAudioSupport(type: AudioType): boolean {
|
||||
if (supportMap.has(type)) return supportMap.get(type)!;
|
||||
else {
|
||||
const support = audio.canPlayType(type);
|
||||
const canPlay = support === 'maybe' || support === 'probably';
|
||||
supportMap.set(type, canPlay);
|
||||
return canPlay;
|
||||
}
|
||||
}
|
||||
|
||||
const typeMap = new Map<string, AudioType>([
|
||||
['ogg', AudioType.Ogg],
|
||||
['mp3', AudioType.Mp3],
|
||||
['wav', AudioType.Wav],
|
||||
['flac', AudioType.Flac],
|
||||
['opus', AudioType.Opus],
|
||||
['aac', AudioType.Aac]
|
||||
]);
|
||||
|
||||
/**
|
||||
* 根据文件名拓展猜测其类型
|
||||
* @param file 文件名
|
||||
*/
|
||||
export function guessTypeByExt(file: string): AudioType | '' {
|
||||
const ext = /\.[a-zA-Z\d]+$/.exec(file);
|
||||
if (!ext?.[0]) return '';
|
||||
const type = ext[0].slice(1);
|
||||
return typeMap.get(type.toLocaleLowerCase()) ?? '';
|
||||
}
|
||||
|
||||
isAudioSupport(AudioType.Ogg);
|
||||
isAudioSupport(AudioType.Mp3);
|
||||
isAudioSupport(AudioType.Wav);
|
||||
isAudioSupport(AudioType.Flac);
|
||||
isAudioSupport(AudioType.Opus);
|
||||
isAudioSupport(AudioType.Aac);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Patch, PatchClass } from '@motajs/legacy-common';
|
||||
import { audioPlayer, bgmController, soundPlayer } from '../audio';
|
||||
import { mainSetting } from '@motajs/legacy-ui';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
// todo: 添加弃用警告 logger.warn(56)
|
||||
|
||||
export function patchAudio() {
|
||||
const patch = new Patch(PatchClass.Control);
|
||||
|
||||
const play = (bgm: BgmIds, when?: number) => {
|
||||
bgmController.play(bgm, when);
|
||||
};
|
||||
const pause = () => {
|
||||
bgmController.pause();
|
||||
};
|
||||
|
||||
patch.add('playBgm', function (bgm, startTime) {
|
||||
const name = core.getMappedName(bgm) as BgmIds;
|
||||
play(name, startTime);
|
||||
});
|
||||
patch.add('pauseBgm', function () {
|
||||
pause();
|
||||
});
|
||||
patch.add('resumeBgm', function () {
|
||||
bgmController.resume();
|
||||
});
|
||||
patch.add('checkBgm', function () {
|
||||
if (bgmController.playing) return;
|
||||
if (mainSetting.getValue('audio.bgmEnabled')) {
|
||||
if (bgmController.playingBgm) {
|
||||
bgmController.play(bgmController.playingBgm);
|
||||
} else {
|
||||
play(main.startBgm, 0);
|
||||
}
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
});
|
||||
patch.add('triggerBgm', function () {
|
||||
if (bgmController.playing) bgmController.pause();
|
||||
else bgmController.resume();
|
||||
});
|
||||
|
||||
patch.add(
|
||||
'playSound',
|
||||
function (sound, _pitch, callback, position, orientation) {
|
||||
const name = core.getMappedName(sound) as SoundIds;
|
||||
const num = soundPlayer.play(name, position, orientation);
|
||||
const route = audioPlayer.getRoute(`sounds.${num}`);
|
||||
if (!route) {
|
||||
callback?.();
|
||||
return -1;
|
||||
} else {
|
||||
sleep(route.duration).then(() => callback?.());
|
||||
return num;
|
||||
}
|
||||
}
|
||||
);
|
||||
patch.add('stopSound', function (id) {
|
||||
if (isNil(id)) {
|
||||
soundPlayer.stopAllSounds();
|
||||
} else {
|
||||
soundPlayer.stop(id);
|
||||
}
|
||||
});
|
||||
patch.add('getPlayingSounds', function () {
|
||||
return [...soundPlayer.playing];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { patchAudio } from './audio';
|
||||
import { patchWeather } from './weather';
|
||||
import { patchUI } from './ui';
|
||||
|
||||
export function patchAll() {
|
||||
patchAudio();
|
||||
patchWeather();
|
||||
patchUI();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Patch, PatchClass } from '@motajs/legacy-common';
|
||||
import { TipStore } from '../render/components/tip';
|
||||
|
||||
export function patchUI() {
|
||||
const patch = new Patch(PatchClass.UI);
|
||||
|
||||
patch.add('drawTip', function (text, id) {
|
||||
const tip = TipStore.get('main-tip');
|
||||
tip?.drawTip(text, id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Patch, PatchClass } from '@motajs/legacy-common';
|
||||
import { WeatherController } from '../render/weather';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
// todo: 添加弃用警告 logger.warn(56)
|
||||
|
||||
export function patchWeather() {
|
||||
const patch = new Patch(PatchClass.Control);
|
||||
let nowWeather: string = '';
|
||||
let nowLevel: number = 0;
|
||||
|
||||
patch.add('setWeather', (type, level) => {
|
||||
const weather = WeatherController.get('main');
|
||||
if (!weather) return;
|
||||
if (type === nowWeather && level === nowLevel) return;
|
||||
weather.clearWeather();
|
||||
if (!isNil(type)) {
|
||||
weather.activate(type, level);
|
||||
nowWeather = type;
|
||||
nowLevel = level ?? 5;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { loading } from '@user/data-base';
|
||||
import { createAudio } from './audio';
|
||||
import { patchAll } from './fallback';
|
||||
import { createGameRenderer, createRender } from './render';
|
||||
|
||||
export function create() {
|
||||
patchAll();
|
||||
createAudio();
|
||||
createRender();
|
||||
loading.once('coreInit', () => {
|
||||
createGameRenderer();
|
||||
});
|
||||
}
|
||||
|
||||
export * from './action';
|
||||
export * from './audio';
|
||||
export * from './fallback';
|
||||
export * from './loader';
|
||||
export * from './render';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stream';
|
||||
@@ -0,0 +1,128 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
export interface IStreamController<T = void> {
|
||||
readonly loading: boolean;
|
||||
|
||||
/**
|
||||
* 开始流传输
|
||||
*/
|
||||
start(): Promise<T>;
|
||||
|
||||
/**
|
||||
* 主动终止流传输
|
||||
* @param reason 终止原因
|
||||
*/
|
||||
cancel(reason?: string): void;
|
||||
}
|
||||
|
||||
export interface IStreamReader<T = any> {
|
||||
/**
|
||||
* 接受字节流流传输的数据
|
||||
* @param data 传入的字节流数据,只包含本分块的内容
|
||||
* @param done 是否传输完成
|
||||
*/
|
||||
pump(
|
||||
data: Uint8Array | undefined,
|
||||
done: boolean,
|
||||
response: Response
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 当前对象被传递给加载流时执行的函数
|
||||
* @param controller 传输流控制对象
|
||||
*/
|
||||
piped(controller: IStreamController<T>): void;
|
||||
|
||||
/**
|
||||
* 开始流传输
|
||||
* @param stream 传输流对象
|
||||
* @param controller 传输流控制对象
|
||||
*/
|
||||
start(
|
||||
stream: ReadableStream,
|
||||
controller: IStreamController<T>,
|
||||
response: Response
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 结束流传输
|
||||
* @param done 是否传输完成,如果为 false 的话,说明可能是由于出现错误导致的终止
|
||||
* @param reason 如果没有传输完成,那么表示失败的原因
|
||||
*/
|
||||
end(done: boolean, reason?: string): void;
|
||||
}
|
||||
|
||||
interface StreamLoaderEvent {
|
||||
data: [data: Uint8Array | undefined, done: boolean];
|
||||
}
|
||||
|
||||
export class StreamLoader
|
||||
extends EventEmitter<StreamLoaderEvent>
|
||||
implements IStreamController<void>
|
||||
{
|
||||
/** 传输目标 */
|
||||
private target: Set<IStreamReader> = new Set();
|
||||
/** 读取流对象 */
|
||||
private stream?: ReadableStream;
|
||||
|
||||
loading: boolean = false;
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将加载流传递给字节流读取对象
|
||||
* @param reader 字节流读取对象
|
||||
*/
|
||||
pipe(reader: IStreamReader) {
|
||||
if (this.loading) {
|
||||
logger.warn(46);
|
||||
return;
|
||||
}
|
||||
this.target.add(reader);
|
||||
reader.piped(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
const response = await window.fetch(this.url);
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
logger.error(23);
|
||||
return;
|
||||
}
|
||||
// 获取读取器
|
||||
this.stream = stream;
|
||||
const reader = response.body?.getReader();
|
||||
const targets = [...this.target];
|
||||
await Promise.all(targets.map(v => v.start(stream, this, response)));
|
||||
if (reader && reader.read) {
|
||||
// 开始流传输
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
await Promise.all(
|
||||
targets.map(v => v.pump(value, done, response))
|
||||
);
|
||||
if (done) break;
|
||||
}
|
||||
} else {
|
||||
// 如果不支持流传输
|
||||
const buffer = await response.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
await Promise.all(targets.map(v => v.pump(data, true, response)));
|
||||
}
|
||||
this.loading = false;
|
||||
targets.forEach(v => v.end(true));
|
||||
}
|
||||
|
||||
cancel(reason?: string) {
|
||||
if (!this.stream) return;
|
||||
this.stream.cancel(reason);
|
||||
this.loading = false;
|
||||
this.target.forEach(v => v.end(false, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { gameKey } from '@motajs/system-action';
|
||||
import { POP_BOX_WIDTH, CENTER_LOC, FULL_LOC } from './shared';
|
||||
import {
|
||||
saveSave,
|
||||
mainUIController,
|
||||
saveLoad,
|
||||
openSettings,
|
||||
openViewMap,
|
||||
openReplay
|
||||
} from './ui';
|
||||
import { ElementLocator } from '@motajs/render-core';
|
||||
|
||||
export function createAction() {
|
||||
gameKey
|
||||
.realize('save', () => {
|
||||
saveSave(mainUIController, FULL_LOC);
|
||||
})
|
||||
.realize('load', () => {
|
||||
saveLoad(mainUIController, FULL_LOC);
|
||||
})
|
||||
.realize('menu', () => {
|
||||
const loc = CENTER_LOC.slice() as ElementLocator;
|
||||
loc[2] = POP_BOX_WIDTH;
|
||||
openSettings(mainUIController, loc);
|
||||
})
|
||||
.realize('replay', () => {
|
||||
const loc = CENTER_LOC.slice() as ElementLocator;
|
||||
loc[2] = POP_BOX_WIDTH;
|
||||
openReplay(mainUIController, loc);
|
||||
})
|
||||
.realize('viewMap', () => {
|
||||
openViewMap(mainUIController, FULL_LOC);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import { DefaultProps, ElementLocator, Font } from '@motajs/render';
|
||||
import { computed, defineComponent, reactive, ref } from 'vue';
|
||||
import { Background, Selection } from './misc';
|
||||
import { TextContent, TextContentProps } from './textbox';
|
||||
import { TextAlign } from './textboxTyper';
|
||||
import { Page, PageExpose } from './page';
|
||||
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { useKey } from '../use';
|
||||
import { sleep } from 'mutate-animate';
|
||||
|
||||
export interface ConfirmBoxProps extends DefaultProps, TextContentProps {
|
||||
/** 确认框的提示文本内容 */
|
||||
text: string;
|
||||
/** 确认框对话框的宽度 */
|
||||
width: number;
|
||||
/** 确认框对话框的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 确认/取消按钮的字体样式 */
|
||||
selFont?: Font;
|
||||
/** 确认/取消按钮的文本颜色 */
|
||||
selFill?: CanvasStyle;
|
||||
/** 对话框内部所有元素的内边距 */
|
||||
pad?: number;
|
||||
/** 确认按钮的显示文本,默认为"确认" */
|
||||
yesText?: string;
|
||||
/** 取消按钮的显示文本,默认为"取消" */
|
||||
noText?: string;
|
||||
/** 窗口皮肤图片ID,用于对话框背景绘制 */
|
||||
winskin?: ImageIds;
|
||||
/** 是否默认选中确认按钮 */
|
||||
defaultYes?: boolean;
|
||||
/** 对话框背景颜色,当未设置 winskin 时生效 */
|
||||
color?: CanvasStyle;
|
||||
/** 对话框边框颜色,当未设置 winskin 时生效 */
|
||||
border?: CanvasStyle;
|
||||
/** 按键作用域,如果需要同作用域按键,那么需要传入 */
|
||||
scope?: symbol;
|
||||
}
|
||||
|
||||
export type ConfirmBoxEmits = {
|
||||
yes: () => void;
|
||||
no: () => void;
|
||||
};
|
||||
|
||||
const confirmBoxProps = {
|
||||
props: [
|
||||
'text',
|
||||
'width',
|
||||
'loc',
|
||||
'selFont',
|
||||
'selFill',
|
||||
'pad',
|
||||
'yesText',
|
||||
'noText',
|
||||
'winskin',
|
||||
'defaultYes',
|
||||
'color',
|
||||
'border',
|
||||
'scope'
|
||||
],
|
||||
emits: ['no', 'yes']
|
||||
} satisfies SetupComponentOptions<
|
||||
ConfirmBoxProps,
|
||||
ConfirmBoxEmits,
|
||||
keyof ConfirmBoxEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 确认框组件,与 2.x 的 drawConfirm 类似,可以键盘操作,单次调用参考 {@link getConfirm}。
|
||||
* 参数参考 {@link ConfirmBoxProps},事件参考 {@link ConfirmBoxEmits},用例如下:
|
||||
* ```tsx
|
||||
* const onYes = () => console.log('yes');
|
||||
* const onNo = () => console.log('no');
|
||||
*
|
||||
* <ConfirmBox
|
||||
* text="是否要返回标题界面"
|
||||
* width={240}
|
||||
* // 确认框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 使用 winskin 图片作为背景
|
||||
* winskin="winskin.png"
|
||||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||||
* color="#333"
|
||||
* border="gold"
|
||||
* // 设置选项的字体
|
||||
* selFont={new Font('Verdana', 16)}
|
||||
* // 设置选项的文本颜色
|
||||
* selFill="#d48"
|
||||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||||
* font={new Font('arial')}
|
||||
* onYes={onYes}
|
||||
* onNo={onNo}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ConfirmBox = defineComponent<
|
||||
ConfirmBoxProps,
|
||||
ConfirmBoxEmits,
|
||||
keyof ConfirmBoxEmits
|
||||
>((props, { emit, attrs }) => {
|
||||
const height = ref(200);
|
||||
const selected = ref(props.defaultYes ? true : false);
|
||||
const yesSize = ref<[number, number]>([0, 0]);
|
||||
const noSize = ref<[number, number]>([0, 0]);
|
||||
|
||||
const loc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, height.value, ax, ay];
|
||||
});
|
||||
const yesText = computed(() => props.yesText ?? '确认');
|
||||
const noText = computed(() => props.noText ?? '取消');
|
||||
const pad = computed(() => props.pad ?? 24);
|
||||
const yesLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - pad.value;
|
||||
return [props.width / 3, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const noLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - pad.value;
|
||||
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
const width = props.width - pad.value * 2;
|
||||
return [props.width / 2, pad.value, width, 0, 0.5, 0];
|
||||
});
|
||||
const selectLoc = computed<ElementLocator>(() => {
|
||||
if (selected.value) {
|
||||
const [x = 0, y = 0] = yesLoc.value;
|
||||
const [width, height] = yesSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
} else {
|
||||
const [x = 0, y = 0] = noLoc.value;
|
||||
const [width, height] = noSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
}
|
||||
});
|
||||
|
||||
const onUpdateHeight = (textHeight: number) => {
|
||||
height.value = textHeight + pad.value * 4;
|
||||
};
|
||||
|
||||
const setYes = (_: string, width: number, height: number) => {
|
||||
yesSize.value = [width, height];
|
||||
};
|
||||
|
||||
const setNo = (_: string, width: number, height: number) => {
|
||||
noSize.value = [width, height];
|
||||
};
|
||||
|
||||
const [key] = useKey(false, props.scope);
|
||||
key.realize('confirm', () => {
|
||||
if (selected.value) emit('yes');
|
||||
else emit('no');
|
||||
});
|
||||
key.realize('moveLeft', () => void (selected.value = true));
|
||||
key.realize('moveRight', () => void (selected.value = false));
|
||||
|
||||
return () => (
|
||||
<container loc={loc.value}>
|
||||
<Background
|
||||
loc={[0, 0, props.width, height.value]}
|
||||
winskin={props.winskin}
|
||||
color={props.color ?? '#333'}
|
||||
border={props.border}
|
||||
zIndex={0}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
loc={contentLoc.value}
|
||||
text={props.text}
|
||||
width={props.width - pad.value * 2}
|
||||
zIndex={5}
|
||||
textAlign={TextAlign.Center}
|
||||
autoHeight
|
||||
onUpdateHeight={onUpdateHeight}
|
||||
/>
|
||||
<Selection
|
||||
loc={selectLoc.value}
|
||||
winskin={props.winskin}
|
||||
border={props.border}
|
||||
noevent
|
||||
zIndex={10}
|
||||
/>
|
||||
<text
|
||||
loc={yesLoc.value}
|
||||
text={yesText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={() => emit('yes')}
|
||||
onEnter={() => (selected.value = true)}
|
||||
onSetText={setYes}
|
||||
/>
|
||||
<text
|
||||
loc={noLoc.value}
|
||||
text={noText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={() => emit('no')}
|
||||
onEnter={() => (selected.value = false)}
|
||||
onSetText={setNo}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, confirmBoxProps);
|
||||
|
||||
export type ChoiceKey = string | number;
|
||||
export type ChoiceItem<T extends ChoiceKey = ChoiceKey> = [
|
||||
key: T,
|
||||
text: string
|
||||
];
|
||||
|
||||
export interface ChoicesProps extends DefaultProps, TextContentProps {
|
||||
/** 选项数组 */
|
||||
choices: ChoiceItem[];
|
||||
/** 选择框对话框的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 选择框对话框的宽度 */
|
||||
width: number;
|
||||
/** 选择框的最大高度,超过时将分页显示 */
|
||||
maxHeight?: number;
|
||||
/** 选择框的提示文本内容 */
|
||||
text?: string;
|
||||
/** 选择框的标题文本 */
|
||||
title?: string;
|
||||
/** 窗口皮肤图片ID,用于对话框背景绘制 */
|
||||
winskin?: ImageIds;
|
||||
/** 对话框背景颜色,当未设置 winskin 时生效 */
|
||||
color?: CanvasStyle;
|
||||
/** 对话框边框颜色,当未设置 winskin 时生效 */
|
||||
border?: CanvasStyle;
|
||||
/** 选项文本的字体样式 */
|
||||
selFont?: Font;
|
||||
/** 选项文本的颜色 */
|
||||
selFill?: CanvasStyle;
|
||||
/** 标题文本的字体样式 */
|
||||
titleFont?: Font;
|
||||
/** 标题文本的颜色 */
|
||||
titleFill?: CanvasStyle;
|
||||
/** 对话框内部所有元素的内边距 */
|
||||
pad?: number;
|
||||
/** 选项之间的垂直间隔 */
|
||||
interval?: number;
|
||||
/** 默认选中的选项索引 */
|
||||
selected?: number;
|
||||
/** 按键作用域,如果需要同作用域按键,那么需要传入,例如系统设置 UI */
|
||||
scope?: symbol;
|
||||
}
|
||||
|
||||
export type ChoicesEmits = {
|
||||
choose: (key: ChoiceKey) => void;
|
||||
};
|
||||
|
||||
const choicesProps = {
|
||||
props: [
|
||||
'choices',
|
||||
'loc',
|
||||
'width',
|
||||
'maxHeight',
|
||||
'text',
|
||||
'title',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'selFont',
|
||||
'selFill',
|
||||
'titleFont',
|
||||
'titleFill',
|
||||
'pad',
|
||||
'interval',
|
||||
'selected',
|
||||
'scope'
|
||||
],
|
||||
emits: ['choose']
|
||||
} satisfies SetupComponentOptions<
|
||||
ChoicesProps,
|
||||
ChoicesEmits,
|
||||
keyof ChoicesEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 选项框组件,用于在多个选项中选择一个,例如样板的系统设置就由它实现。单次调用参考 {@link getChoice}。
|
||||
* 参数参考 {@link ChoicesProps},事件参考 {@link ChoicesEmits}。用例如下:
|
||||
* ```tsx
|
||||
* <Choices
|
||||
* // 选项数组,每一项是一个二元素数组,第一项表示这个选项的 id,在选中时会以此作为参数传递给事件
|
||||
* // 第二项表示这一项的内容,即展示给玩家看的内容
|
||||
* choices={[[0, '选项1'], [100, '选项2']]}
|
||||
* // 选项框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* text="请选择一项"
|
||||
* title="选项"
|
||||
* // 使用 winskin 图片作为背景
|
||||
* winskin="winskin.png"
|
||||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||||
* color="#333"
|
||||
* border="gold"
|
||||
* // 调整每两个选项之间的间隔
|
||||
* interval={12}
|
||||
* // 设置选项的字体
|
||||
* selFont={new Font('Verdana', 16)}
|
||||
* // 设置选项的文本颜色
|
||||
* selFill="#d48"
|
||||
* // 设置标题的字体
|
||||
* titleFont={new Font('Verdana', 16)}
|
||||
* // 设置标题的文本颜色
|
||||
* selFill="gold"
|
||||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||||
* font={new Font('arial')}
|
||||
* // 当选择某一项时触发
|
||||
* onChoice={(choice) => console.log(choice)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Choices = defineComponent<
|
||||
ChoicesProps,
|
||||
ChoicesEmits,
|
||||
keyof ChoicesEmits
|
||||
>((props, { emit, attrs }) => {
|
||||
const titleHeight = ref(0);
|
||||
const contentHeight = ref(0);
|
||||
const selected = ref(props.selected ?? 0);
|
||||
const pageCom = ref<PageExpose>();
|
||||
const choiceSize = reactive<[number, number][]>([]);
|
||||
|
||||
const selFont = computed(() => props.selFont ?? new Font());
|
||||
const maxHeight = computed(() => props.maxHeight ?? 360);
|
||||
const pad = computed(() => props.pad ?? 28);
|
||||
const choiceInterval = computed(() => props.interval ?? 16);
|
||||
const hasText = computed(() => !!props.text);
|
||||
const hasTitle = computed(() => !!props.title);
|
||||
const contentWidth = computed(() => props.width - pad.value * 2);
|
||||
const choiceHeight = computed(
|
||||
() => selFont.value.size + 8 + choiceInterval.value
|
||||
);
|
||||
const contentY = computed(() => {
|
||||
if (hasTitle.value) {
|
||||
return pad.value * 2 + titleHeight.value;
|
||||
} else {
|
||||
return pad.value;
|
||||
}
|
||||
});
|
||||
const choicesY = computed(() => {
|
||||
const padding = pad.value;
|
||||
const text = hasText.value;
|
||||
let y = padding;
|
||||
if (hasTitle.value) {
|
||||
y += titleHeight.value;
|
||||
if (text) {
|
||||
y += padding / 2;
|
||||
} else {
|
||||
y += padding;
|
||||
}
|
||||
}
|
||||
if (text) {
|
||||
y += contentHeight.value;
|
||||
y += padding / 2;
|
||||
}
|
||||
return y;
|
||||
});
|
||||
const choicesMaxHeight = computed(
|
||||
() =>
|
||||
maxHeight.value -
|
||||
choicesY.value -
|
||||
pad.value * 2 -
|
||||
selFont.value.size -
|
||||
8
|
||||
);
|
||||
const choiceCountPerPage = computed(() =>
|
||||
Math.max(Math.floor(choicesMaxHeight.value / choiceHeight.value), 1)
|
||||
);
|
||||
const pages = computed(() =>
|
||||
Math.ceil(props.choices.length / choiceCountPerPage.value)
|
||||
);
|
||||
const choicesHeight = computed(() => {
|
||||
const padBottom = pages.value > 1 ? pad.value + selFont.value.size : 0;
|
||||
if (props.choices.length > choiceCountPerPage.value) {
|
||||
return choiceCountPerPage.value * choiceHeight.value + padBottom;
|
||||
} else {
|
||||
return props.choices.length * choiceHeight.value + padBottom;
|
||||
}
|
||||
});
|
||||
const boxHeight = computed(() => {
|
||||
if (props.choices.length > choiceCountPerPage.value) {
|
||||
return (
|
||||
choicesHeight.value +
|
||||
choicesY.value +
|
||||
// 不乘2是因为 choiceY 已经算上了顶部填充
|
||||
pad.value
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
choicesHeight.value +
|
||||
choicesY.value +
|
||||
// 不乘2是因为 choiceY 已经算上了顶部填充
|
||||
pad.value
|
||||
);
|
||||
}
|
||||
});
|
||||
const boxLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, boxHeight.value, ax, ay];
|
||||
});
|
||||
const titleLoc = computed<ElementLocator>(() => {
|
||||
return [props.width / 2, pad.value, void 0, void 0, 0.5, 0];
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
return [
|
||||
props.width / 2,
|
||||
contentY.value,
|
||||
contentWidth.value,
|
||||
void 0,
|
||||
0.5,
|
||||
0
|
||||
];
|
||||
});
|
||||
const choiceLoc = computed<ElementLocator>(() => {
|
||||
return [
|
||||
props.width / 2,
|
||||
choicesY.value,
|
||||
contentWidth.value,
|
||||
choicesHeight.value,
|
||||
0.5,
|
||||
0
|
||||
];
|
||||
});
|
||||
const selectionLoc = computed<ElementLocator>(() => {
|
||||
const [width = 200, height = 200] = choiceSize[selected.value] ?? [];
|
||||
return [
|
||||
props.width / 2 - pad.value,
|
||||
(selected.value + 0.5) * choiceHeight.value,
|
||||
width + 8,
|
||||
height + 8,
|
||||
0.5,
|
||||
0.5
|
||||
];
|
||||
});
|
||||
|
||||
const getPageContent = (page: number) => {
|
||||
const count = choiceCountPerPage.value;
|
||||
return props.choices.slice(page * count, (page + 1) * count);
|
||||
};
|
||||
|
||||
const getChoiceLoc = (index: number): ElementLocator => {
|
||||
return [
|
||||
props.width / 2 - pad.value,
|
||||
choiceHeight.value * (index + 0.5),
|
||||
void 0,
|
||||
void 0,
|
||||
0.5,
|
||||
0.5
|
||||
];
|
||||
};
|
||||
|
||||
const updateContentHeight = (height: number) => {
|
||||
contentHeight.value = height;
|
||||
};
|
||||
|
||||
const updateTitleHeight = (_0: string, _1: number, height: number) => {
|
||||
titleHeight.value = height;
|
||||
};
|
||||
|
||||
const updateChoiceSize = (index: number, width: number, height: number) => {
|
||||
choiceSize[index] = [width, height];
|
||||
};
|
||||
|
||||
const onPageChange = () => {
|
||||
selected.value = 0;
|
||||
};
|
||||
|
||||
const [key] = useKey(false, props.scope);
|
||||
key.realize('moveUp', () => {
|
||||
if (selected.value === 0) {
|
||||
if (pageCom.value?.now() !== 0) {
|
||||
pageCom.value?.movePage(-1);
|
||||
selected.value = choiceCountPerPage.value - 1;
|
||||
}
|
||||
} else {
|
||||
selected.value--;
|
||||
}
|
||||
});
|
||||
key.realize('moveDown', () => {
|
||||
const page = pageCom.value?.now() ?? 0;
|
||||
if (selected.value === choiceCountPerPage.value - 1) {
|
||||
if (page < pages.value - 1) {
|
||||
pageCom.value?.movePage(1);
|
||||
selected.value = 0;
|
||||
}
|
||||
} else {
|
||||
const index = page * choiceCountPerPage.value + selected.value;
|
||||
if (index < props.choices.length - 1) {
|
||||
selected.value++;
|
||||
}
|
||||
}
|
||||
});
|
||||
key.realize('moveLeft', () => pageCom.value?.movePage(-1));
|
||||
key.realize('moveRight', () => pageCom.value?.movePage(1));
|
||||
key.realize('confirm', () => {
|
||||
const page = pageCom.value?.now() ?? 0;
|
||||
const index = page * choiceCountPerPage.value + selected.value;
|
||||
emit('choose', props.choices[index][0]);
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container loc={boxLoc.value}>
|
||||
<Background
|
||||
loc={[0, 0, props.width, boxHeight.value]}
|
||||
winskin={props.winskin}
|
||||
color={props.color ?? '#333'}
|
||||
border={props.border}
|
||||
/>
|
||||
<text
|
||||
hidden={!hasTitle.value}
|
||||
loc={titleLoc.value}
|
||||
text={props.title}
|
||||
font={props.titleFont ?? new Font(void 0, 18)}
|
||||
fillStyle={props.titleFill ?? 'gold'}
|
||||
zIndex={5}
|
||||
onSetText={updateTitleHeight}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
hidden={!hasText.value}
|
||||
text={props.text}
|
||||
loc={contentLoc.value}
|
||||
width={contentWidth.value}
|
||||
zIndex={5}
|
||||
autoHeight
|
||||
onUpdateHeight={updateContentHeight}
|
||||
/>
|
||||
<Page
|
||||
ref={pageCom}
|
||||
loc={choiceLoc.value}
|
||||
pages={pages.value}
|
||||
font={props.selFont}
|
||||
hideIfSingle
|
||||
onPageChange={onPageChange}
|
||||
>
|
||||
{(page: number) => [
|
||||
<Selection
|
||||
loc={selectionLoc.value}
|
||||
winskin={props.winskin}
|
||||
color={props.color}
|
||||
border={props.border}
|
||||
/>,
|
||||
...getPageContent(page).map((v, i) => {
|
||||
return (
|
||||
<text
|
||||
text={v[1]}
|
||||
loc={getChoiceLoc(i)}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={5}
|
||||
fillStyle={props.selFill}
|
||||
onClick={() => emit('choose', v[0])}
|
||||
onSetText={(_, width, height) =>
|
||||
updateChoiceSize(i, width, height)
|
||||
}
|
||||
onEnter={() => (selected.value = i)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
]}
|
||||
</Page>
|
||||
</container>
|
||||
);
|
||||
}, choicesProps);
|
||||
|
||||
/**
|
||||
* 弹出一个确认框,然后将确认结果返回,例如给玩家弹出一个确认框,并获取玩家是否确认:
|
||||
* ```ts
|
||||
* const confirm = await getConfirm(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 确认内容
|
||||
* '确认要 xxx 吗?',
|
||||
* // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||||
* { font: new Font('Verdana', 20) }
|
||||
* );
|
||||
* // 之后,就可以直接判断 confirm 来执行不同的操作了
|
||||
* if (confirm) { ... }
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export function getConfirm(
|
||||
controller: IUIMountable,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: Partial<ConfirmBoxProps>
|
||||
) {
|
||||
return new Promise<boolean>(res => {
|
||||
const instance = controller.open(
|
||||
ConfirmBoxUI,
|
||||
{
|
||||
...(props ?? {}),
|
||||
loc,
|
||||
text,
|
||||
width,
|
||||
onYes: () => {
|
||||
controller.close(instance);
|
||||
res(true);
|
||||
},
|
||||
onNo: () => {
|
||||
controller.close(instance);
|
||||
res(false);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出一个选择框,然后将选择结果返回,例如给玩家弹出一个选择框,并获取玩家选择了哪个:
|
||||
* ```ts
|
||||
* const choice = await getChoice(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 选项内容,参考 Choices 的注释
|
||||
* [[0, '选项1'], [1, '选项2'], [2, '选项3']],
|
||||
* // 选择框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定标题,此项可选
|
||||
* { title: '选项标题' }
|
||||
* );
|
||||
* // 之后,就可以直接判断 choice 来执行不同的操作了
|
||||
* if (choice === 0) { ... }
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param choices 选择框的选项
|
||||
* @param loc 选择框的位置
|
||||
* @param width 选择框的宽度
|
||||
* @param props 额外的 props,参考 {@link ChoicesProps}
|
||||
*/
|
||||
export function getChoice<T extends ChoiceKey = ChoiceKey>(
|
||||
controller: IUIMountable,
|
||||
choices: ChoiceItem<T>[],
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: Partial<ChoicesProps>
|
||||
): Promise<T> {
|
||||
return new Promise<T>(res => {
|
||||
const instance = controller.open(
|
||||
ChoicesUI,
|
||||
{
|
||||
...(props ?? {}),
|
||||
choices,
|
||||
loc,
|
||||
width,
|
||||
onChoose: key => {
|
||||
controller.close(instance);
|
||||
res(key as T);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getChoiceRoute(defaults: number) {
|
||||
const route = core.status.replay.toReplay[0];
|
||||
if (!route.startsWith('choices:')) {
|
||||
return defaults;
|
||||
} else {
|
||||
return Number(route.slice(8));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出一个确认框,然后将确认结果返回,与 getConfirm 不同的是内置录像支持,如果这个选择框需要进录像,
|
||||
* 需要使用此方法。例如给玩家弹出一个确认框,并获取玩家是否确认:
|
||||
* ```ts
|
||||
* const confirm = await routedConfirm(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 确认内容
|
||||
* '确认要 xxx 吗?',
|
||||
* // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||||
* { font: new Font('Verdana', 20) }
|
||||
* );
|
||||
* // 之后,就可以直接判断 confirm 来执行不同的操作了
|
||||
* if (confirm) { ... }
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export async function routedConfirm(
|
||||
controller: IUIMountable,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: Partial<ConfirmBoxProps>
|
||||
) {
|
||||
if (core.isReplaying()) {
|
||||
const confirm = getChoiceRoute(1) === 0;
|
||||
const timeout = core.control.__replay_getTimeout();
|
||||
core.status.route.push(`choices:${confirm ? 0 : 1}`);
|
||||
core.status.replay.toReplay.shift();
|
||||
if (timeout === 0) return confirm;
|
||||
const instance = controller.open(ConfirmBoxUI, {
|
||||
...(props ?? {}),
|
||||
text,
|
||||
loc,
|
||||
width,
|
||||
defaultYes: confirm
|
||||
});
|
||||
await sleep(core.control.__replay_getTimeout());
|
||||
controller.close(instance);
|
||||
return confirm;
|
||||
} else {
|
||||
const confirm = await getConfirm(controller, text, loc, width, props);
|
||||
core.status.route.push(`choices:${confirm ? 0 : 1}`);
|
||||
return confirm;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出一个选择框,然后将选择结果返回,与 getChoice 不同的是内置录像支持,如果这个选择框需要进录像,
|
||||
* 需要使用此方法。例如给玩家弹出一个选择框,并获取玩家选择了哪个:
|
||||
* ```ts
|
||||
* const choice = await routedChoice(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 选项内容,参考 Choices 的注释
|
||||
* [[0, '选项1'], [1, '选项2'], [2, '选项3']],
|
||||
* // 选择框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定标题,此项可选
|
||||
* { title: '选项标题' }
|
||||
* );
|
||||
* // 之后,就可以直接判断 choice 来执行不同的操作了
|
||||
* if (choice === 0) { ... }
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param choices 选择框的选项
|
||||
* @param loc 选择框的位置
|
||||
* @param width 选择框的宽度
|
||||
* @param props 额外的 props,参考 {@link ChoicesProps}
|
||||
*/
|
||||
export async function routedChoices<T extends ChoiceKey>(
|
||||
controller: IUIMountable,
|
||||
choices: ChoiceItem<T>[],
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: Partial<ChoicesProps>
|
||||
): Promise<T> {
|
||||
if (core.isReplaying()) {
|
||||
const selected = getChoiceRoute(0);
|
||||
const timeout = core.control.__replay_getTimeout();
|
||||
core.status.route.push(`choices:${selected}`);
|
||||
core.status.replay.toReplay.shift();
|
||||
if (timeout === 0) return choices[selected][0];
|
||||
const instance = controller.open(ChoicesUI, {
|
||||
...(props ?? {}),
|
||||
choices,
|
||||
loc,
|
||||
width,
|
||||
selected
|
||||
});
|
||||
await sleep(core.control.__replay_getTimeout());
|
||||
controller.close(instance);
|
||||
return choices[selected][0];
|
||||
} else {
|
||||
const choice = await getChoice(controller, choices, loc, width, props);
|
||||
const index = choices.findIndex(v => v[0] === choice);
|
||||
core.status.route.push(`choices:${index}`);
|
||||
return choice;
|
||||
}
|
||||
}
|
||||
|
||||
/** @see {@link ConfirmBox} */
|
||||
export const ConfirmBoxUI = new GameUI('confirm-box', ConfirmBox);
|
||||
/** @see {@link Choices} */
|
||||
export const ChoicesUI = new GameUI('choices', Choices);
|
||||
@@ -0,0 +1,234 @@
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { clamp, isNil } from 'lodash-es';
|
||||
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
|
||||
import { Scroll, ScrollExpose } from './scroll';
|
||||
import { Font } from '@motajs/render-style';
|
||||
import { MotaOffscreenCanvas2D } from '@motajs/render-core';
|
||||
import {
|
||||
HALF_STATUS_WIDTH,
|
||||
STATUS_BAR_HEIGHT,
|
||||
STATUS_BAR_WIDTH
|
||||
} from '../shared';
|
||||
|
||||
const SCROLL_HEIGHT = STATUS_BAR_HEIGHT - 280;
|
||||
const HALF_HEIGHT = SCROLL_HEIGHT / 2;
|
||||
|
||||
export interface FloorSelectorProps extends DefaultProps {
|
||||
floors: FloorIds[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export type FloorSelectorEmits = {
|
||||
/**
|
||||
* 点击关闭按钮时触发
|
||||
*/
|
||||
close: () => void;
|
||||
|
||||
/**
|
||||
* 当选中的楼层改变时触发
|
||||
* @param floor 楼层索引
|
||||
* @param floorId 楼层 id
|
||||
*/
|
||||
update: (floor: number, floorId: FloorIds) => void;
|
||||
|
||||
'update:now': (value: number) => void;
|
||||
};
|
||||
|
||||
const floorSelectorProps = {
|
||||
props: ['floors', 'now'],
|
||||
emits: ['close', 'update', 'update:now']
|
||||
} satisfies SetupComponentOptions<
|
||||
FloorSelectorProps,
|
||||
FloorSelectorEmits,
|
||||
keyof FloorSelectorEmits
|
||||
>;
|
||||
|
||||
export const FloorSelector = defineComponent<
|
||||
FloorSelectorProps,
|
||||
FloorSelectorEmits,
|
||||
keyof FloorSelectorEmits
|
||||
>((props, { emit }) => {
|
||||
const listFont = new Font(Font.defaultFamily, 12);
|
||||
|
||||
/** 当前选中楼层,不反向 */
|
||||
const now = ref(props.now ?? 0);
|
||||
/** 当前鼠标选中楼层,反向 */
|
||||
const selList = ref(0);
|
||||
|
||||
const scrollRef = ref<ScrollExpose>();
|
||||
|
||||
const floors = computed(() => props.floors.toReversed());
|
||||
const floorId = computed(() => props.floors[now.value]);
|
||||
const floorName = computed(() => core.floors[floorId.value].title);
|
||||
|
||||
watch(
|
||||
() => props.now,
|
||||
value => {
|
||||
if (!isNil(value)) {
|
||||
changeTo(value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let gradient: CanvasGradient | null = null;
|
||||
|
||||
const getGradient = (ctx: CanvasRenderingContext2D) => {
|
||||
if (gradient) return gradient;
|
||||
gradient = ctx.createLinearGradient(0, 0, 0, SCROLL_HEIGHT);
|
||||
gradient.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
gradient.addColorStop(0.2, 'rgba(255,255,255,1)');
|
||||
gradient.addColorStop(0.8, 'rgba(255,255,255,1)');
|
||||
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
return gradient;
|
||||
};
|
||||
|
||||
const renderMask = (canvas: MotaOffscreenCanvas2D) => {
|
||||
const { ctx } = canvas;
|
||||
const gradient = getGradient(ctx);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 144, 200);
|
||||
};
|
||||
|
||||
const changeTo = (index: number, time: number = 500) => {
|
||||
// 取反是为了符合 2.x 的操作习惯
|
||||
const res = clamp(index, 0, floors.value.length - 1);
|
||||
const reversed = floors.value.length - res - 1;
|
||||
now.value = res;
|
||||
selList.value = reversed;
|
||||
const y = reversed * 24;
|
||||
scrollRef.value?.scrollTo(y, time);
|
||||
emit('update', now.value, floorId.value);
|
||||
emit('update:now', now.value);
|
||||
};
|
||||
|
||||
const changeFloor = (delta: number) => {
|
||||
changeTo(now.value + delta);
|
||||
};
|
||||
|
||||
const enterList = (index: number) => {
|
||||
selList.value = index;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
changeTo(now.value, 0);
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text={floorName.value}
|
||||
loc={[HALF_STATUS_WIDTH, 24]}
|
||||
anc={[0.5, 0.5]}
|
||||
/>
|
||||
<g-line line={[48, 40, STATUS_BAR_WIDTH - 48, 40]} lineWidth={1} />
|
||||
<g-line
|
||||
line={[
|
||||
48,
|
||||
STATUS_BAR_HEIGHT - 40,
|
||||
STATUS_BAR_WIDTH - 48,
|
||||
STATUS_BAR_HEIGHT - 40
|
||||
]}
|
||||
lineWidth={1}
|
||||
/>
|
||||
<text
|
||||
text="退出"
|
||||
loc={[90, STATUS_BAR_HEIGHT - 24]}
|
||||
anc={[0.5, 0.5]}
|
||||
cursor="pointer"
|
||||
onClick={close}
|
||||
/>
|
||||
<text
|
||||
text="「 上移十层 」"
|
||||
loc={[90, 70]}
|
||||
anc={[0.5, 0.5]}
|
||||
cursor="pointer"
|
||||
onClick={() => changeFloor(10)}
|
||||
/>
|
||||
<text
|
||||
text="「 上移一层 」"
|
||||
loc={[90, 110]}
|
||||
anc={[0.5, 0.5]}
|
||||
cursor="pointer"
|
||||
onClick={() => changeFloor(1)}
|
||||
/>
|
||||
<text
|
||||
text="「 下移一层 」"
|
||||
loc={[90, STATUS_BAR_HEIGHT - 110]}
|
||||
anc={[0.5, 0.5]}
|
||||
cursor="pointer"
|
||||
onClick={() => changeFloor(-1)}
|
||||
/>
|
||||
<text
|
||||
text="「 下移十层 」"
|
||||
loc={[90, STATUS_BAR_HEIGHT - 70]}
|
||||
anc={[0.5, 0.5]}
|
||||
cursor="pointer"
|
||||
onClick={() => changeFloor(-10)}
|
||||
/>
|
||||
<container loc={[0, 140, 144, SCROLL_HEIGHT]}>
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
loc={[0, 0, 144, SCROLL_HEIGHT]}
|
||||
noscroll
|
||||
zIndex={10}
|
||||
padEnd={HALF_HEIGHT - 12}
|
||||
>
|
||||
{floors.value.map((v, i, a) => {
|
||||
const floor = core.floors[v];
|
||||
const reversed = a.length - i - 1;
|
||||
const nowFloor = a.length - now.value - 1;
|
||||
const highlight = nowFloor === i || selList.value === i;
|
||||
const color = highlight ? '#fff' : '#aaa';
|
||||
const fill = highlight ? '#fff' : '#000';
|
||||
return (
|
||||
<container
|
||||
nocache
|
||||
loc={[0, i * 24 + HALF_HEIGHT - 12, 144, 24]}
|
||||
key={v}
|
||||
>
|
||||
<text
|
||||
cursor="pointer"
|
||||
text={floor.title}
|
||||
loc={[114, 12]}
|
||||
anc={[1, 0.5]}
|
||||
font={listFont}
|
||||
fillStyle={color}
|
||||
onEnter={() => enterList(i)}
|
||||
onLeave={() => enterList(nowFloor)}
|
||||
onClick={() => changeTo(reversed)}
|
||||
/>
|
||||
<g-circle
|
||||
stroke
|
||||
fill
|
||||
lineWidth={1}
|
||||
circle={[130, 12, 3]}
|
||||
strokeStyle={color}
|
||||
fillStyle={nowFloor === i ? fill : '#000'}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
<g-line
|
||||
line={[130, 0, 130, SCROLL_HEIGHT]}
|
||||
zIndex={5}
|
||||
lineWidth={1}
|
||||
strokeStyle="#aaa"
|
||||
/>
|
||||
<sprite
|
||||
zIndex={20}
|
||||
loc={[0, 0, 144, SCROLL_HEIGHT]}
|
||||
nocache
|
||||
noevent
|
||||
render={renderMask}
|
||||
composite="destination-in"
|
||||
/>
|
||||
</container>
|
||||
</container>
|
||||
);
|
||||
}, floorSelectorProps);
|
||||
@@ -0,0 +1,449 @@
|
||||
import { DefaultProps, ElementLocator, GraphicPropsBase } from '@motajs/render';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
DefineSetupFnComponent,
|
||||
onMounted,
|
||||
Ref,
|
||||
shallowRef,
|
||||
watch
|
||||
} from 'vue';
|
||||
|
||||
export interface IconsProps extends DefaultProps<GraphicPropsBase> {
|
||||
loc: ElementLocator;
|
||||
}
|
||||
|
||||
const iconsProps = {
|
||||
props: ['loc']
|
||||
} satisfies SetupComponentOptions<IconsProps>;
|
||||
|
||||
type IconLoc = [
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
cx: number,
|
||||
cy: number
|
||||
];
|
||||
type IconPad = [left: number, right: number, top: number, bottom: number];
|
||||
type PathGenerator = (width: number, height: number) => Path2D;
|
||||
type PadFn = (divisor: number) => IconPad;
|
||||
|
||||
/**
|
||||
* @param loc 图标位置信息
|
||||
* @param pad 计算图标外围填充
|
||||
*/
|
||||
type PathFn = (loc: IconLoc, pad: PadFn) => Path2D;
|
||||
|
||||
/**
|
||||
* 适配填充
|
||||
* @param x 左上角横坐标
|
||||
* @param y 左上角纵坐标
|
||||
* @param width 矩形宽度
|
||||
* @param height 矩形高度
|
||||
* @param divisor 填充除数,例如左边缘会变成 `x + width / divisor`
|
||||
*/
|
||||
function pad(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
divisor: number
|
||||
): IconPad {
|
||||
return [
|
||||
x + width / divisor, // left
|
||||
x + width - width / divisor, // right
|
||||
y + height / divisor, // top
|
||||
y + height - height / divisor // bottom
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配图标路径生成
|
||||
* @param aspect 宽高比
|
||||
* @param ref 路径响应式对象
|
||||
* @param fn 路径定义函数
|
||||
*/
|
||||
function adjustPath(
|
||||
aspect: number,
|
||||
ref: Ref<Path2D | undefined>,
|
||||
fn: PathFn
|
||||
): PathGenerator {
|
||||
let beforeWidth = 200;
|
||||
let beforeHeight = 200;
|
||||
let path: Path2D | undefined;
|
||||
return (width, height) => {
|
||||
if (width === beforeWidth && height === beforeHeight && path) {
|
||||
return path;
|
||||
}
|
||||
beforeWidth = width;
|
||||
beforeHeight = height;
|
||||
const eleAspect = width / height;
|
||||
let ox = 0;
|
||||
let oy = 0;
|
||||
let dw = 0;
|
||||
let dh = 0;
|
||||
if (aspect >= eleAspect) {
|
||||
ox = 0;
|
||||
dw = width;
|
||||
dh = width / aspect;
|
||||
oy = (height - dh) / 2;
|
||||
} else {
|
||||
oy = 0;
|
||||
dh = height;
|
||||
dw = height * aspect;
|
||||
ox = (width - dw) / 2;
|
||||
}
|
||||
const cx = ox + dw / 2;
|
||||
const cy = oy + dh / 2;
|
||||
path = fn([ox, oy, dw, dh, cx, cy], divisor =>
|
||||
pad(ox, oy, dw, dh, divisor)
|
||||
);
|
||||
ref.value = path;
|
||||
return path;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义一个描边图标
|
||||
* @param aspect 图标的宽高比
|
||||
* @param pathDef 图标的路径定义函数
|
||||
* @param props 图标组件定义的参数配置
|
||||
*/
|
||||
export function defineIcon<T extends IconsProps>(
|
||||
aspect: number,
|
||||
pathDef: PathFn,
|
||||
props: SetupComponentOptions<T> = iconsProps
|
||||
): DefineSetupFnComponent<T> {
|
||||
return defineComponent<T>(props => {
|
||||
const path = shallowRef<Path2D>();
|
||||
|
||||
const width = computed(() => props.loc[2] ?? 200);
|
||||
const height = computed(() => props.loc[3] ?? 200);
|
||||
const generatePath = adjustPath(aspect, path, pathDef);
|
||||
|
||||
watch(props, () => {
|
||||
generatePath(width.value, height.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
generatePath(width.value, height.value);
|
||||
});
|
||||
|
||||
return () => (
|
||||
<g-path
|
||||
loc={props.loc}
|
||||
path={path.value}
|
||||
stroke
|
||||
lineJoin="round"
|
||||
lineCap="round"
|
||||
></g-path>
|
||||
);
|
||||
}, props);
|
||||
}
|
||||
|
||||
export const RollbackIcon = defineIcon(1, loc => {
|
||||
const path = new Path2D();
|
||||
const [ox, oy, width, height] = loc;
|
||||
const arc = width / 10;
|
||||
const arrow = width / 10;
|
||||
const left = ox + width / 10;
|
||||
const top = oy + height / 5;
|
||||
const right = ox + width - width / 10;
|
||||
const bottom = oy + height - height / 5;
|
||||
const end = left + width / 4;
|
||||
path.moveTo(left, bottom);
|
||||
path.lineTo(right - arc, bottom);
|
||||
path.arcTo(right, bottom, right, bottom - arc, arc);
|
||||
path.lineTo(right, top + arc);
|
||||
path.arcTo(right, top, right - arc, top, arc);
|
||||
path.lineTo(end, top);
|
||||
path.moveTo(end + arrow, top - arrow);
|
||||
path.lineTo(end, top);
|
||||
path.lineTo(end + arrow, top + arrow);
|
||||
path.moveTo(left, top);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const RetweetIcon = defineIcon(1, loc => {
|
||||
const path = new Path2D();
|
||||
const [ox, oy, width, height] = loc;
|
||||
const arc = width / 10;
|
||||
const arrow = width / 10;
|
||||
const left = ox + width / 10;
|
||||
const top = oy + height / 5;
|
||||
const right = ox + width - width / 10;
|
||||
const bottom = oy + height - height / 5;
|
||||
const end = left + width / 2;
|
||||
path.moveTo(end, bottom);
|
||||
path.lineTo(left + arc, bottom);
|
||||
path.arcTo(left, bottom, left, bottom - arc, arc);
|
||||
path.lineTo(left, top + arc);
|
||||
path.arcTo(left, top, left + arc, top, arc);
|
||||
path.lineTo(right, top);
|
||||
path.moveTo(right - arrow, top - arrow);
|
||||
path.lineTo(right, top);
|
||||
path.lineTo(right - arrow, top + arrow);
|
||||
path.moveTo(left, top);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ViewMapIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , , , cx, cy] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
path.rect(left, top, right - left, bottom - top);
|
||||
path.moveTo(cx, top);
|
||||
path.lineTo(cx, bottom);
|
||||
path.moveTo(left, cy);
|
||||
path.lineTo(right, cy);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const DanmakuIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, height, cx, cy] = loc;
|
||||
const [left, , , bottom] = pad(5);
|
||||
const rx = width / 3;
|
||||
const ry = height / 4;
|
||||
const start = (Math.PI * 16) / 18;
|
||||
const end = (Math.PI * 12) / 18;
|
||||
path.ellipse(cx, cy, rx, ry, 0, start, end);
|
||||
path.lineTo(left - width / 24, bottom - height / 36);
|
||||
path.closePath();
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ReplayIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, height, , cy] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
const arc = width / 10;
|
||||
path.moveTo(right, cy - height / 8);
|
||||
path.lineTo(right, top + arc);
|
||||
path.arcTo(right, top, right - arc, top, arc);
|
||||
path.lineTo(left + arc, top);
|
||||
path.arcTo(left, top, left, top + arc, arc);
|
||||
path.lineTo(left, cy);
|
||||
path.moveTo(left + arc, cy - arc);
|
||||
path.lineTo(left, cy);
|
||||
path.lineTo(left - arc, cy - arc);
|
||||
path.moveTo(left, cy + height / 8);
|
||||
path.lineTo(left, bottom - arc);
|
||||
path.arcTo(left, bottom, left + arc, bottom, arc);
|
||||
path.lineTo(right - arc, bottom);
|
||||
path.arcTo(right, bottom, right, bottom - arc, arc);
|
||||
path.lineTo(right, cy);
|
||||
path.moveTo(right - arc, cy + arc);
|
||||
path.lineTo(right, cy);
|
||||
path.lineTo(right + arc, cy + arc);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const NumpadIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, height, cx, cy] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
path.rect(left, top, right - left, bottom - top);
|
||||
const path2 = new Path2D();
|
||||
path2.ellipse(cx, cy, width / 9, height / 6, 0, 0, Math.PI * 2);
|
||||
path.addPath(path2);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const PlayIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, oy, , height] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
path.moveTo(left, top);
|
||||
path.lineTo(right, oy + height / 2);
|
||||
path.lineTo(left, bottom);
|
||||
path.closePath();
|
||||
return path;
|
||||
});
|
||||
|
||||
export const PauseIcon = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, , cx] = loc;
|
||||
const [, , top, bottom] = pad(5);
|
||||
path.moveTo(cx - width / 5, top);
|
||||
path.lineTo(cx - width / 5, bottom);
|
||||
path.moveTo(cx + width / 5, top);
|
||||
path.lineTo(cx + width / 5, bottom);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const DoubleArrow = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const path2 = new Path2D();
|
||||
const [, , , height, cx, cy] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
path.moveTo(left, top + height / 12);
|
||||
path.lineTo(cx, cy);
|
||||
path.lineTo(left, bottom - height / 12);
|
||||
path.closePath();
|
||||
path2.moveTo(cx, top + height / 12);
|
||||
path2.lineTo(right, cy);
|
||||
path2.lineTo(cx, bottom - height / 12);
|
||||
path2.closePath();
|
||||
path.addPath(path2);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const StepForward = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const path2 = new Path2D();
|
||||
const [, oy, , height] = loc;
|
||||
const [left, right, top, bottom] = pad(5);
|
||||
path.moveTo(left, top);
|
||||
path.lineTo(right, oy + height / 2);
|
||||
path.lineTo(left, bottom);
|
||||
path.closePath();
|
||||
path2.moveTo(right, top);
|
||||
path2.lineTo(right, bottom);
|
||||
path.addPath(path2);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const SoundVolume = defineIcon(1, loc => {
|
||||
const path = new Path2D();
|
||||
const [ox, oy, width, height, cx, cy] = loc;
|
||||
const left = ox + width / 8;
|
||||
const top = oy + height / 5;
|
||||
const bottom = oy + height - height / 5;
|
||||
path.moveTo(left, height / 2 - height / 10);
|
||||
path.lineTo(left, height / 2 + height / 10);
|
||||
path.lineTo(left + width / 6, height / 2 + height / 10);
|
||||
path.lineTo(width / 2, bottom);
|
||||
path.lineTo(width / 2, top);
|
||||
path.lineTo(left + width / 6, height / 2 - height / 10);
|
||||
path.closePath();
|
||||
const start = -Math.PI / 4;
|
||||
const end = Math.PI / 4;
|
||||
path.moveTo(
|
||||
width / 2 + (Math.SQRT1_2 * width) / 6,
|
||||
height / 2 - (Math.SQRT1_2 * width) / 6
|
||||
);
|
||||
path.arc(cx, cy, width / 6, start, end);
|
||||
path.moveTo(
|
||||
width / 2 + (Math.SQRT1_2 * width) / 3,
|
||||
height / 2 - (Math.SQRT1_2 * width) / 3
|
||||
);
|
||||
path.arc(cx, cy, width / 3, start, end);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const Fullscreen = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, height] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
// 左上
|
||||
path.moveTo(left + width / 6, top + height / 6);
|
||||
path.lineTo(left, top);
|
||||
path.moveTo(left, top + height / 8);
|
||||
path.lineTo(left, top);
|
||||
path.lineTo(left + width / 8, top);
|
||||
|
||||
// 右上
|
||||
path.moveTo(right - width / 6, top + height / 6);
|
||||
path.lineTo(right, top);
|
||||
path.moveTo(right, top + height / 8);
|
||||
path.lineTo(right, top);
|
||||
path.lineTo(right - width / 8, top);
|
||||
|
||||
// 左下
|
||||
path.moveTo(left + width / 6, bottom - height / 6);
|
||||
path.lineTo(left, bottom);
|
||||
path.moveTo(left, bottom - height / 8);
|
||||
path.lineTo(left, bottom);
|
||||
path.lineTo(left + width / 8, bottom);
|
||||
|
||||
// 右下
|
||||
path.moveTo(right - width / 6, bottom - height / 6);
|
||||
path.lineTo(right, bottom);
|
||||
path.moveTo(right, bottom - height / 8);
|
||||
path.lineTo(right, bottom);
|
||||
path.lineTo(right - width / 8, bottom);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ExitFullscreen = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width, height] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
// 左上
|
||||
path.moveTo(left + width / 6, top + height / 6);
|
||||
path.lineTo(left, top);
|
||||
path.moveTo(left + width / 24, top + height / 6);
|
||||
path.lineTo(left + width / 6, top + height / 6);
|
||||
path.lineTo(left + width / 6, top + height / 24);
|
||||
|
||||
// 右上
|
||||
path.moveTo(right - width / 6, top + height / 6);
|
||||
path.lineTo(right, top);
|
||||
path.moveTo(right - width / 24, top + height / 6);
|
||||
path.lineTo(right - width / 6, top + height / 6);
|
||||
path.lineTo(right - width / 6, top + height / 24);
|
||||
|
||||
// 左下
|
||||
path.moveTo(left + width / 6, bottom - height / 6);
|
||||
path.lineTo(left, bottom);
|
||||
path.moveTo(left + width / 24, bottom - height / 6);
|
||||
path.lineTo(left + width / 6, bottom - height / 6);
|
||||
path.lineTo(left + width / 6, bottom - height / 24);
|
||||
|
||||
// 右下
|
||||
path.moveTo(right - width / 6, bottom - height / 6);
|
||||
path.lineTo(right, bottom);
|
||||
path.moveTo(right - width / 24, bottom - height / 6);
|
||||
path.lineTo(right - width / 6, bottom - height / 6);
|
||||
path.lineTo(right - width / 6, bottom - height / 24);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ArrowLeftTailless = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
path.moveTo(right, top);
|
||||
path.lineTo(left + width / 4, (top + right) / 2);
|
||||
path.lineTo(right, bottom);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ArrowRightTailless = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , width] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
path.moveTo(left, top);
|
||||
path.lineTo(right - width / 4, (top + right) / 2);
|
||||
path.lineTo(left, bottom);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ArrowUpTailless = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , , height] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
path.moveTo(left, bottom);
|
||||
path.lineTo((left + right) / 2, top + height / 4);
|
||||
path.lineTo(right, bottom);
|
||||
return path;
|
||||
});
|
||||
|
||||
export const ArrowDownTailless = defineIcon(1, (loc, pad) => {
|
||||
const path = new Path2D();
|
||||
const [, , , height] = loc;
|
||||
const [left, right, top, bottom] = pad(4);
|
||||
|
||||
path.moveTo(left, top);
|
||||
path.lineTo((left + right) / 2, bottom - height / 4);
|
||||
path.lineTo(right, top);
|
||||
return path;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './choices';
|
||||
export * from './floorSelect';
|
||||
export * from './icons';
|
||||
export * from './input';
|
||||
export * from './list';
|
||||
export * from './misc';
|
||||
export * from './page';
|
||||
export * from './scroll';
|
||||
export * from './textbox';
|
||||
export * from './textboxTyper';
|
||||
export * from './thumbnail';
|
||||
export * from './tip';
|
||||
@@ -0,0 +1,600 @@
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { computed, defineComponent, onUnmounted, ref, watch } from 'vue';
|
||||
import { TextContent, TextContentProps } from './textbox';
|
||||
import { RectRCircleParams } from '@motajs/render-elements';
|
||||
import {
|
||||
Container,
|
||||
ElementLocator,
|
||||
MotaRenderer,
|
||||
RenderItem,
|
||||
Transform
|
||||
} from '@motajs/render-core';
|
||||
import { Font } from '@motajs/render-style';
|
||||
import { transitionedColor, useKey } from '../use';
|
||||
import { linear } from 'mutate-animate';
|
||||
import { Background, Selection } from './misc';
|
||||
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { KeyCode } from '@motajs/client-base';
|
||||
|
||||
export interface InputProps extends DefaultProps, Partial<TextContentProps> {
|
||||
/** 输入框的提示内容 */
|
||||
placeholder?: string;
|
||||
/** 输入框的值 */
|
||||
value?: string;
|
||||
/** 是否是多行输入,多行输入时,允许换行 */
|
||||
multiline?: boolean;
|
||||
/** 边框颜色 */
|
||||
border?: string;
|
||||
/** 边框圆角 */
|
||||
circle?: RectRCircleParams;
|
||||
/** 边框宽度 */
|
||||
borderWidth?: number;
|
||||
/** 内边距 */
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type InputEmits = {
|
||||
/**
|
||||
* 当输入框的值被确认时触发,例如失焦时
|
||||
* @param value 输入框的值
|
||||
*/
|
||||
change: (value: string) => void;
|
||||
|
||||
/**
|
||||
* 当输入框的值发生改变时触发,例如输入了一个字符,或者删除了一个字母
|
||||
* @param value 输入框的值
|
||||
*/
|
||||
input: (value: string) => void;
|
||||
|
||||
'update:value': (value: string) => void;
|
||||
};
|
||||
|
||||
const inputProps = {
|
||||
props: [
|
||||
'loc',
|
||||
'placeholder',
|
||||
'value',
|
||||
'multiline',
|
||||
'border',
|
||||
'circle',
|
||||
'borderWidth',
|
||||
'pad'
|
||||
],
|
||||
emits: ['change', 'input', 'update:value']
|
||||
} satisfies SetupComponentOptions<InputProps, InputEmits, keyof InputEmits>;
|
||||
|
||||
/**
|
||||
* 输入组件,点击后允许编辑。参数参考 {@link InputProps},事件参考 {@link InputEmits}。
|
||||
* 完全继承 TextContent 组件的参数,用于控制输入内容的显示方式。用法示例:
|
||||
* ```tsx
|
||||
* const inputValue = ref('');
|
||||
* <Input
|
||||
* placeholder="提示词"
|
||||
* // 双向数据绑定,当输入内容改变时,inputValue 会同时改变
|
||||
* v-model={inputValue.value}
|
||||
* // 设置为多行模式
|
||||
* multiline
|
||||
* // 边框颜色
|
||||
* border="#eee"
|
||||
* // 圆角参数,与 g-rectr 参数一致
|
||||
* circle={[4]}
|
||||
* // 边框宽度
|
||||
* borderWidth={3}
|
||||
* // 内边距
|
||||
* pad={8}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Input = defineComponent<InputProps, InputEmits, keyof InputEmits>(
|
||||
(props, { attrs, emit }) => {
|
||||
let ele: HTMLInputElement | HTMLTextAreaElement | null = null;
|
||||
|
||||
const value = ref(props.value ?? '');
|
||||
const root = ref<Container>();
|
||||
|
||||
const width = computed(() => props.loc?.[2] ?? 200);
|
||||
const height = computed(() => props.loc?.[3] ?? 200);
|
||||
const showText = computed(() => value.value || props.placeholder || '');
|
||||
const padding = computed(() => props.pad ?? 4);
|
||||
const textLoc = computed<ElementLocator>(() => [
|
||||
padding.value,
|
||||
padding.value,
|
||||
width.value - padding.value * 2,
|
||||
height.value - padding.value * 2
|
||||
]);
|
||||
const rectLoc = computed<ElementLocator>(() => {
|
||||
const b = props.borderWidth ?? 1;
|
||||
return [b, b, width.value - b * 2, height.value - b * 2];
|
||||
});
|
||||
|
||||
const borderColor = transitionedColor(
|
||||
props.border ?? '#ddd',
|
||||
200,
|
||||
linear()
|
||||
)!;
|
||||
|
||||
const listenInput = () => {
|
||||
if (!ele) return;
|
||||
ele.addEventListener('input', () => {
|
||||
if (ele) {
|
||||
updateInput(ele.value);
|
||||
}
|
||||
});
|
||||
ele.addEventListener('change', () => {
|
||||
if (ele) {
|
||||
updateValue(ele.value);
|
||||
}
|
||||
});
|
||||
ele.addEventListener('blur', () => {
|
||||
if (ele) {
|
||||
updateInput(ele.value);
|
||||
}
|
||||
ele?.remove();
|
||||
});
|
||||
};
|
||||
|
||||
const createInput = (mul: boolean) => {
|
||||
if (mul) {
|
||||
ele = document.createElement('textarea');
|
||||
} else {
|
||||
ele = document.createElement('input');
|
||||
}
|
||||
// See file://./../../../../../src/styles.less
|
||||
ele.classList.add('motajs-input-element');
|
||||
listenInput();
|
||||
};
|
||||
|
||||
const updateValue = (newValue: string) => {
|
||||
value.value = newValue;
|
||||
emit('update:value', newValue);
|
||||
emit('change', newValue);
|
||||
};
|
||||
|
||||
const updateInput = (newValue: string) => {
|
||||
value.value = newValue;
|
||||
emit('update:value', newValue);
|
||||
emit('input', newValue);
|
||||
};
|
||||
|
||||
const click = () => {
|
||||
if (!ele) createInput(props.multiline ?? false);
|
||||
if (!ele) return;
|
||||
// 计算当前绝对位置
|
||||
|
||||
const chain: RenderItem[] = [];
|
||||
let now: RenderItem | undefined = root.value;
|
||||
let renderer: MotaRenderer | undefined;
|
||||
if (!now) return;
|
||||
while (now) {
|
||||
chain.unshift(now);
|
||||
if (now?.isRoot) {
|
||||
renderer = now as MotaRenderer;
|
||||
}
|
||||
now = now.parent;
|
||||
}
|
||||
|
||||
const canvas = renderer?.getCanvas();
|
||||
if (!canvas) return;
|
||||
|
||||
const w = width.value;
|
||||
const h = height.value;
|
||||
|
||||
const border = props.borderWidth ?? 1;
|
||||
const inputWidth = w - border * 2;
|
||||
const inputHeight = h - border * 2;
|
||||
|
||||
// 应用根画布偏移
|
||||
const box = canvas.getBoundingClientRect();
|
||||
let trans = new Transform();
|
||||
trans.translate(box.x, box.y);
|
||||
trans.scale(renderer?.getScale() ?? 1);
|
||||
for (const item of chain) {
|
||||
const { anchorX, anchorY, width, height } = item;
|
||||
trans.translate(-anchorX * width, -anchorY * height);
|
||||
trans = trans.multiply(item.transform);
|
||||
}
|
||||
trans.translate(border, border);
|
||||
|
||||
// 构建CSS transform的matrix字符串
|
||||
const [a, b, , c, d, , e, f] = trans.mat;
|
||||
const str = `matrix(${a},${b},${c},${d},${e},${f})`;
|
||||
|
||||
const font = props.font ?? Font.defaults();
|
||||
ele.style.transform = str;
|
||||
ele.style.width = `${inputWidth}px`;
|
||||
ele.style.height = `${inputHeight}px`;
|
||||
ele.style.font = font.string();
|
||||
ele.style.color = String(props.fillStyle ?? 'white');
|
||||
ele.style.zIndex = '100';
|
||||
document.body.appendChild(ele);
|
||||
ele.focus();
|
||||
};
|
||||
|
||||
const enter = () => {
|
||||
borderColor.set('#0ff');
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
borderColor.set(props.border ?? '#ddd');
|
||||
};
|
||||
|
||||
const [key] = useKey();
|
||||
key.realize('confirm', (_, code) => {
|
||||
if (code === KeyCode.Enter) {
|
||||
// 特判回车键
|
||||
ele?.blur();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
newValue => {
|
||||
value.value = newValue ?? '';
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.multiline,
|
||||
value => {
|
||||
createInput(value ?? false);
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
ele?.remove();
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container
|
||||
loc={props.loc}
|
||||
ref={root}
|
||||
cursor="text"
|
||||
onClick={click}
|
||||
onEnter={enter}
|
||||
onLeave={leave}
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={props.circle}
|
||||
lineWidth={props.borderWidth ?? 1}
|
||||
fill
|
||||
stroke
|
||||
fillStyle="#111"
|
||||
strokeStyle={borderColor.ref.value}
|
||||
zIndex={0}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
noevent
|
||||
loc={textLoc.value}
|
||||
width={width.value - padding.value * 2}
|
||||
text={showText.value}
|
||||
fillStyle="white"
|
||||
alpha={value.value.length === 0 ? 0.6 : 1}
|
||||
zIndex={10}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
},
|
||||
inputProps
|
||||
);
|
||||
|
||||
export interface InputBoxProps extends TextContentProps {
|
||||
/** 输入框对话框的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 传递给内部 Input 组件的配置参数,用于自定义输入行为 */
|
||||
input?: InputProps;
|
||||
/** 窗口皮肤图片ID,用于对话框背景绘制 */
|
||||
winskin?: ImageIds;
|
||||
/** 对话框背景颜色,当未设置 winskin 时生效 */
|
||||
color?: CanvasStyle;
|
||||
/** 对话框边框颜色,当未设置 winskin 时生效 */
|
||||
border?: CanvasStyle;
|
||||
/** 对话框内部所有元素的内边距 */
|
||||
pad?: number;
|
||||
/** 内部输入框区域的高度 */
|
||||
inputHeight?: number;
|
||||
/** 对话框顶部的提示文本 */
|
||||
text?: string;
|
||||
/** 确认按钮的显示文本,默认为"确认" */
|
||||
yesText?: string;
|
||||
/** 取消按钮的显示文本,默认为"取消" */
|
||||
noText?: string;
|
||||
/** 确认/取消按钮的字体样式 */
|
||||
selFont?: Font;
|
||||
/** 确认/取消按钮的文本颜色 */
|
||||
selFill?: CanvasStyle;
|
||||
}
|
||||
|
||||
export type InputBoxEmits = {
|
||||
/**
|
||||
* 当确认输入框的内容时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
confirm: (value: string) => void;
|
||||
|
||||
/**
|
||||
* 当取消时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
cancel: (value: string) => void;
|
||||
} & InputEmits;
|
||||
|
||||
const inputBoxProps = {
|
||||
props: [
|
||||
'loc',
|
||||
'input',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'pad',
|
||||
'inputHeight',
|
||||
'text',
|
||||
'yesText',
|
||||
'noText',
|
||||
'selFont',
|
||||
'selFill',
|
||||
'width'
|
||||
],
|
||||
emits: ['confirm', 'cancel', 'change', 'input', 'update:value']
|
||||
} satisfies SetupComponentOptions<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 输入框组件,与 2.x 的 myconfirm 类似,单次调用参考 {@link getInput}。用法与 `ConfirmBox` 类似。
|
||||
* 参数参考 {@link InputBoxProps},事件参考 {@link InputBoxEmits},用例如下:
|
||||
* ```tsx
|
||||
* const onConfirm = (value: string) => console.log(value);
|
||||
*
|
||||
* <ConfirmBox
|
||||
* text="请输入文本:"
|
||||
* width={240}
|
||||
* // 输入框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 使用 winskin 图片作为背景
|
||||
* winskin="winskin.png"
|
||||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||||
* color="#333"
|
||||
* border="gold"
|
||||
* // 设置选项的字体
|
||||
* selFont={new Font('Verdana', 16)}
|
||||
* // 设置选项的文本颜色
|
||||
* selFill="#d48"
|
||||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||||
* font={new Font('arial')}
|
||||
* onConfirm={onYes}
|
||||
* // 可以使用 input 参数调整输入组件
|
||||
* input={{
|
||||
* // 例如在输入组件中添加占位符(未输入任何东西时显示此内容)
|
||||
* placeholder: '在这里输入你的文本'
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const InputBox = defineComponent<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>((props, { attrs, emit }) => {
|
||||
const contentHeight = ref(0);
|
||||
const value = ref('');
|
||||
const selected = ref(false);
|
||||
const yesSize = ref<[number, number]>([0, 0]);
|
||||
const noSize = ref<[number, number]>([0, 0]);
|
||||
const height = ref(200);
|
||||
|
||||
const yesText = computed(() => props.yesText ?? '确认');
|
||||
const noText = computed(() => props.noText ?? '取消');
|
||||
const text = computed(() => props.text ?? '请输入内容:');
|
||||
const padding = computed(() => props.pad ?? 8);
|
||||
const inputHeight = computed(() => props.inputHeight ?? 24);
|
||||
const inputLoc = computed<ElementLocator>(() => [
|
||||
padding.value,
|
||||
padding.value * 2 + contentHeight.value,
|
||||
props.width - padding.value * 2,
|
||||
inputHeight.value
|
||||
]);
|
||||
const yesLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [props.width / 3, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const noLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const selectionLoc = computed<ElementLocator>(() => {
|
||||
if (selected.value) {
|
||||
const [x = 0, y = 0] = yesLoc.value;
|
||||
const [width, height] = yesSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
} else {
|
||||
const [x = 0, y = 0] = noLoc.value;
|
||||
const [width, height] = noSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
}
|
||||
});
|
||||
const boxLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, height.value, ax, ay];
|
||||
});
|
||||
|
||||
const updateHeight = (h: number) => {
|
||||
contentHeight.value = h;
|
||||
const [, yh] = yesSize.value;
|
||||
const [, nh] = noSize.value;
|
||||
const buttonHeight = Math.max(yh, nh);
|
||||
height.value = h + inputHeight.value + padding.value * 4 + buttonHeight;
|
||||
};
|
||||
|
||||
const change = (value: string) => {
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
const input = (value: string) => {
|
||||
emit('update:value', value);
|
||||
emit('input', value);
|
||||
};
|
||||
|
||||
const setYes = (_: string, width: number, height: number) => {
|
||||
yesSize.value = [width, height];
|
||||
};
|
||||
|
||||
const setNo = (_: string, width: number, height: number) => {
|
||||
noSize.value = [width, height];
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm', value.value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel', value.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container loc={boxLoc.value}>
|
||||
<Background
|
||||
loc={[0, 0, props.width, height.value]}
|
||||
winskin={props.winskin}
|
||||
color={props.color ?? '#333'}
|
||||
border={props.border}
|
||||
zIndex={0}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
loc={[padding.value, padding.value]}
|
||||
text={text.value}
|
||||
width={props.width - padding.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={updateHeight}
|
||||
autoHeight
|
||||
/>
|
||||
<Input
|
||||
{...(props.input ?? {})}
|
||||
loc={inputLoc.value}
|
||||
v-model={value.value}
|
||||
zIndex={10}
|
||||
circle={[4]}
|
||||
onChange={change}
|
||||
onInput={input}
|
||||
/>
|
||||
<Selection
|
||||
loc={selectionLoc.value}
|
||||
winskin={props.winskin}
|
||||
border={props.border}
|
||||
noevent
|
||||
zIndex={10}
|
||||
/>
|
||||
<text
|
||||
loc={yesLoc.value}
|
||||
text={yesText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={confirm}
|
||||
onEnter={() => (selected.value = true)}
|
||||
onSetText={setYes}
|
||||
/>
|
||||
<text
|
||||
loc={noLoc.value}
|
||||
text={noText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={cancel}
|
||||
onEnter={() => (selected.value = false)}
|
||||
onSetText={setNo}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, inputBoxProps);
|
||||
|
||||
/**
|
||||
* 弹出一个输入框,然后将结果返回:
|
||||
* ```ts
|
||||
* const value = await getInput(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 提示内容
|
||||
* '请输入文本:',
|
||||
* // 输入框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||||
* { font: new Font('Verdana', 20) }
|
||||
* );
|
||||
* // 之后,就可以根据 value 来执行不同的操作了
|
||||
* console.log(value); // 输出用户输入的内容
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export function getInput(
|
||||
controller: IUIMountable,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
return new Promise<string>(res => {
|
||||
const instance = controller.open(
|
||||
InputBoxUI,
|
||||
{
|
||||
...(props ?? {}),
|
||||
text,
|
||||
loc,
|
||||
width,
|
||||
onConfirm: value => {
|
||||
controller.close(instance);
|
||||
res(value);
|
||||
},
|
||||
onCancel: () => {
|
||||
controller.close(instance);
|
||||
res('');
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `getInput` 类似,不过会将结果转为数字。用法参考 {@link getInput}
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export async function getInputNumber(
|
||||
controller: IUIMountable,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
const value = await getInput(controller, text, loc, width, props);
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
export async function routedInput() {
|
||||
// todo
|
||||
}
|
||||
|
||||
export async function routedInputNumber() {
|
||||
// todo
|
||||
}
|
||||
|
||||
export const InputBoxUI = new GameUI('input-box', InputBox);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { computed, defineComponent, ref, SlotsType, VNode } from 'vue';
|
||||
import { Selection } from './misc';
|
||||
import { ElementLocator } from '@motajs/render-core';
|
||||
import { Font } from '@motajs/render-style';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { Scroll } from './scroll';
|
||||
|
||||
export interface ListProps extends DefaultProps {
|
||||
/** 列表内容,第一项表示 id,第二项表示显示的内容 */
|
||||
list: [string, string][];
|
||||
/** 当前选中的项 */
|
||||
selected: string;
|
||||
/** 定位 */
|
||||
loc: ElementLocator;
|
||||
/** 每行的高度,默认 18 */
|
||||
lineHeight?: number;
|
||||
/** 字体 */
|
||||
font?: Font;
|
||||
/** 使用 winskin 作为光标 */
|
||||
winskin?: ImageIds;
|
||||
/** 使用指定样式作为光标背景 */
|
||||
color?: CanvasStyle;
|
||||
/** 使用指定样式作为光标边框 */
|
||||
border?: CanvasStyle;
|
||||
/** 选择图标的不透明度范围 */
|
||||
alphaRange?: [number, number];
|
||||
}
|
||||
|
||||
export type ListEmits = {
|
||||
/**
|
||||
* 当用户选中某一项时触发
|
||||
* @param key 选中的项的 id
|
||||
*/
|
||||
update: (key: string) => void;
|
||||
|
||||
'update:selected': (value: string) => void;
|
||||
};
|
||||
|
||||
const listProps = {
|
||||
props: [
|
||||
'list',
|
||||
'selected',
|
||||
'loc',
|
||||
'lineHeight',
|
||||
'font',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'alphaRange'
|
||||
],
|
||||
emits: ['update', 'update:selected']
|
||||
} satisfies SetupComponentOptions<ListProps, ListEmits, keyof ListEmits>;
|
||||
|
||||
export const List = defineComponent<ListProps, ListEmits, keyof ListEmits>(
|
||||
(props, { emit }) => {
|
||||
const selected = ref(props.list[0][0]);
|
||||
const lineHeight = computed(() => props.lineHeight ?? 18);
|
||||
|
||||
const select = (value: string) => {
|
||||
selected.value = value;
|
||||
emit('update', value);
|
||||
emit('update:selected', value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Scroll loc={props.loc}>
|
||||
{props.list.map((v, i) => {
|
||||
const [key, value] = v;
|
||||
const loc: ElementLocator = [
|
||||
0,
|
||||
lineHeight.value * i,
|
||||
props.loc[2] ?? 200,
|
||||
lineHeight.value
|
||||
];
|
||||
const selectionLoc: ElementLocator = [
|
||||
0,
|
||||
0,
|
||||
(props.loc[2] ?? 200) - 10,
|
||||
lineHeight.value
|
||||
];
|
||||
const textLoc: ElementLocator = [
|
||||
10,
|
||||
lineHeight.value / 2,
|
||||
void 0,
|
||||
void 0,
|
||||
0,
|
||||
0.5
|
||||
];
|
||||
return (
|
||||
<container loc={loc} onClick={() => select(key)}>
|
||||
{selected.value === key && (
|
||||
<Selection
|
||||
loc={selectionLoc}
|
||||
color={props.color}
|
||||
border={props.border}
|
||||
winskin={props.winskin}
|
||||
alphaRange={props.alphaRange}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
loc={textLoc}
|
||||
text={value}
|
||||
font={props.font}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
})}
|
||||
</Scroll>
|
||||
);
|
||||
},
|
||||
listProps
|
||||
);
|
||||
|
||||
export interface ListPageProps extends ListProps {
|
||||
/** 组件定位 */
|
||||
loc: ElementLocator;
|
||||
/** 列表所占比例 */
|
||||
basis?: number;
|
||||
/** 列表是否排列在右侧 */
|
||||
right?: boolean;
|
||||
/** 是否显示关闭按钮 */
|
||||
close?: boolean;
|
||||
/** 关闭按钮的位置,相对于组件定位 */
|
||||
closeLoc?: ElementLocator;
|
||||
}
|
||||
|
||||
export type ListPageEmits = {
|
||||
close: () => void;
|
||||
} & ListEmits;
|
||||
|
||||
export type ListPageSlots = SlotsType<{
|
||||
default: (key: string) => VNode | VNode[];
|
||||
|
||||
[x: string]: (key: string) => VNode | VNode[];
|
||||
}>;
|
||||
|
||||
const listPageProps = {
|
||||
props: [
|
||||
'basis',
|
||||
'right',
|
||||
'list',
|
||||
'selected',
|
||||
'loc',
|
||||
'lineHeight',
|
||||
'font',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'alphaRange',
|
||||
'close',
|
||||
'closeLoc'
|
||||
],
|
||||
emits: ['update', 'update:selected', 'close']
|
||||
} satisfies SetupComponentOptions<
|
||||
ListPageProps,
|
||||
ListPageEmits,
|
||||
keyof ListPageEmits,
|
||||
ListPageSlots
|
||||
>;
|
||||
|
||||
export const ListPage = defineComponent<
|
||||
ListPageProps,
|
||||
ListPageEmits,
|
||||
keyof ListPageEmits,
|
||||
ListPageSlots
|
||||
>((props, { emit, slots }) => {
|
||||
const selected = ref(props.selected);
|
||||
|
||||
const basis = computed(() => props.basis ?? 0.3);
|
||||
const width = computed(() => props.loc[2] ?? 200);
|
||||
const height = computed(() => props.loc[3] ?? 200);
|
||||
const listLoc = computed<ElementLocator>(() => {
|
||||
const listWidth = width.value * basis.value;
|
||||
if (props.right) {
|
||||
return [width.value - listWidth, 0, listWidth, height.value];
|
||||
} else {
|
||||
return [0, 0, listWidth, height.value];
|
||||
}
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
const contentWidth = width.value * (1 - basis.value);
|
||||
if (props.right) {
|
||||
return [0, 0, contentWidth, height.value];
|
||||
} else {
|
||||
return [width.value - contentWidth, 0, contentWidth, height.value];
|
||||
}
|
||||
});
|
||||
|
||||
const update = (key: string) => {
|
||||
emit('update', key);
|
||||
emit('update:selected', key);
|
||||
};
|
||||
|
||||
const close = () => emit('close');
|
||||
|
||||
return () => (
|
||||
<container loc={props.loc}>
|
||||
<List
|
||||
{...props}
|
||||
loc={listLoc.value}
|
||||
list={props.list}
|
||||
v-model:selected={selected.value}
|
||||
onUpdate={update}
|
||||
></List>
|
||||
<container loc={contentLoc.value}>
|
||||
{slots[selected.value]?.(selected.value) ??
|
||||
slots.default?.(selected.value)}
|
||||
</container>
|
||||
{props.close && (
|
||||
<text
|
||||
loc={props.closeLoc}
|
||||
text="关闭"
|
||||
cursor="pointer"
|
||||
font={props.font}
|
||||
onClick={close}
|
||||
></text>
|
||||
)}
|
||||
</container>
|
||||
);
|
||||
}, listPageProps);
|
||||
@@ -0,0 +1,588 @@
|
||||
import {
|
||||
DefaultProps,
|
||||
ElementLocator,
|
||||
onTick,
|
||||
PathProps,
|
||||
Sprite
|
||||
} from '@motajs/render';
|
||||
import { computed, defineComponent, ref, SetupContext, watch } from 'vue';
|
||||
import { MotaOffscreenCanvas2D } from '@motajs/render';
|
||||
import { TextContent, TextContentProps } from './textbox';
|
||||
import { Scroll, ScrollExpose, ScrollProps } from './scroll';
|
||||
import { transitioned } from '../use';
|
||||
import { hyper } from 'mutate-animate';
|
||||
import { logger } from '@motajs/common';
|
||||
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { clamp } from 'lodash-es';
|
||||
|
||||
interface ProgressProps extends DefaultProps {
|
||||
/** 进度条的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 进度条的进度,1表示完成,0表示未完成 */
|
||||
progress: number;
|
||||
/** 已完成部分的样式,默认为绿色(green) */
|
||||
success?: CanvasStyle;
|
||||
/** 未完成部分的样式,默认为灰色(gray) */
|
||||
background?: CanvasStyle;
|
||||
/** 线宽度 */
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
const progressProps = {
|
||||
props: ['loc', 'progress', 'success', 'background']
|
||||
} satisfies SetupComponentOptions<ProgressProps>;
|
||||
|
||||
/**
|
||||
* 进度条组件,参数参考 {@link ProgressProps},用例如下:
|
||||
* ```tsx
|
||||
* // 定义进度
|
||||
* const progress = ref(0);
|
||||
*
|
||||
* // 显示进度条
|
||||
* <Progress loc={[12, 12, 120, 8]} progress={progress.value} />
|
||||
* ```
|
||||
*/
|
||||
export const Progress = defineComponent<ProgressProps>(props => {
|
||||
const element = ref<Sprite>();
|
||||
|
||||
const render = (canvas: MotaOffscreenCanvas2D) => {
|
||||
const { ctx } = canvas;
|
||||
const width = props.loc[2] ?? 200;
|
||||
const height = props.loc[3] ?? 200;
|
||||
ctx.lineCap = 'round';
|
||||
const lineWidth = props.lineWidth ?? 2;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeStyle = props.background ?? 'gray';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineWidth, height / 2);
|
||||
ctx.lineTo(width - lineWidth, height / 2);
|
||||
ctx.stroke();
|
||||
const progress = clamp(props.progress, 0, 1);
|
||||
if (!isNaN(progress)) {
|
||||
ctx.strokeStyle = props.success ?? 'green';
|
||||
const p = lineWidth + (width - lineWidth * 2) * progress;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineWidth, height / 2);
|
||||
ctx.lineTo(p, height / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
watch(props, () => {
|
||||
element.value?.update();
|
||||
});
|
||||
|
||||
return () => {
|
||||
return <sprite ref={element} loc={props.loc} render={render}></sprite>;
|
||||
};
|
||||
}, progressProps);
|
||||
|
||||
export interface ArrowProps extends PathProps {
|
||||
/** 箭头的起始和终点坐标,前两个是起始坐标,后两个是终点坐标 */
|
||||
arrow: [number, number, number, number];
|
||||
/** 箭头的头部大小 */
|
||||
head?: number;
|
||||
/** 箭头的颜色 */
|
||||
color?: CanvasStyle;
|
||||
}
|
||||
|
||||
const arrowProps = {
|
||||
props: ['arrow', 'head', 'color']
|
||||
} satisfies SetupComponentOptions<ArrowProps>;
|
||||
|
||||
/**
|
||||
* 箭头组件,显示一个箭头,参数参考 {@link ArrowProps},用例如下:
|
||||
* ```tsx
|
||||
* // 从 (12, 12) 到 (48, 48) 的箭头
|
||||
* <Arrow arrow={[12, 12, 48, 48]} />
|
||||
* ```
|
||||
*/
|
||||
export const Arrow = defineComponent<ArrowProps>(props => {
|
||||
const loc = computed<ElementLocator>(() => {
|
||||
const [x1, y1, x2, y2] = props.arrow;
|
||||
const left = Math.min(x1, x2);
|
||||
const right = Math.max(x1, x2);
|
||||
const top = Math.min(y1, y2);
|
||||
const bottom = Math.max(y1, y2);
|
||||
return [left, top, right - left, bottom - top];
|
||||
});
|
||||
const path = computed(() => {
|
||||
const path = new Path2D();
|
||||
const head = props.head ?? 8;
|
||||
const [x = 0, y = 0] = loc.value;
|
||||
const [x1, y1, x2, y2] = props.arrow;
|
||||
path.moveTo(x1 - x, y1 - y);
|
||||
path.lineTo(x2 - x, y2 - y);
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
path.moveTo(
|
||||
x2 - head * Math.cos(angle - Math.PI / 6),
|
||||
y2 - head * Math.sin(angle - Math.PI / 6)
|
||||
);
|
||||
path.lineTo(x2 - x, y2 - y);
|
||||
path.lineTo(
|
||||
x2 - head * Math.cos(angle + Math.PI / 6),
|
||||
y2 - head * Math.sin(angle + Math.PI / 6)
|
||||
);
|
||||
return path;
|
||||
});
|
||||
|
||||
return () => (
|
||||
<g-path
|
||||
loc={loc.value}
|
||||
path={path.value}
|
||||
stroke
|
||||
strokeStyle={props.color}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
/>
|
||||
);
|
||||
}, arrowProps);
|
||||
|
||||
export interface ScrollTextProps extends TextContentProps, ScrollProps {
|
||||
/** 自动滚动的速度,每秒多少像素 */
|
||||
speed: number;
|
||||
/** 文字的最大宽度 */
|
||||
width: number;
|
||||
/** 自动滚动组件的定位 */
|
||||
loc: ElementLocator;
|
||||
/** 文字滚动入元素之前要先滚动多少像素,默认16像素 */
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type ScrollTextEmits = {
|
||||
/**
|
||||
* 当滚动完毕时触发
|
||||
*/
|
||||
scrollEnd: () => void;
|
||||
};
|
||||
|
||||
export interface ScrollTextExpose {
|
||||
/**
|
||||
* 暂停滚动
|
||||
*/
|
||||
pause(): void;
|
||||
|
||||
/**
|
||||
* 继续滚动
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/**
|
||||
* 设置滚动速度
|
||||
*/
|
||||
setSpeed(speed: number): void;
|
||||
|
||||
/**
|
||||
* 立刻重新滚动
|
||||
*/
|
||||
rescroll(): void;
|
||||
}
|
||||
|
||||
const scrollProps = {
|
||||
props: ['speed', 'loc', 'pad', 'width'],
|
||||
emits: ['scrollEnd']
|
||||
} satisfies SetupComponentOptions<
|
||||
ScrollTextProps,
|
||||
ScrollTextEmits,
|
||||
keyof ScrollTextEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 滚动文字,可以用于展示长剧情或者 staff 表,参数参考 {@link ScrollTextProps},
|
||||
* 事件参考 {@link ScrollTextEmits},函数接口参考 {@link ScrollTextExpose},用例如下:
|
||||
* ```tsx
|
||||
* // 用于接受函数接口
|
||||
* const scroll = ref<ScrollTextExpose>();
|
||||
* // 显示的文字
|
||||
* const text = '滚动文字'.repeat(100);
|
||||
*
|
||||
* onMounted(() => {
|
||||
* // 设置为每秒滚动 100 像素
|
||||
* scroll.value?.setSpeed(100);
|
||||
* // 暂停滚动
|
||||
* scroll.value?.pause();
|
||||
* });
|
||||
*
|
||||
* // 显示滚动文字,每秒滚动 50 像素
|
||||
* <ScrollText ref={scroll} text={text} speed={50} loc={[0, 0, 180, 100]} width={180} />
|
||||
* ```
|
||||
*/
|
||||
export const ScrollText = defineComponent<
|
||||
ScrollTextProps,
|
||||
ScrollTextEmits,
|
||||
keyof ScrollTextEmits
|
||||
>((props, { emit, expose, attrs }) => {
|
||||
const scroll = ref<ScrollExpose>();
|
||||
const speed = ref(props.speed);
|
||||
|
||||
const eleHeight = computed(() => props.loc[3] ?? props.width);
|
||||
const pad = computed(() => props.pad ?? 16);
|
||||
|
||||
let lastFixedTime = Date.now();
|
||||
let lastFixedPos = 0;
|
||||
let paused = false;
|
||||
let nowScroll = 0;
|
||||
|
||||
onTick(() => {
|
||||
if (paused || !scroll.value) return;
|
||||
const now = Date.now();
|
||||
const dt = now - lastFixedTime;
|
||||
nowScroll = (dt / 1000) * speed.value + lastFixedPos;
|
||||
scroll.value.scrollTo(nowScroll, 0);
|
||||
if (nowScroll >= scroll.value.getScrollLength()) {
|
||||
emit('scrollEnd');
|
||||
paused = true;
|
||||
}
|
||||
lastFixedTime = now;
|
||||
});
|
||||
|
||||
const pause = () => {
|
||||
paused = true;
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
paused = false;
|
||||
lastFixedPos = nowScroll;
|
||||
lastFixedTime = Date.now();
|
||||
};
|
||||
|
||||
const setSpeed = (value: number) => {
|
||||
lastFixedPos = nowScroll;
|
||||
lastFixedTime = Date.now();
|
||||
speed.value = value;
|
||||
};
|
||||
|
||||
const rescroll = () => {
|
||||
nowScroll = 0;
|
||||
lastFixedTime = Date.now();
|
||||
lastFixedPos = 0;
|
||||
};
|
||||
|
||||
expose<ScrollTextExpose>({ pause, resume, setSpeed, rescroll });
|
||||
|
||||
return () => (
|
||||
<Scroll
|
||||
ref={scroll}
|
||||
loc={props.loc}
|
||||
padEnd={eleHeight.value + pad.value}
|
||||
noscroll
|
||||
>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
width={props.width - 16}
|
||||
loc={[8, eleHeight.value + pad.value]}
|
||||
autoHeight
|
||||
/>
|
||||
</Scroll>
|
||||
);
|
||||
}, scrollProps);
|
||||
|
||||
export interface SelectionProps extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
color?: CanvasStyle;
|
||||
border?: CanvasStyle;
|
||||
winskin?: ImageIds;
|
||||
/** 选择图标的不透明度范围 */
|
||||
alphaRange?: [number, number];
|
||||
}
|
||||
|
||||
const selectionProps = {
|
||||
props: ['loc', 'color', 'border', 'winskin', 'alphaRange']
|
||||
} satisfies SetupComponentOptions<SelectionProps>;
|
||||
|
||||
/**
|
||||
* 显示一个选择光标,与 2.x 的 drawUIEventSelector 效果一致,参数参考 {@link SelectionProps},用例如下:
|
||||
* ```tsx
|
||||
* // 使用 winskin.png 作为选择光标,光标动画的不透明度范围是 [0.3, 0.8]
|
||||
* <Selection loc={[24, 24, 80, 16]} winskin="winskin.png" alphaRange={[0.3, 0.8]} />
|
||||
* // 使用指定的填充和边框颜色作为选择光标
|
||||
* <Selection loc={[24, 24, 80, 16]} color="#ddd" border="gold" />
|
||||
* ```
|
||||
*/
|
||||
export const Selection = defineComponent<SelectionProps>(props => {
|
||||
const minAlpha = computed(() => props.alphaRange?.[0] ?? 0.25);
|
||||
const maxAlpha = computed(() => props.alphaRange?.[1] ?? 0.55);
|
||||
const alpha = transitioned(minAlpha.value, 2000, hyper('sin', 'in-out'))!;
|
||||
|
||||
const isWinskin = computed(() => !!props.winskin);
|
||||
const winskinImage = computed(() =>
|
||||
isWinskin.value ? core.material.images.images[props.winskin!] : null
|
||||
);
|
||||
const fixedLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, width = 200, height = 200, ax, ay] = props.loc;
|
||||
return [x + 1, y + 1, width - 2, height - 2, ax, ay];
|
||||
});
|
||||
|
||||
const renderWinskin = (canvas: MotaOffscreenCanvas2D) => {
|
||||
const ctx = canvas.ctx;
|
||||
const image = winskinImage.value;
|
||||
if (!image) return;
|
||||
const [, , width = 200, height = 200] = props.loc;
|
||||
// 背景
|
||||
ctx.drawImage(image, 130, 66, 28, 28, 2, 2, width - 4, height - 4);
|
||||
// 四个角
|
||||
ctx.drawImage(image, 128, 64, 2, 2, 0, 0, 2, 2);
|
||||
ctx.drawImage(image, 158, 64, 2, 2, width - 2, 0, 2, 2);
|
||||
ctx.drawImage(image, 128, 94, 2, 2, 0, height - 2, 2, 2);
|
||||
ctx.drawImage(image, 158, 94, 2, 2, width - 2, height - 2, 2, 2);
|
||||
// 四条边
|
||||
ctx.drawImage(image, 130, 64, 28, 2, 2, 0, width - 4, 2);
|
||||
ctx.drawImage(image, 130, 94, 28, 2, 2, height - 2, width - 4, 2);
|
||||
ctx.drawImage(image, 128, 66, 2, 28, 0, 2, 2, height - 4);
|
||||
ctx.drawImage(image, 158, 66, 2, 28, width - 2, 2, 2, height - 4);
|
||||
};
|
||||
|
||||
onTick(() => {
|
||||
if (alpha.value === maxAlpha.value) {
|
||||
alpha.set(minAlpha.value);
|
||||
}
|
||||
if (alpha.value === minAlpha.value) {
|
||||
alpha.set(maxAlpha.value);
|
||||
}
|
||||
});
|
||||
|
||||
return () =>
|
||||
isWinskin.value ? (
|
||||
<sprite
|
||||
loc={props.loc}
|
||||
render={renderWinskin}
|
||||
alpha={alpha.ref.value}
|
||||
noanti
|
||||
/>
|
||||
) : (
|
||||
<g-rectr
|
||||
loc={fixedLoc.value}
|
||||
circle={[4]}
|
||||
alpha={alpha.ref.value}
|
||||
fill
|
||||
stroke
|
||||
fillStyle={props.color}
|
||||
strokeStyle={props.border}
|
||||
lineWidth={1}
|
||||
/>
|
||||
);
|
||||
}, selectionProps);
|
||||
|
||||
export interface BackgroundProps extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
winskin?: ImageIds;
|
||||
color?: CanvasStyle;
|
||||
border?: CanvasStyle;
|
||||
}
|
||||
|
||||
const backgroundProps = {
|
||||
props: ['loc', 'winskin', 'color', 'border']
|
||||
} satisfies SetupComponentOptions<BackgroundProps>;
|
||||
|
||||
/**
|
||||
* 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下:
|
||||
* ```tsx
|
||||
* // 使用 winskin2.png 作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} winskin="winskin2.png" />
|
||||
* // 使用指定填充和边框颜色作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} color="#333" border="gold" />
|
||||
* ```
|
||||
*/
|
||||
export const Background = defineComponent<BackgroundProps>(props => {
|
||||
const isWinskin = computed(() => !!props.winskin);
|
||||
const fixedLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, width = 200, height = 200] = props.loc;
|
||||
return [x + 2, y + 2, width - 4, height - 4];
|
||||
});
|
||||
|
||||
return () =>
|
||||
isWinskin.value ? (
|
||||
<winskin image={props.winskin!} loc={props.loc} noanti />
|
||||
) : (
|
||||
<g-rectr
|
||||
loc={fixedLoc.value}
|
||||
fillStyle={props.color}
|
||||
strokeStyle={props.border}
|
||||
fill
|
||||
stroke
|
||||
lineWidth={2}
|
||||
circle={[4]}
|
||||
/>
|
||||
);
|
||||
}, backgroundProps);
|
||||
|
||||
export interface WaitBoxProps<T>
|
||||
extends Partial<BackgroundProps>,
|
||||
Partial<TextContentProps> {
|
||||
loc: ElementLocator;
|
||||
width: number;
|
||||
promise?: Promise<T>;
|
||||
text?: string;
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type WaitBoxEmits<T> = {
|
||||
resolve: (data: T) => void;
|
||||
};
|
||||
|
||||
export interface WaitBoxExpose<T> {
|
||||
/**
|
||||
* 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现,
|
||||
* 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次
|
||||
* @param data 兑现值
|
||||
*/
|
||||
resolve(data: T): void;
|
||||
}
|
||||
|
||||
const waitBoxProps = {
|
||||
props: ['promise', 'loc', 'winskin', 'color', 'border', 'width'],
|
||||
emits: ['resolve']
|
||||
} satisfies SetupComponentOptions<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。
|
||||
* 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下:
|
||||
* ```tsx
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
*
|
||||
* <WaitBox
|
||||
* // 传入要等待的 Promise
|
||||
* promise={promise}
|
||||
* // 等待框的位置,宽度由 width 参数指定,高度由内部计算得来,不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 等待框的宽度
|
||||
* width={240}
|
||||
* // 完全继承 Background 的参数,因此可以直接指定背景样式
|
||||
* winskin="winskin2.png"
|
||||
* // 完全继承 TextContent 的参数,因此可以直接指定字体
|
||||
* font={new Font('Verdana', 28)}
|
||||
* // 当传入的 Promise 兑现时触发此事件,注意此事件只可能触发一次,触发后便不会再次触发
|
||||
* onResolve={(time) => console.log(time)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const WaitBox = defineComponent<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>(
|
||||
<T,>(
|
||||
props: WaitBoxProps<T>,
|
||||
{ emit, expose, attrs }: SetupContext<WaitBoxEmits<T>>
|
||||
) => {
|
||||
const contentHeight = ref(200);
|
||||
|
||||
const text = computed(() => props.text ?? '请等待 ...');
|
||||
const pad = computed(() => props.pad ?? 24);
|
||||
const containerLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, contentHeight.value, ax, ay];
|
||||
});
|
||||
const backLoc = computed<ElementLocator>(() => {
|
||||
return [1, 1, props.width - 2, contentHeight.value - 2];
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
return [
|
||||
pad.value,
|
||||
pad.value,
|
||||
props.width - pad.value * 2,
|
||||
contentHeight.value - pad.value * 2
|
||||
];
|
||||
});
|
||||
|
||||
let resolved: boolean = false;
|
||||
|
||||
props.promise?.then(
|
||||
value => {
|
||||
resolve(value);
|
||||
},
|
||||
reason => {
|
||||
logger.warn(63, reason);
|
||||
}
|
||||
);
|
||||
|
||||
const resolve = (data: T) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
emit('resolve', data);
|
||||
};
|
||||
|
||||
const onContentHeight = (height: number) => {
|
||||
contentHeight.value = height + pad.value * 2;
|
||||
};
|
||||
|
||||
expose<WaitBoxExpose<T>>({ resolve });
|
||||
|
||||
return () => (
|
||||
<container loc={containerLoc.value}>
|
||||
<Background
|
||||
loc={backLoc.value}
|
||||
zIndex={0}
|
||||
winskin={props.winskin}
|
||||
color={props.color}
|
||||
border={props.border}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
autoHeight
|
||||
text={text.value}
|
||||
loc={contentLoc.value}
|
||||
width={props.width - pad.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={onContentHeight}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
},
|
||||
waitBoxProps
|
||||
);
|
||||
|
||||
/**
|
||||
* 打开一个等待框,等待传入的 Promise 兑现后,关闭等待框,并将兑现值返回。
|
||||
* 示例,等待 1000ms:
|
||||
* ```ts
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
* const value = await waitbox(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 确认框的宽度
|
||||
* 240,
|
||||
* // 要等待的 Promise
|
||||
* promise,
|
||||
* // 额外的 props,例如填写等待文本,此项可选,参考 WaitBoxProps
|
||||
* { text: '请等待 1000ms' }
|
||||
* );
|
||||
* console.log(value); // 输出时间刻
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param loc 等待框的位置
|
||||
* @param width 等待框的宽度
|
||||
* @param promise 要等待的 Promise
|
||||
* @param props 额外的 props,参考 {@link WaitBoxProps}
|
||||
*/
|
||||
export function waitbox<T>(
|
||||
controller: IUIMountable,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
promise: Promise<T>,
|
||||
props?: Partial<WaitBoxProps<T>>
|
||||
): Promise<T> {
|
||||
return new Promise<T>(res => {
|
||||
const instance = controller.open(WaitBoxUI, {
|
||||
...(props ?? {}),
|
||||
loc,
|
||||
width,
|
||||
promise,
|
||||
onResolve: data => {
|
||||
controller.close(instance);
|
||||
res(data as T);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const WaitBoxUI = new GameUI('wait-box', WaitBox);
|
||||
export const BackgroundUI = new GameUI('background', Background);
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
SlotsType,
|
||||
VNode,
|
||||
watch
|
||||
} from 'vue';
|
||||
import { clamp, isNil } from 'lodash-es';
|
||||
import { DefaultProps, ElementLocator, Font } from '@motajs/render';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
|
||||
/** 圆角矩形页码距离容器的边框大小,与 pageSize 相乘 */
|
||||
const RECT_PAD = 0.1;
|
||||
|
||||
export interface PageProps extends DefaultProps {
|
||||
/** 共有多少页 */
|
||||
pages: number;
|
||||
/** 页码组件的定位 */
|
||||
loc: ElementLocator;
|
||||
/** 当前页码 */
|
||||
page?: number;
|
||||
/** 页码的字体 */
|
||||
font?: Font;
|
||||
/** 只有一页的时候,是否隐藏页码 */
|
||||
hideIfSingle?: boolean;
|
||||
}
|
||||
|
||||
export type PageEmits = {
|
||||
pageChange: (page: number) => void;
|
||||
|
||||
'update:page': (page: number) => void;
|
||||
};
|
||||
|
||||
export interface PageExpose {
|
||||
/**
|
||||
* 切换页码
|
||||
* @param page 要切换至的页码数,0 表示第一页
|
||||
*/
|
||||
changePage(page: number): void;
|
||||
|
||||
/**
|
||||
* 切换到传入的页码数加上当前页码数的页码
|
||||
* @param delta 页码数增量
|
||||
*/
|
||||
movePage(delta: number): void;
|
||||
|
||||
/**
|
||||
* 获取当前在第几页
|
||||
*/
|
||||
now(): number;
|
||||
}
|
||||
|
||||
type PageSlots = SlotsType<{
|
||||
default: (page: number) => VNode | VNode[];
|
||||
}>;
|
||||
|
||||
const pageProps = {
|
||||
props: ['pages', 'loc', 'page', 'font', 'hideIfSingle'],
|
||||
emits: ['pageChange', 'update:page']
|
||||
} satisfies SetupComponentOptions<
|
||||
PageProps,
|
||||
PageEmits,
|
||||
keyof PageEmits,
|
||||
PageSlots
|
||||
>;
|
||||
|
||||
/**
|
||||
* 分页组件,用于多页切换,例如存档界面等。参数参考 {@link PageProps},函数接口参考 {@link PageExpose}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* 用例如下,是一个在每页显示文字的用例,其中 page 表示页码索引,第一页就是 0,第二页就是 1,以此类推:
|
||||
* ```tsx
|
||||
* <Page maxPage={5}>
|
||||
* {
|
||||
* (page: number) => {
|
||||
* return items[page].map(v => <text text={v.text} />)
|
||||
* }
|
||||
* }
|
||||
* </Page>
|
||||
* ```
|
||||
*/
|
||||
export const Page = defineComponent<
|
||||
PageProps,
|
||||
PageEmits,
|
||||
keyof PageEmits,
|
||||
PageSlots
|
||||
>((props, { slots, expose, emit }) => {
|
||||
const nowPage = ref(props.page ?? 0);
|
||||
|
||||
// 五个元素的位置
|
||||
const leftLoc = ref<ElementLocator>([]);
|
||||
const leftPageLoc = ref<ElementLocator>([]);
|
||||
const nowPageLoc = ref<ElementLocator>([]);
|
||||
const rightPageLoc = ref<ElementLocator>([]);
|
||||
const rightLoc = ref<ElementLocator>([]);
|
||||
/** 内容的位置 */
|
||||
const contentLoc = ref<ElementLocator>([]);
|
||||
/** 页码容器的位置 */
|
||||
const pageLoc = ref<ElementLocator>([]);
|
||||
/** 页码的矩形框的位置 */
|
||||
const rectLoc = ref<ElementLocator>([0, 0, 0, 0]);
|
||||
/** 页面文字的位置 */
|
||||
const textLoc = ref<ElementLocator>([0, 0, 0, 0]);
|
||||
|
||||
// 两个监听的参数
|
||||
const leftArrow = ref<Path2D>();
|
||||
const rightArrow = ref<Path2D>();
|
||||
|
||||
const hide = computed(() => props.hideIfSingle && props.pages === 1);
|
||||
const font = computed(() => props.font ?? new Font());
|
||||
const isFirst = computed(() => nowPage.value === 0);
|
||||
const isLast = computed(() => nowPage.value === props.pages - 1);
|
||||
const width = computed(() => props.loc[2] ?? 200);
|
||||
const height = computed(() => props.loc[3] ?? 200);
|
||||
const round = computed(() => font.value.size / 4);
|
||||
const nowPageFont = computed(() => Font.clone(font.value, { weight: 700 }));
|
||||
/** 页码的横向间距 */
|
||||
const interval = computed(() => {
|
||||
const size = font.value.size * 1.5;
|
||||
const max = size * 9;
|
||||
if (width.value > max) {
|
||||
return size;
|
||||
} else {
|
||||
return (width.value - size * 5) / 4;
|
||||
}
|
||||
});
|
||||
|
||||
// 左右箭头的颜色
|
||||
const leftColor = computed(() => (isFirst.value ? '#666' : '#ddd'));
|
||||
const rightColor = computed(() => (isLast.value ? '#666' : '#ddd'));
|
||||
|
||||
let updating = false;
|
||||
const updatePagePos = () => {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
nextTick(() => {
|
||||
updating = false;
|
||||
});
|
||||
const pageH = hide.value ? 0 : font.value.size + 8;
|
||||
contentLoc.value = [0, 0, width.value, height.value - pageH];
|
||||
pageLoc.value = [0, height.value - pageH, width.value, pageH];
|
||||
const center = width.value / 2;
|
||||
const size = font.value.size * 1.5;
|
||||
const int = size + interval.value;
|
||||
nowPageLoc.value = [center, 0, size, size, 0.5, 0];
|
||||
leftPageLoc.value = [center - int, 0, size, size, 0.5, 0];
|
||||
leftLoc.value = [center - int * 2, 0, size, size, 0.5, 0];
|
||||
rightPageLoc.value = [center + int, 0, size, size, 0.5, 0];
|
||||
rightLoc.value = [center + int * 2, 0, size, size, 0.5, 0];
|
||||
};
|
||||
|
||||
const updateArrowPath = () => {
|
||||
const rectSize = font.value.size * 1.5;
|
||||
const size = font.value.size;
|
||||
const pad = rectSize - size;
|
||||
const left = new Path2D();
|
||||
left.moveTo(size, pad);
|
||||
left.lineTo(pad, rectSize / 2);
|
||||
left.lineTo(size, rectSize - pad);
|
||||
const right = new Path2D();
|
||||
right.moveTo(pad, pad);
|
||||
right.lineTo(size, rectSize / 2);
|
||||
right.lineTo(pad, rectSize - pad);
|
||||
leftArrow.value = left;
|
||||
rightArrow.value = right;
|
||||
};
|
||||
|
||||
const updateRectAndText = () => {
|
||||
const size = font.value.size * 1.5;
|
||||
const pad = RECT_PAD * size;
|
||||
rectLoc.value = [pad, pad, size - pad * 2, size - pad * 2];
|
||||
textLoc.value = [size / 2, size / 2, void 0, void 0, 0.5, 0.5];
|
||||
};
|
||||
|
||||
watch(font, () => {
|
||||
updatePagePos();
|
||||
updateArrowPath();
|
||||
updateRectAndText();
|
||||
});
|
||||
watch(
|
||||
() => props.loc,
|
||||
() => {
|
||||
updatePagePos();
|
||||
updateRectAndText();
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.page,
|
||||
page => {
|
||||
if (!isNil(page)) {
|
||||
const target = clamp(page, 0, props.pages - 1);
|
||||
if (nowPage.value !== target) {
|
||||
nowPage.value = target;
|
||||
emit('pageChange', target);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换页码
|
||||
*/
|
||||
const changePage = (page: number) => {
|
||||
const target = clamp(page, 0, props.pages - 1);
|
||||
if (nowPage.value !== target) {
|
||||
nowPage.value = target;
|
||||
emit('pageChange', target);
|
||||
emit('update:page', target);
|
||||
}
|
||||
};
|
||||
|
||||
const movePage = (delta: number) => {
|
||||
changePage(nowPage.value + delta);
|
||||
};
|
||||
|
||||
const now = () => nowPage.value;
|
||||
|
||||
const lastPage = () => {
|
||||
changePage(nowPage.value - 1);
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
changePage(nowPage.value + 1);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updatePagePos();
|
||||
updateArrowPath();
|
||||
updateRectAndText();
|
||||
});
|
||||
|
||||
expose<PageExpose>({ changePage, movePage, now });
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<container loc={props.loc}>
|
||||
<container loc={contentLoc.value}>
|
||||
{slots.default?.(nowPage.value)}
|
||||
</container>
|
||||
<container loc={pageLoc.value} hidden={hide.value} nocache>
|
||||
<container
|
||||
key={1}
|
||||
loc={leftLoc.value}
|
||||
onClick={lastPage}
|
||||
cursor="pointer"
|
||||
nocache
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[round.value]}
|
||||
strokeStyle={leftColor.value}
|
||||
lineWidth={1}
|
||||
stroke
|
||||
></g-rectr>
|
||||
<g-path
|
||||
path={leftArrow.value}
|
||||
stroke
|
||||
strokeStyle={leftColor.value}
|
||||
lineWidth={1}
|
||||
></g-path>
|
||||
</container>
|
||||
{!isFirst.value && (
|
||||
<container
|
||||
key={2}
|
||||
loc={leftPageLoc.value}
|
||||
onClick={lastPage}
|
||||
cursor="pointer"
|
||||
nocache
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[round.value]}
|
||||
strokeStyle="#ddd"
|
||||
lineWidth={1}
|
||||
stroke
|
||||
></g-rectr>
|
||||
<text
|
||||
loc={textLoc.value}
|
||||
text={nowPage.value.toString()}
|
||||
font={font.value}
|
||||
></text>
|
||||
</container>
|
||||
)}
|
||||
<container loc={nowPageLoc.value} key={3} nocache>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[round.value]}
|
||||
strokeStyle="#ddd"
|
||||
fillStyle="#ddd"
|
||||
lineWidth={1}
|
||||
fill
|
||||
stroke
|
||||
></g-rectr>
|
||||
<text
|
||||
loc={textLoc.value}
|
||||
text={(nowPage.value + 1).toString()}
|
||||
fillStyle="#222"
|
||||
font={nowPageFont.value}
|
||||
></text>
|
||||
</container>
|
||||
{!isLast.value && (
|
||||
<container
|
||||
key={4}
|
||||
loc={rightPageLoc.value}
|
||||
onClick={nextPage}
|
||||
cursor="pointer"
|
||||
nocache
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[round.value]}
|
||||
strokeStyle="#ddd"
|
||||
lineWidth={1}
|
||||
stroke
|
||||
></g-rectr>
|
||||
<text
|
||||
loc={textLoc.value}
|
||||
text={(nowPage.value + 2).toString()}
|
||||
font={font.value}
|
||||
></text>
|
||||
</container>
|
||||
)}
|
||||
<container
|
||||
key={5}
|
||||
loc={rightLoc.value}
|
||||
onClick={nextPage}
|
||||
cursor="pointer"
|
||||
nocache
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[round.value]}
|
||||
strokeStyle={rightColor.value}
|
||||
lineWidth={1}
|
||||
stroke
|
||||
></g-rectr>
|
||||
<g-path
|
||||
path={rightArrow.value}
|
||||
stroke
|
||||
strokeStyle={rightColor.value}
|
||||
lineWidth={1}
|
||||
></g-path>
|
||||
</container>
|
||||
</container>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
}, pageProps);
|
||||
@@ -0,0 +1,582 @@
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
SlotsType,
|
||||
VNode,
|
||||
watch
|
||||
} from 'vue';
|
||||
import {
|
||||
Container,
|
||||
DefaultProps,
|
||||
ElementLocator,
|
||||
RenderItem,
|
||||
Sprite,
|
||||
Transform,
|
||||
MotaOffscreenCanvas2D,
|
||||
IActionEvent,
|
||||
IWheelEvent,
|
||||
MouseType,
|
||||
EventProgress,
|
||||
ActionEventMap,
|
||||
ContainerCustom,
|
||||
ActionType,
|
||||
CustomContainerPropagateOrigin
|
||||
} from '@motajs/render';
|
||||
import { hyper, linear, Transition } from 'mutate-animate';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { transitioned } from '../use';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
|
||||
export const enum ScrollDirection {
|
||||
Horizontal,
|
||||
Vertical
|
||||
}
|
||||
|
||||
export interface ScrollExpose {
|
||||
/**
|
||||
* 控制滚动条滚动至目标位置
|
||||
* @param y 滚动至的目标位置
|
||||
* @param time 滚动的动画时长,默认为无动画
|
||||
*/
|
||||
scrollTo(y: number, time?: number): void;
|
||||
|
||||
/**
|
||||
* 获取这个滚动条组件最多可以滚动多长
|
||||
*/
|
||||
getScrollLength(): number;
|
||||
}
|
||||
|
||||
export interface ScrollProps extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
hor?: boolean;
|
||||
/** 是否不允许滚动 */
|
||||
noscroll?: boolean;
|
||||
/**
|
||||
* 滚动到最下方(最右方)时的填充大小,如果默认的高度计算方式有误,
|
||||
* 那么可以调整此参数来修复错误
|
||||
*/
|
||||
padEnd?: number;
|
||||
}
|
||||
|
||||
type ScrollSlots = SlotsType<{
|
||||
default: () => VNode | VNode[];
|
||||
}>;
|
||||
|
||||
const scrollProps = {
|
||||
props: ['hor', 'noscroll', 'loc', 'padEnd']
|
||||
} satisfies SetupComponentOptions<ScrollProps, {}, string, ScrollSlots>;
|
||||
|
||||
/** 滚动条图示的最短长度 */
|
||||
const SCROLL_MIN_LENGTH = 20;
|
||||
/** 滚动条图示的宽度 */
|
||||
const SCROLL_WIDTH = 10;
|
||||
/** 滚动条的颜色 */
|
||||
const SCROLL_COLOR = '255,255,255';
|
||||
|
||||
/**
|
||||
* 滚动条组件,具有虚拟滚动功能,即在画面外的不渲染。参数参考 {@link ScrollProps},暴露接口参考 {@link ScrollExpose}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* 使用时,建议使用平铺式布局,即包含很多子元素,而不要用一个 container 将所有内容包裹,
|
||||
* 每个子元素的高度(宽度)不建议过大,以更好地通过虚拟滚动优化
|
||||
*
|
||||
* **推荐写法**:
|
||||
* ```tsx
|
||||
* <Scroll>
|
||||
* <item />
|
||||
* <item />
|
||||
* ...其他元素
|
||||
* <item />
|
||||
* <item />
|
||||
* </Scroll>
|
||||
* ```
|
||||
* **不推荐**使用这种写法:
|
||||
* ```tsx
|
||||
* <Scroll>
|
||||
* <container>
|
||||
* <item />
|
||||
* </container>
|
||||
* <Scroll>
|
||||
* ```
|
||||
*/
|
||||
export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
(props, { slots, expose }) => {
|
||||
/** 滚动条的定位 */
|
||||
const sp = ref<ElementLocator>([0, 0, 1, 1]);
|
||||
|
||||
const listenedChild: Set<RenderItem> = new Set();
|
||||
const areaMap: Map<RenderItem, [number, number]> = new Map();
|
||||
const content = ref<Container>();
|
||||
const scroll = ref<Sprite>();
|
||||
|
||||
const scrollAlpha = transitioned(0.5, 100, linear())!;
|
||||
|
||||
const width = computed(() => props.loc[2] ?? 200);
|
||||
const height = computed(() => props.loc[3] ?? 200);
|
||||
const direction = computed(() =>
|
||||
props.hor ? ScrollDirection.Horizontal : ScrollDirection.Vertical
|
||||
);
|
||||
const scrollColor = computed(
|
||||
() => `rgba(${SCROLL_COLOR},${scrollAlpha.ref.value ?? 0.5})`
|
||||
);
|
||||
const padEnd = computed(() => props.padEnd ?? 0);
|
||||
|
||||
watch(scrollColor, () => {
|
||||
scroll.value?.update();
|
||||
});
|
||||
|
||||
/** 滚动内容的当前位置 */
|
||||
let contentPos = 0;
|
||||
/** 滚动条的当前位置 */
|
||||
let scrollPos = 0;
|
||||
/** 滚动内容的目标位置 */
|
||||
let contentTarget = 0;
|
||||
/** 滚动条的目标位置 */
|
||||
let scrollTarget = 0;
|
||||
/** 滚动内容的长度 */
|
||||
let maxLength = 0;
|
||||
/** 滚动条的长度 */
|
||||
let scrollLength = SCROLL_MIN_LENGTH;
|
||||
|
||||
const transition = new Transition();
|
||||
transition.value.scroll = 0;
|
||||
transition.value.showScroll = 0;
|
||||
transition.mode(hyper('sin', 'out')).absolute();
|
||||
|
||||
//#region 滚动操作
|
||||
|
||||
transition.ticker.add(() => {
|
||||
if (scrollPos !== scrollTarget) {
|
||||
scrollPos = transition.value.scroll;
|
||||
content.value?.update();
|
||||
}
|
||||
if (contentPos !== contentTarget) {
|
||||
contentPos = transition.value.showScroll;
|
||||
checkAllItem();
|
||||
content.value?.update();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 滚动到目标值
|
||||
* @param time 动画时长
|
||||
*/
|
||||
const scrollTo = (y: number, time: number = 0) => {
|
||||
if (maxLength < height.value) return;
|
||||
const max = maxLength - height.value;
|
||||
const target = clamp(y, 0, max);
|
||||
contentTarget = target;
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
scrollTarget =
|
||||
(width.value - scrollLength) * (contentTarget / max);
|
||||
} else {
|
||||
scrollTarget =
|
||||
(height.value - scrollLength) * (contentTarget / max);
|
||||
}
|
||||
if (isNaN(scrollTarget)) scrollTarget = 0;
|
||||
transition.time(time).transition('scroll', scrollTarget);
|
||||
transition.time(time).transition('showScroll', target);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算一个元素会在画面上显示的区域
|
||||
*/
|
||||
const getArea = (item: RenderItem, rect: DOMRectReadOnly) => {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
areaMap.set(item, [rect.left - width.value, rect.right]);
|
||||
} else {
|
||||
areaMap.set(item, [rect.top - height.value, rect.bottom]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查一个元素是否需要显示,不需要则隐藏
|
||||
*/
|
||||
const checkItem = (item: RenderItem) => {
|
||||
const area = areaMap.get(item);
|
||||
if (!area) {
|
||||
item.show();
|
||||
return;
|
||||
}
|
||||
const [min, max] = area;
|
||||
if (contentPos > min - 10 && contentPos < max + 10) {
|
||||
item.show();
|
||||
} else {
|
||||
item.hide();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 对所有元素执行显示检查
|
||||
*/
|
||||
const checkAllItem = () => {
|
||||
content.value?.children.forEach(v => checkItem(v));
|
||||
};
|
||||
|
||||
/**
|
||||
* 当一个元素的矩阵发生变换时执行,检查其显示区域
|
||||
*/
|
||||
const onTransform = (item: RenderItem) => {
|
||||
const rect = item.getBoundingRect();
|
||||
const pad = props.padEnd ?? 0;
|
||||
if (item.parent === content.value) {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
if (rect.right > maxLength - pad) {
|
||||
maxLength = rect.right + pad;
|
||||
updatePosition();
|
||||
}
|
||||
} else {
|
||||
if (rect.bottom > maxLength - pad) {
|
||||
maxLength = rect.bottom + pad;
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.off('transform', onTransform);
|
||||
listenedChild.delete(item);
|
||||
}
|
||||
getArea(item, rect);
|
||||
checkItem(item);
|
||||
scroll.value?.update();
|
||||
content.value?.update();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新滚动条位置
|
||||
*/
|
||||
const updatePosition = () => {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
scrollLength = clamp(
|
||||
(height.value / maxLength) * width.value,
|
||||
SCROLL_MIN_LENGTH,
|
||||
width.value
|
||||
);
|
||||
const h = props.noscroll
|
||||
? height.value
|
||||
: height.value - SCROLL_WIDTH;
|
||||
sp.value = [0, h, width.value, SCROLL_WIDTH];
|
||||
} else {
|
||||
scrollLength = clamp(
|
||||
(height.value / maxLength) * height.value,
|
||||
SCROLL_MIN_LENGTH,
|
||||
height.value
|
||||
);
|
||||
const w = props.noscroll
|
||||
? width.value
|
||||
: width.value - SCROLL_WIDTH;
|
||||
sp.value = [w, 0, SCROLL_WIDTH, height.value];
|
||||
}
|
||||
};
|
||||
|
||||
let updating = false;
|
||||
const updateScroll = () => {
|
||||
if (!content.value || updating) return;
|
||||
updating = true;
|
||||
nextTick(() => {
|
||||
updating = false;
|
||||
});
|
||||
let max = 0;
|
||||
listenedChild.forEach(v => v.off('transform', onTransform));
|
||||
listenedChild.clear();
|
||||
areaMap.clear();
|
||||
content.value.children.forEach(v => {
|
||||
if (v.isComment) return;
|
||||
const rect = v.getBoundingRect();
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
if (rect.right > max) {
|
||||
max = rect.right;
|
||||
}
|
||||
} else {
|
||||
if (rect.bottom > max) {
|
||||
max = rect.bottom;
|
||||
}
|
||||
}
|
||||
getArea(v, rect);
|
||||
if (!listenedChild.has(v)) {
|
||||
v.on('transform', onTransform);
|
||||
listenedChild.add(v);
|
||||
}
|
||||
checkItem(v);
|
||||
});
|
||||
maxLength = Math.max(max + padEnd.value, 10);
|
||||
updatePosition();
|
||||
scroll.value?.update();
|
||||
};
|
||||
|
||||
watch(() => props.loc, updateScroll);
|
||||
onUpdated(updateScroll);
|
||||
onMounted(updateScroll);
|
||||
onUnmounted(() => {
|
||||
listenedChild.forEach(v => v.off('transform', onTransform));
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 渲染滚动
|
||||
|
||||
const drawScroll = (canvas: MotaOffscreenCanvas2D) => {
|
||||
if (props.noscroll) return;
|
||||
const ctx = canvas.ctx;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = scrollColor.value;
|
||||
ctx.beginPath();
|
||||
const scroll = transition.value.scroll;
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
ctx.moveTo(scroll + 5, 5);
|
||||
ctx.lineTo(scroll + scrollLength - 5, 5);
|
||||
} else {
|
||||
ctx.moveTo(5, scroll + 5);
|
||||
ctx.lineTo(5, scroll + scrollLength - 5);
|
||||
}
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const renderContent = (
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
children: RenderItem[],
|
||||
transform: Transform
|
||||
) => {
|
||||
const ctx = canvas.ctx;
|
||||
ctx.save();
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
ctx.translate(-contentPos, 0);
|
||||
} else {
|
||||
ctx.translate(0, -contentPos);
|
||||
}
|
||||
children.forEach(v => {
|
||||
if (v.hidden) return;
|
||||
v.renderContent(canvas, transform);
|
||||
});
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 事件监听
|
||||
|
||||
// todo: 滑动操作时的滚动惯性
|
||||
|
||||
const customPropagate = <T extends ActionType>(
|
||||
type: T,
|
||||
progress: EventProgress,
|
||||
event: ActionEventMap[T],
|
||||
_: ContainerCustom,
|
||||
origin: CustomContainerPropagateOrigin
|
||||
) => {
|
||||
if (progress === EventProgress.Capture) {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
event.offsetX += contentPos;
|
||||
} else {
|
||||
event.offsetY += contentPos;
|
||||
}
|
||||
}
|
||||
origin(type, progress, event);
|
||||
};
|
||||
|
||||
const wheelScroll = (delta: number, max: number) => {
|
||||
const sign = Math.sign(delta);
|
||||
const dx = Math.abs(delta);
|
||||
const movement = Math.min(max, dx) * sign;
|
||||
scrollTo(contentTarget + movement, dx > 10 ? 300 : 0);
|
||||
};
|
||||
|
||||
const wheel = (ev: IWheelEvent) => {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
if (ev.wheelX !== 0) {
|
||||
wheelScroll(ev.wheelX, width.value / 5);
|
||||
} else if (ev.wheelY !== 0) {
|
||||
wheelScroll(ev.wheelY, width.value / 5);
|
||||
}
|
||||
} else {
|
||||
wheelScroll(ev.wheelY, height.value / 5);
|
||||
}
|
||||
};
|
||||
|
||||
const getPos = (ev: IActionEvent, absolute: boolean = false) => {
|
||||
if (absolute) {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
return ev.absoluteX;
|
||||
} else {
|
||||
return ev.absoluteY;
|
||||
}
|
||||
} else {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
return ev.offsetX;
|
||||
} else {
|
||||
return ev.offsetY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let identifier = -2;
|
||||
let downPos = 0;
|
||||
/** 拖动内容时,内容原本所在的位置 */
|
||||
let contentBefore = 0;
|
||||
|
||||
const down = (ev: IActionEvent) => {
|
||||
identifier = ev.identifier;
|
||||
downPos = getPos(ev, true);
|
||||
contentBefore = contentTarget;
|
||||
};
|
||||
|
||||
const move = (ev: IActionEvent) => {
|
||||
if (ev.identifier !== identifier) return;
|
||||
let pos = 0;
|
||||
if (ev.touch) {
|
||||
pos = getPos(ev);
|
||||
} else {
|
||||
if (ev.buttons & MouseType.Left) {
|
||||
pos = getPos(ev);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const movement = pos - downPos;
|
||||
scrollTo(contentBefore - movement, 0);
|
||||
};
|
||||
|
||||
/** 最初滚动条在哪 */
|
||||
let scrollBefore = 0;
|
||||
/** 本次拖动滚动条的操作标识符 */
|
||||
let scrollIdentifier = -2;
|
||||
/** 点击滚动条时,点击位置在平行于滚动条方向的位置 */
|
||||
let scrollDownPos = 0;
|
||||
/** 是否是点击了滚动条区域中滚动条之外的地方,这样视为类滚轮操作 */
|
||||
let scrollMutate = false;
|
||||
/** 点击滚动条时,点击位置垂直于滚动条方向的位置 */
|
||||
let scrollPin = 0;
|
||||
|
||||
/**
|
||||
* 获取点击滚动条时,垂直于滚动条方向的位置
|
||||
*/
|
||||
const getScrollPin = (ev: IActionEvent) => {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
return ev.absoluteY;
|
||||
} else {
|
||||
return ev.absoluteX;
|
||||
}
|
||||
};
|
||||
|
||||
const downScroll = (ev: IActionEvent) => {
|
||||
scrollBefore = contentTarget;
|
||||
scrollIdentifier = ev.identifier;
|
||||
const pos = getPos(ev, true);
|
||||
// 计算点击在了滚动条的哪个位置
|
||||
const offsetPos = getPos(ev);
|
||||
const sEnd = scrollPos + scrollLength;
|
||||
if (offsetPos >= scrollPos && offsetPos <= sEnd) {
|
||||
scrollDownPos = pos - scrollPos;
|
||||
scrollMutate = false;
|
||||
scrollPin = getScrollPin(ev);
|
||||
} else {
|
||||
scrollMutate = true;
|
||||
}
|
||||
scrollAlpha.set(0.9);
|
||||
};
|
||||
|
||||
const moveScroll = (ev: IActionEvent) => {
|
||||
if (ev.identifier !== scrollIdentifier || scrollMutate) return;
|
||||
const pos = getPos(ev, true);
|
||||
const scrollPos = pos - scrollDownPos;
|
||||
let deltaPin = 0;
|
||||
let threshold = 0;
|
||||
if (ev.touch) {
|
||||
const pin = getScrollPin(ev);
|
||||
deltaPin = Math.abs(pin - scrollPin);
|
||||
threshold = 200;
|
||||
} else {
|
||||
const pin = getScrollPin(ev);
|
||||
deltaPin = Math.abs(pin - scrollPin);
|
||||
threshold = 100;
|
||||
}
|
||||
if (deltaPin > threshold) {
|
||||
scrollTo(scrollBefore, 0);
|
||||
} else {
|
||||
const pos = (scrollPos / height.value) * maxLength;
|
||||
scrollTo(pos, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const upScroll = (ev: IActionEvent) => {
|
||||
scrollAlpha.set(0.7);
|
||||
if (!scrollMutate) return;
|
||||
const pos = getPos(ev);
|
||||
if (pos < scrollPos) {
|
||||
scrollTo(contentTarget - 50, 300);
|
||||
} else {
|
||||
scrollTo(contentTarget + 50, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const enter = () => {
|
||||
scrollAlpha.set(0.7);
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
scrollAlpha.set(0.5);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
onMounted(() => {
|
||||
scroll.value?.root?.on('move', move);
|
||||
scroll.value?.root?.on('move', moveScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
scroll.value?.root?.off('move', move);
|
||||
scroll.value?.root?.off('move', moveScroll);
|
||||
transition.ticker.destroy();
|
||||
});
|
||||
|
||||
//#region expose 函数
|
||||
|
||||
const getScrollLength = () => {
|
||||
return maxLength - height.value;
|
||||
};
|
||||
|
||||
expose<ScrollExpose>({
|
||||
scrollTo,
|
||||
getScrollLength
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<container loc={props.loc} onWheel={wheel}>
|
||||
<container-custom
|
||||
loc={[0, 0, props.loc[2], props.loc[3]]}
|
||||
ref={content}
|
||||
onDown={down}
|
||||
render={renderContent}
|
||||
propagate={customPropagate}
|
||||
zIndex={0}
|
||||
>
|
||||
{slots.default?.()}
|
||||
</container-custom>
|
||||
<sprite
|
||||
nocache
|
||||
hidden={props.noscroll}
|
||||
loc={sp.value}
|
||||
ref={scroll}
|
||||
render={drawScroll}
|
||||
onDown={downScroll}
|
||||
onUp={upScroll}
|
||||
zIndex={10}
|
||||
onEnter={enter}
|
||||
onLeave={leave}
|
||||
></sprite>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
},
|
||||
scrollProps
|
||||
);
|
||||
@@ -0,0 +1,672 @@
|
||||
import {
|
||||
ElementLocator,
|
||||
Font,
|
||||
Sprite,
|
||||
DefaultProps,
|
||||
Text,
|
||||
MotaOffscreenCanvas2D
|
||||
} from '@motajs/render';
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowReactive,
|
||||
shallowRef,
|
||||
SlotsType,
|
||||
VNode,
|
||||
watch
|
||||
} from 'vue';
|
||||
import { logger } from '@motajs/common';
|
||||
import { isNil } from 'lodash-es';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import {
|
||||
ITextContentConfig,
|
||||
TextContentTyper,
|
||||
TyperRenderable,
|
||||
TextContentType,
|
||||
WordBreak,
|
||||
TextAlign
|
||||
} from './textboxTyper';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { texture } from '../elements';
|
||||
|
||||
//#region TextContent
|
||||
|
||||
export interface TextContentProps
|
||||
extends DefaultProps,
|
||||
Partial<ITextContentConfig> {
|
||||
/** 显示的文字 */
|
||||
text?: string;
|
||||
/** 是否填充 */
|
||||
fill?: boolean;
|
||||
/** 是否描边 */
|
||||
stroke?: boolean;
|
||||
/** 是否自适应高度,即组件内部计算 height 的值,而非指定,可与滚动条结合 */
|
||||
autoHeight?: boolean;
|
||||
/** 文字的最大宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
export type TextContentEmits = {
|
||||
/**
|
||||
* 当打字机结束时触发
|
||||
*/
|
||||
typeEnd: () => void;
|
||||
|
||||
/**
|
||||
* 当打字机开始打字时触发
|
||||
*/
|
||||
typeStart: () => void;
|
||||
|
||||
/**
|
||||
* 当文字发生变动,组件内部重新计算文字高度时触发
|
||||
* @param height 更新后的高度
|
||||
*/
|
||||
updateHeight: (height: number) => void;
|
||||
};
|
||||
|
||||
export interface TextContentExpose {
|
||||
/**
|
||||
* 重新开始打字
|
||||
*/
|
||||
retype(): void;
|
||||
|
||||
/**
|
||||
* 立刻显示所有文字
|
||||
*/
|
||||
showAll(): void;
|
||||
|
||||
/**
|
||||
* 获取这段 TextContent 的总高度
|
||||
*/
|
||||
getHeight(): number;
|
||||
}
|
||||
|
||||
const textContentOptions = {
|
||||
props: [
|
||||
'breakChars',
|
||||
'font',
|
||||
'ignoreLineEnd',
|
||||
'ignoreLineStart',
|
||||
'interval',
|
||||
'keepLast',
|
||||
'lineHeight',
|
||||
'text',
|
||||
'textAlign',
|
||||
'wordBreak',
|
||||
'fill',
|
||||
'fillStyle',
|
||||
'strokeStyle',
|
||||
'strokeWidth',
|
||||
'stroke',
|
||||
'loc',
|
||||
'width',
|
||||
'autoHeight'
|
||||
],
|
||||
emits: ['typeEnd', 'typeStart', 'updateHeight']
|
||||
} satisfies SetupComponentOptions<
|
||||
TextContentProps,
|
||||
TextContentEmits,
|
||||
keyof TextContentEmits
|
||||
>;
|
||||
|
||||
export const TextContent = defineComponent<
|
||||
TextContentProps,
|
||||
TextContentEmits,
|
||||
keyof TextContentEmits
|
||||
>((props, { emit, expose }) => {
|
||||
const loc = shallowRef<ElementLocator>(
|
||||
(props.loc?.slice() as ElementLocator) ?? []
|
||||
);
|
||||
|
||||
if (props.width < 0) {
|
||||
logger.warn(41, String(props.width));
|
||||
}
|
||||
|
||||
const typer = new TextContentTyper(props);
|
||||
let renderable: TyperRenderable[] = [];
|
||||
let needUpdate = false;
|
||||
|
||||
const retype = () => {
|
||||
if (needUpdate || props.hidden) return;
|
||||
needUpdate = true;
|
||||
if (!spriteElement.value) {
|
||||
needUpdate = false;
|
||||
}
|
||||
renderable = [];
|
||||
|
||||
typer.setConfig(props);
|
||||
typer.setText(props.text ?? '');
|
||||
typer.type();
|
||||
needUpdate = false;
|
||||
updateLoc();
|
||||
};
|
||||
|
||||
const showAll = () => {
|
||||
typer.typeAll();
|
||||
};
|
||||
|
||||
watch(props, () => {
|
||||
typer.setConfig(props);
|
||||
retype();
|
||||
});
|
||||
|
||||
const getHeight = () => {
|
||||
return typer.getHeight();
|
||||
};
|
||||
|
||||
const updateLoc = () => {
|
||||
const height = getHeight();
|
||||
if (props.autoHeight) {
|
||||
const [x = 0, y = 0, width = props.width, , ax = 0, ay = 0] =
|
||||
loc.value;
|
||||
loc.value = [x, y, width, height, ax, ay];
|
||||
}
|
||||
emit('updateHeight', height);
|
||||
};
|
||||
|
||||
expose<TextContentExpose>({ retype, showAll, getHeight });
|
||||
|
||||
const spriteElement = shallowRef<Sprite>();
|
||||
const renderContent = (canvas: MotaOffscreenCanvas2D) => {
|
||||
const ctx = canvas.ctx;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = props.strokeWidth ?? 2;
|
||||
ctx.lineJoin = 'round';
|
||||
for (const data of renderable) {
|
||||
if (data.cut) break;
|
||||
switch (data.type) {
|
||||
case TextContentType.Text: {
|
||||
if (data.text.length === 0) continue;
|
||||
ctx.fillStyle = data.fillStyle;
|
||||
ctx.strokeStyle = data.strokeStyle;
|
||||
ctx.font = data.font;
|
||||
|
||||
const text = data.text.slice(0, data.pointer);
|
||||
|
||||
if (props.stroke) {
|
||||
ctx.strokeText(text, data.x, data.y);
|
||||
}
|
||||
if (props.fill ?? true) {
|
||||
ctx.fillText(text, data.x, data.y);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TextContentType.Icon: {
|
||||
const { renderable: r, x: dx, y: dy, width, height } = data;
|
||||
const render = r.render;
|
||||
const [x, y, w, h] = render[0];
|
||||
const icon = r.autotile ? r.image[0] : r.image;
|
||||
ctx.drawImage(icon, x, y, w, h, dx, dy, width, height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderFunc = (data: TyperRenderable[]) => {
|
||||
renderable = data;
|
||||
spriteElement.value?.update();
|
||||
};
|
||||
|
||||
typer.setRender(renderFunc);
|
||||
typer.on('typeStart', () => {
|
||||
emit('typeStart');
|
||||
});
|
||||
typer.on('typeEnd', () => {
|
||||
emit('typeEnd');
|
||||
});
|
||||
|
||||
onMounted(retype);
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<sprite
|
||||
loc={loc.value}
|
||||
ref={spriteElement}
|
||||
render={renderContent}
|
||||
></sprite>
|
||||
);
|
||||
};
|
||||
}, textContentOptions);
|
||||
|
||||
//#region Textbox
|
||||
|
||||
export interface TextboxProps extends TextContentProps, DefaultProps {
|
||||
/** 背景颜色 */
|
||||
backColor?: CanvasStyle;
|
||||
/** 背景 winskin */
|
||||
winskin?: ImageIds;
|
||||
/** 边框与文字间的距离,默认为8 */
|
||||
padding?: number;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** 标题字体 */
|
||||
titleFont?: Font;
|
||||
/** 标题填充样式 */
|
||||
titleFill?: CanvasStyle;
|
||||
/** 标题描边样式 */
|
||||
titleStroke?: CanvasStyle;
|
||||
/** 标题文字与边框间的距离,默认为4 */
|
||||
titlePadding?: number;
|
||||
/** 图标 */
|
||||
icon?: AllIdsWithNone;
|
||||
/** 最大宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface TextboxExpose {
|
||||
/**
|
||||
* 显示这个文本框
|
||||
*/
|
||||
show(): void;
|
||||
|
||||
/**
|
||||
* 隐藏这个文本框
|
||||
*/
|
||||
hide(): void;
|
||||
|
||||
/**
|
||||
* 重新开始打字
|
||||
*/
|
||||
retype(): void;
|
||||
|
||||
/**
|
||||
* 立刻显示所有文字
|
||||
*/
|
||||
showAll(): void;
|
||||
}
|
||||
|
||||
type TextboxEmits = TextContentEmits;
|
||||
type TextboxSlots = SlotsType<{
|
||||
default: (data: TextboxProps) => VNode[];
|
||||
title: (data: TextboxProps) => VNode[];
|
||||
}>;
|
||||
|
||||
const textboxOptions = {
|
||||
props: (textContentOptions.props as (keyof TextboxProps)[]).concat([
|
||||
'backColor',
|
||||
'winskin',
|
||||
'padding',
|
||||
'titleFill',
|
||||
'titleStroke',
|
||||
'titleFont',
|
||||
'titlePadding',
|
||||
'id',
|
||||
'hidden',
|
||||
'title'
|
||||
]),
|
||||
emits: textContentOptions.emits
|
||||
} satisfies SetupComponentOptions<TextboxProps, {}, string, TextboxSlots>;
|
||||
|
||||
let id = 0;
|
||||
function getNextTextboxId() {
|
||||
return `@default-textbox-${id++}`;
|
||||
}
|
||||
|
||||
export const Textbox = defineComponent<
|
||||
TextboxProps,
|
||||
TextboxEmits,
|
||||
keyof TextboxEmits,
|
||||
TextboxSlots
|
||||
>((props, { slots, expose, emit }) => {
|
||||
const data = shallowReactive<TextboxProps>({ width: 200 });
|
||||
|
||||
const setTextboxData = () => {
|
||||
// Textbox
|
||||
data.backColor = props.backColor ?? '#222';
|
||||
data.winskin = props.winskin;
|
||||
data.padding = props.padding ?? 8;
|
||||
data.titleFill = props.titleFill ?? 'gold';
|
||||
data.titleStroke = props.titleStroke ?? 'transparent';
|
||||
data.titleFont = props.titleFont ?? new Font('Verdana', 18);
|
||||
data.titlePadding = props.titlePadding ?? 8;
|
||||
data.width = props.width ?? props.loc?.[2] ?? 200;
|
||||
data.height = props.height ?? props.loc?.[3] ?? 200;
|
||||
data.title = props.title ?? '';
|
||||
data.icon = props.icon;
|
||||
|
||||
// TextContent
|
||||
data.breakChars = props.breakChars ?? '';
|
||||
data.font = props.font ?? new Font();
|
||||
data.ignoreLineEnd = props.ignoreLineEnd ?? '';
|
||||
data.ignoreLineStart = props.ignoreLineStart ?? '';
|
||||
data.interval = props.interval ?? 0;
|
||||
data.keepLast = props.keepLast ?? false;
|
||||
data.lineHeight = props.lineHeight ?? 0;
|
||||
data.text = props.text ?? '';
|
||||
data.textAlign = props.textAlign ?? TextAlign.Left;
|
||||
data.wordBreak = props.wordBreak ?? WordBreak.Space;
|
||||
data.fill = props.fill ?? true;
|
||||
data.stroke = props.stroke ?? false;
|
||||
data.fillStyle = props.fillStyle ?? '#fff';
|
||||
data.strokeStyle = props.strokeStyle ?? '#000';
|
||||
data.strokeWidth = props.strokeWidth ?? 2;
|
||||
data.loc = props.loc;
|
||||
data.width = props.width;
|
||||
};
|
||||
|
||||
setTextboxData();
|
||||
|
||||
watch(props, () => {
|
||||
const needUpdateTitle = data.title !== props.title;
|
||||
setTextboxData();
|
||||
if (needUpdateTitle) {
|
||||
onSetText();
|
||||
}
|
||||
});
|
||||
|
||||
const titleElement = ref<Text>();
|
||||
const content = ref<TextContentExpose>();
|
||||
const hidden = ref(props.hidden);
|
||||
/** 标题宽度 */
|
||||
const tw = ref(data.titlePadding! * 2);
|
||||
/** 标题高度 */
|
||||
const th = ref(data.titlePadding! * 2);
|
||||
const contentX = computed(() => {
|
||||
if (hasIcon.value) {
|
||||
return data.padding! + 40;
|
||||
} else {
|
||||
return data.padding!;
|
||||
}
|
||||
});
|
||||
const contentY = computed(() => {
|
||||
const height = th.value;
|
||||
return data.title ? height : 0;
|
||||
});
|
||||
const backHeight = computed(() => data.height! - contentY.value);
|
||||
const contentWidth = computed(() => {
|
||||
if (hasIcon.value) {
|
||||
return data.width! - data.padding! * 2 - 40;
|
||||
} else {
|
||||
return data.width! - data.padding! * 2;
|
||||
}
|
||||
});
|
||||
const contentHeight = computed(
|
||||
() => data.height! - data.padding! * 2 - contentY.value
|
||||
);
|
||||
const iconLoc = computed<ElementLocator>(() => {
|
||||
const y = contentY.value;
|
||||
const pad = data.padding!;
|
||||
const icon = data.icon;
|
||||
if (isNil(icon) || icon === 'none') {
|
||||
return [];
|
||||
} else {
|
||||
const num = texture.idNumberMap[icon];
|
||||
const renderable = texture.getRenderable(num);
|
||||
if (!renderable) return [];
|
||||
const [, , w, h] = renderable.render[0];
|
||||
return [pad, pad + y, w, h];
|
||||
}
|
||||
});
|
||||
const hasIcon = computed(() => {
|
||||
return !isNil(data.icon) && data.icon !== 'none';
|
||||
});
|
||||
|
||||
const onSetText = () => {
|
||||
nextTick(() => {
|
||||
titleElement.value?.requestBeforeFrame(() => {
|
||||
if (titleElement.value) {
|
||||
const { width, height } = titleElement.value;
|
||||
tw.value = width + data.titlePadding! * 2;
|
||||
th.value = height + data.titlePadding! * 2;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
//#region store
|
||||
|
||||
let lastTitle = data.title;
|
||||
|
||||
/** 结束打字机 */
|
||||
const storeEmits: TextboxStoreEmits = {
|
||||
endType() {
|
||||
content.value?.showAll();
|
||||
},
|
||||
hide() {
|
||||
hidden.value = true;
|
||||
},
|
||||
show() {
|
||||
hidden.value = false;
|
||||
},
|
||||
update(value) {
|
||||
if (value.title !== lastTitle) {
|
||||
onSetText();
|
||||
lastTitle = value.title;
|
||||
}
|
||||
},
|
||||
setText(text) {
|
||||
if (data.text === text) {
|
||||
content.value?.retype();
|
||||
} else {
|
||||
data.text = text;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const store = TextboxStore.use(
|
||||
props.id ?? getNextTextboxId(),
|
||||
data,
|
||||
storeEmits
|
||||
);
|
||||
|
||||
const onTypeStart = () => {
|
||||
store.emitTypeStart();
|
||||
emit('typeStart');
|
||||
};
|
||||
|
||||
const onTypeEnd = () => {
|
||||
store.emitTypeEnd();
|
||||
emit('typeEnd');
|
||||
};
|
||||
|
||||
expose<TextboxExpose>({
|
||||
show() {
|
||||
hidden.value = false;
|
||||
},
|
||||
hide() {
|
||||
hidden.value = true;
|
||||
},
|
||||
retype() {
|
||||
content.value?.retype();
|
||||
},
|
||||
showAll() {
|
||||
content.value?.showAll();
|
||||
}
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container
|
||||
id={props.id}
|
||||
hidden={hidden.value}
|
||||
alpha={data.alpha}
|
||||
loc={data.loc}
|
||||
>
|
||||
{data.title && (
|
||||
<container zIndex={10} loc={[0, 0, tw.value, th.value]}>
|
||||
{slots.title ? (
|
||||
slots.title(data)
|
||||
) : props.winskin ? (
|
||||
<winskin
|
||||
image={props.winskin}
|
||||
loc={[0, 0, tw.value, th.value]}
|
||||
></winskin>
|
||||
) : (
|
||||
<g-rect loc={[0, 0, tw.value, th.value]}></g-rect>
|
||||
)}
|
||||
<text
|
||||
ref={titleElement}
|
||||
text={data.title}
|
||||
loc={[data.titlePadding, data.titlePadding]}
|
||||
fillStyle={data.titleFill}
|
||||
strokeStyle={data.titleStroke}
|
||||
font={data.titleFont}
|
||||
strokeWidth={2}
|
||||
></text>
|
||||
</container>
|
||||
)}
|
||||
{slots.default ? (
|
||||
slots.default(data)
|
||||
) : props.winskin ? (
|
||||
<winskin
|
||||
image={props.winskin}
|
||||
loc={[0, contentY.value, data.width!, backHeight.value]}
|
||||
></winskin>
|
||||
) : (
|
||||
<g-rect
|
||||
loc={[0, contentY.value, data.width!, backHeight.value]}
|
||||
fill
|
||||
fillStyle={data.backColor}
|
||||
></g-rect>
|
||||
)}
|
||||
{hasIcon.value && (
|
||||
<icon icon={data.icon as AllIds} loc={iconLoc.value} animate />
|
||||
)}
|
||||
{hasIcon.value && (
|
||||
<g-rect
|
||||
loc={iconLoc.value}
|
||||
strokeStyle="gold"
|
||||
fillStyle="#222"
|
||||
lineWidth={2}
|
||||
fill
|
||||
stroke
|
||||
/>
|
||||
)}
|
||||
<TextContent
|
||||
{...data}
|
||||
ref={content}
|
||||
x={contentX.value}
|
||||
y={contentY.value + data.padding!}
|
||||
width={contentWidth.value}
|
||||
height={contentHeight.value}
|
||||
onTypeEnd={onTypeEnd}
|
||||
onTypeStart={onTypeStart}
|
||||
></TextContent>
|
||||
</container>
|
||||
);
|
||||
}, textboxOptions);
|
||||
|
||||
interface TextboxStoreEmits {
|
||||
/** 结束打字机动画的回调函数 */
|
||||
endType: () => void;
|
||||
/** 隐藏文本框的回调函数 */
|
||||
hide: () => void;
|
||||
/** 显示文本框的回调函数 */
|
||||
show: () => void;
|
||||
/** 更新文本框配置的回调函数 */
|
||||
update: (value: TextboxProps) => void;
|
||||
/** 设置显示文本的回调函数 */
|
||||
setText: (text: string) => void;
|
||||
}
|
||||
|
||||
interface TextboxStoreEvent {
|
||||
/** 文本框配置更新事件,传递更新后的配置值 */
|
||||
update: [value: TextboxProps];
|
||||
/** 文本框显示事件 */
|
||||
show: [];
|
||||
/** 文本框隐藏事件 */
|
||||
hide: [];
|
||||
/** 打字机开始打字事件 */
|
||||
typeStart: [];
|
||||
/** 打字机结束打字事件 */
|
||||
typeEnd: [];
|
||||
}
|
||||
|
||||
export class TextboxStore extends EventEmitter<TextboxStoreEvent> {
|
||||
static list: Map<string, TextboxStore> = new Map();
|
||||
|
||||
typing: boolean = false;
|
||||
|
||||
private constructor(
|
||||
public readonly data: TextboxProps,
|
||||
private readonly emits: TextboxStoreEmits
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始打字,由组件调用,而非组件外调用
|
||||
*/
|
||||
emitTypeStart() {
|
||||
this.typing = true;
|
||||
this.emit('typeStart');
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束打字,由组件调用,而非组件外调用
|
||||
*/
|
||||
emitTypeEnd() {
|
||||
this.typing = false;
|
||||
this.emit('typeEnd');
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束打字机的打字
|
||||
*/
|
||||
endType() {
|
||||
this.emits.endType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改渲染数据
|
||||
*/
|
||||
modify(data: Partial<TextboxProps>) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// @ts-expect-error 无法推导
|
||||
if (!isNil(value)) this.data[key] = value;
|
||||
}
|
||||
this.emits.update(this.data);
|
||||
this.emit('update', this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置显示的文本
|
||||
* @param text 要显示的文本
|
||||
*/
|
||||
setText(text: string) {
|
||||
this.emits.setText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示文本框
|
||||
*/
|
||||
show() {
|
||||
this.emits.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏文本框
|
||||
*/
|
||||
hide() {
|
||||
this.emits.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本框
|
||||
* @param id 文本框id
|
||||
*/
|
||||
static get(id: string): TextboxStore | undefined {
|
||||
return this.list.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前作用域下生成文本框控制器
|
||||
* @param id 文本框id
|
||||
* @param props 文本框渲染数据
|
||||
*/
|
||||
static use(id: string, props: TextboxProps, emits: TextboxStoreEmits) {
|
||||
const store = new TextboxStore(props, emits);
|
||||
if (this.list.has(id)) {
|
||||
logger.warn(42, id);
|
||||
}
|
||||
this.list.set(id, store);
|
||||
onUnmounted(() => {
|
||||
this.list.delete(id);
|
||||
});
|
||||
return store;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ElementLocator,
|
||||
MotaOffscreenCanvas2D,
|
||||
Sprite
|
||||
} from '@motajs/render-core';
|
||||
import { SpriteProps } from '@motajs/render-vue';
|
||||
import { defineComponent, ref, watch } from 'vue';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
|
||||
export interface ThumbnailProps extends SpriteProps {
|
||||
/** 缩略图的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 楼层 ID */
|
||||
floorId: FloorIds;
|
||||
/** 缩略图填充样式 */
|
||||
padStyle?: CanvasStyle;
|
||||
/** 楼层信息 */
|
||||
map?: Block[];
|
||||
/** 角色信息 */
|
||||
hero?: HeroStatus;
|
||||
// configs
|
||||
damage?: boolean;
|
||||
all?: boolean;
|
||||
noHD?: boolean;
|
||||
/** 缩略图的比例,1 表示与实际地图大小一致 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const thumbnailProps = {
|
||||
props: [
|
||||
'loc',
|
||||
'padStyle',
|
||||
'floorId',
|
||||
'map',
|
||||
'hero',
|
||||
'damage',
|
||||
'all',
|
||||
'noHD',
|
||||
'size'
|
||||
]
|
||||
} satisfies SetupComponentOptions<ThumbnailProps>;
|
||||
|
||||
export const Thumbnail = defineComponent<ThumbnailProps>(props => {
|
||||
const spriteRef = ref<Sprite>();
|
||||
|
||||
const update = () => {
|
||||
spriteRef.value?.update();
|
||||
};
|
||||
|
||||
const drawThumbnail = (canvas: MotaOffscreenCanvas2D) => {
|
||||
if (props.hidden) return;
|
||||
const ctx = canvas.ctx;
|
||||
const hero = props.hero;
|
||||
const options: Partial<DrawThumbnailConfig> = {
|
||||
damage: props.damage,
|
||||
ctx: ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: props.size ?? 1,
|
||||
all: props.all,
|
||||
noHD: props.noHD,
|
||||
v2: true,
|
||||
inFlyMap: false
|
||||
};
|
||||
if (hero) {
|
||||
options.heroLoc = hero.loc;
|
||||
options.heroIcon = hero.image;
|
||||
options.flags = hero.flags;
|
||||
options.centerX = hero.loc.x;
|
||||
options.centerY = hero.loc.y;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = props.padStyle ?? 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
core.drawThumbnail(props.floorId, props.map, options);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
watch(props, update);
|
||||
|
||||
return () => (
|
||||
<sprite noanti ref={spriteRef} loc={props.loc} render={drawThumbnail} />
|
||||
);
|
||||
}, thumbnailProps);
|
||||
@@ -0,0 +1,175 @@
|
||||
import { DefaultProps, ElementLocator, Font } from '@motajs/render';
|
||||
import { logger } from '@motajs/common';
|
||||
import { computed, defineComponent, onUnmounted, ref } from 'vue';
|
||||
import { transitioned } from '../use';
|
||||
import { hyper } from 'mutate-animate';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { texture } from '../elements';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
|
||||
export interface TipProps extends DefaultProps {
|
||||
/** 显示的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 边距 */
|
||||
pad?: [number, number];
|
||||
/** 圆角 */
|
||||
corner?: number;
|
||||
/** 显示的图标 */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface TipExpose {
|
||||
/**
|
||||
* 显示提示文本
|
||||
* @param text 提示文字
|
||||
* @param icon 显示的图标,不填则不显示
|
||||
*/
|
||||
drawTip(text: string, icon?: AllIds | AllNumbers): void;
|
||||
}
|
||||
|
||||
const tipProps = {
|
||||
props: ['loc', 'pad', 'corner', 'id']
|
||||
} satisfies SetupComponentOptions<TipProps>;
|
||||
|
||||
let id = 0;
|
||||
function getNextTipId() {
|
||||
return `@default-tip-${id++}`;
|
||||
}
|
||||
|
||||
export const Tip = defineComponent<TipProps>((props, { expose }) => {
|
||||
const iconNum = ref<AllNumbers>(0);
|
||||
const text = ref<string>('');
|
||||
const textWidth = ref(0);
|
||||
|
||||
const font = new Font('normal');
|
||||
|
||||
const alpha = transitioned(0, 500, hyper('sin', 'in-out'))!;
|
||||
const pad = computed(() => props.pad ?? [4, 4]);
|
||||
const locHeight = computed(() => props.loc[3] ?? 200);
|
||||
const hidden = computed(() => alpha.ref.value === 0);
|
||||
const showIcon = computed(() => iconNum.value !== 0);
|
||||
const iconSize = computed<[number, number]>(() => {
|
||||
const renderable = texture.getRenderable(iconNum.value);
|
||||
if (!renderable) return [1, 1];
|
||||
const [, , width, height] = renderable.render[0];
|
||||
return [width, height];
|
||||
});
|
||||
const iconLoc = computed<ElementLocator>(() => {
|
||||
const [width, height] = iconSize.value;
|
||||
const aspect = width / height;
|
||||
const realHeight = locHeight.value - pad.value[1] * 2;
|
||||
const realWidth = realHeight * aspect;
|
||||
return [pad.value[0], pad.value[1], realWidth, realHeight];
|
||||
});
|
||||
const textLoc = computed<ElementLocator>(() => {
|
||||
if (showIcon.value) {
|
||||
const [, , width] = iconLoc.value;
|
||||
const x = width! + pad.value[0] + pad.value[1];
|
||||
return [x, locHeight.value / 2, void 0, void 0, 0, 0.5];
|
||||
} else {
|
||||
return [pad.value[0], locHeight.value / 2, void 0, void 0, 0, 0.5];
|
||||
}
|
||||
});
|
||||
const containerLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , height = 200] = props.loc;
|
||||
const iconWidth = iconLoc.value[2] ?? 32;
|
||||
if (showIcon.value) {
|
||||
const width =
|
||||
textWidth.value + iconWidth + pad.value[0] * 2 + pad.value[1];
|
||||
return [x, y, width, height];
|
||||
} else {
|
||||
const width = textWidth.value + pad.value[0] * 2;
|
||||
return [x, y, width, height];
|
||||
}
|
||||
});
|
||||
const rectLoc = computed<ElementLocator>(() => {
|
||||
const [, , width = 200, height = 200] = containerLoc.value;
|
||||
return [1, 1, width - 2, height - 2];
|
||||
});
|
||||
|
||||
const hide = debounce(() => {
|
||||
alpha.set(0);
|
||||
}, 3000);
|
||||
|
||||
const drawTip = (tipText: string, iconId: AllIds | AllNumbers = 0) => {
|
||||
if (typeof iconId === 'string') {
|
||||
const num = texture.idNumberMap[iconId];
|
||||
iconNum.value = num;
|
||||
} else {
|
||||
// 样板竟然会传 null 进来,然后报错
|
||||
iconNum.value = iconId ?? 0;
|
||||
}
|
||||
text.value = core.replaceText(tipText);
|
||||
alpha.set(0, 0);
|
||||
alpha.set(1);
|
||||
hide();
|
||||
};
|
||||
|
||||
const onSetText = (_: string, width: number) => {
|
||||
textWidth.value = width;
|
||||
};
|
||||
|
||||
const ex: TipExpose = { drawTip };
|
||||
|
||||
TipStore.use(props.id ?? getNextTipId(), ex);
|
||||
|
||||
expose<TipExpose>(ex);
|
||||
|
||||
return () => (
|
||||
<container
|
||||
loc={containerLoc.value}
|
||||
alpha={alpha.ref.value}
|
||||
hidden={hidden.value}
|
||||
noevent
|
||||
>
|
||||
<g-rectr
|
||||
loc={rectLoc.value}
|
||||
circle={[props.corner ?? 4]}
|
||||
fill
|
||||
fillStyle="rgba(0,0,0,0.8)"
|
||||
/>
|
||||
<icon
|
||||
hidden={!showIcon.value}
|
||||
icon={iconNum.value}
|
||||
loc={iconLoc.value}
|
||||
/>
|
||||
<text
|
||||
loc={textLoc.value}
|
||||
text={text.value}
|
||||
onSetText={onSetText}
|
||||
font={font}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, tipProps);
|
||||
|
||||
export class TipStore {
|
||||
static list: Map<string, TipStore> = new Map();
|
||||
|
||||
private constructor(private readonly data: TipExpose) {}
|
||||
|
||||
/**
|
||||
* 显示提示文本
|
||||
* @param text 提示文字
|
||||
* @param icon 显示的图标,不填则不显示
|
||||
*/
|
||||
drawTip(text: string, icon: AllIds | AllNumbers = 0) {
|
||||
this.data.drawTip(text, icon);
|
||||
}
|
||||
|
||||
static get(id: string) {
|
||||
return TipStore.list.get(id);
|
||||
}
|
||||
|
||||
static use(id: string, data: TipExpose) {
|
||||
const store = new TipStore(data);
|
||||
if (this.list.has(id)) {
|
||||
logger.warn(60, id);
|
||||
}
|
||||
this.list.set(id, store);
|
||||
onUnmounted(() => {
|
||||
this.list.delete(id);
|
||||
});
|
||||
return store;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
RenderAdapter,
|
||||
transformCanvas,
|
||||
ERenderItemEvent,
|
||||
RenderItem,
|
||||
Transform,
|
||||
MotaOffscreenCanvas2D
|
||||
} from '@motajs/render-core';
|
||||
import { HeroRenderer } from './hero';
|
||||
import { ILayerGroupRenderExtends, LayerGroup } from './layer';
|
||||
|
||||
export class LayerGroupAnimate implements ILayerGroupRenderExtends {
|
||||
static animateList: Set<LayerGroupAnimate> = new Set();
|
||||
id: string = 'animate';
|
||||
|
||||
group!: LayerGroup;
|
||||
hero?: HeroRenderer;
|
||||
animate!: Animate;
|
||||
|
||||
private animation: Set<AnimateData> = new Set();
|
||||
|
||||
/**
|
||||
* 绘制一个跟随勇士的动画
|
||||
* @param name 动画id
|
||||
*/
|
||||
async drawHeroAnimate(name: AnimationIds) {
|
||||
const animate = this.animate.animate(name, 0, 0);
|
||||
this.updatePosition(animate);
|
||||
await this.animate.draw(animate);
|
||||
this.animation.delete(animate);
|
||||
}
|
||||
|
||||
private updatePosition(animate: AnimateData) {
|
||||
if (!this.checkHero()) return;
|
||||
if (!this.hero?.renderable) return;
|
||||
const { x, y } = this.hero.renderable;
|
||||
const cell = this.group.cellSize;
|
||||
const half = cell / 2;
|
||||
animate.centerX = x * cell + half;
|
||||
animate.centerY = y * cell + half;
|
||||
}
|
||||
|
||||
private onMoveTick = (x: number, y: number) => {
|
||||
const cell = this.group.cellSize;
|
||||
const half = cell / 2;
|
||||
const ax = x * cell + half;
|
||||
const ay = y * cell + half;
|
||||
this.animation.forEach(v => {
|
||||
v.centerX = ax;
|
||||
v.centerY = ay;
|
||||
});
|
||||
};
|
||||
|
||||
private listen() {
|
||||
if (this.checkHero()) {
|
||||
this.hero!.on('moveTick', this.onMoveTick);
|
||||
}
|
||||
}
|
||||
|
||||
private checkHero() {
|
||||
if (this.hero) return true;
|
||||
const ex = this.group.getLayer('event')?.getExtends('floor-hero');
|
||||
if (ex instanceof HeroRenderer) {
|
||||
this.hero = ex;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
awake(group: LayerGroup): void {
|
||||
this.group = group;
|
||||
this.animate = new Animate();
|
||||
this.animate.size(group.width, group.height);
|
||||
this.animate.setHD(true);
|
||||
this.animate.setZIndex(100);
|
||||
group.appendChild(this.animate);
|
||||
LayerGroupAnimate.animateList.add(this);
|
||||
this.listen();
|
||||
}
|
||||
|
||||
onDestroy(_group: LayerGroup): void {
|
||||
if (this.checkHero()) {
|
||||
this.hero!.off('moveTick', this.onMoveTick);
|
||||
LayerGroupAnimate.animateList.delete(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AnimateData {
|
||||
obj: globalThis.Animate;
|
||||
/** 第一帧是全局第几帧 */
|
||||
readonly start: number;
|
||||
/** 当前是第几帧 */
|
||||
index: number;
|
||||
/** 是否需要播放音频 */
|
||||
sound: boolean;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
onEnd?: () => void;
|
||||
readonly absolute: boolean;
|
||||
}
|
||||
|
||||
export interface EAnimateEvent extends ERenderItemEvent {}
|
||||
|
||||
export class Animate extends RenderItem<EAnimateEvent> {
|
||||
/** 绝对位置的动画 */
|
||||
private absoluteAnimates: Set<AnimateData> = new Set();
|
||||
/** 静态位置的动画 */
|
||||
private staticAnimates: Set<AnimateData> = new Set();
|
||||
|
||||
private delegation: number;
|
||||
private frame: number = 0;
|
||||
private lastTime: number = 0;
|
||||
|
||||
constructor() {
|
||||
super('absolute', false, true);
|
||||
|
||||
this.delegation = this.delegateTicker(time => {
|
||||
if (time - this.lastTime < 50) return;
|
||||
this.lastTime = time;
|
||||
this.frame++;
|
||||
if (
|
||||
this.absoluteAnimates.size > 0 ||
|
||||
this.staticAnimates.size > 0
|
||||
) {
|
||||
this.update(this);
|
||||
}
|
||||
});
|
||||
|
||||
adapter.add(this);
|
||||
}
|
||||
|
||||
protected render(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
transform: Transform
|
||||
): void {
|
||||
if (
|
||||
this.absoluteAnimates.size === 0 &&
|
||||
this.staticAnimates.size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.drawAnimates(this.absoluteAnimates, canvas);
|
||||
transformCanvas(canvas, transform);
|
||||
this.drawAnimates(this.staticAnimates, canvas);
|
||||
}
|
||||
|
||||
private drawAnimates(
|
||||
data: Set<AnimateData>,
|
||||
canvas: MotaOffscreenCanvas2D
|
||||
) {
|
||||
if (data.size === 0) return;
|
||||
const { ctx } = canvas;
|
||||
const toDelete = new Set<AnimateData>();
|
||||
data.forEach(v => {
|
||||
const obj = v.obj;
|
||||
const index = v.index;
|
||||
const frame = obj.frames[index];
|
||||
const ratio = obj.ratio;
|
||||
if (!v.sound) {
|
||||
const se = (index % obj.frame) + 1;
|
||||
core.playSound(v.obj.se[se], v.obj.pitch[se]);
|
||||
v.sound = true;
|
||||
}
|
||||
const centerX = v.centerX;
|
||||
const centerY = v.centerY;
|
||||
|
||||
frame.forEach(v => {
|
||||
const img = obj.images[v.index];
|
||||
if (!img) return;
|
||||
|
||||
const realWidth = (img.width * ratio * v.zoom) / 100;
|
||||
const realHeight = (img.height * ratio * v.zoom) / 100;
|
||||
ctx.globalAlpha = v.opacity / 255;
|
||||
|
||||
const cx = centerX + v.x;
|
||||
const cy = centerY + v.y;
|
||||
|
||||
const ix = -realWidth / 2;
|
||||
const iy = -realHeight / 2;
|
||||
const angle = v.angle ? (-v.angle * Math.PI) / 180 : 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
if (v.mirror) {
|
||||
ctx.scale(-1, 1);
|
||||
}
|
||||
ctx.rotate(angle);
|
||||
ctx.drawImage(img, ix, iy, realWidth, realHeight);
|
||||
ctx.restore();
|
||||
});
|
||||
const now = this.frame - v.start;
|
||||
if (now !== v.index) v.sound = true;
|
||||
v.index = now;
|
||||
if (v.index === v.obj.frame) {
|
||||
toDelete.add(v);
|
||||
}
|
||||
});
|
||||
toDelete.forEach(v => {
|
||||
data.delete(v);
|
||||
v.onEnd?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个可以被执行的动画
|
||||
* @param name 动画名称
|
||||
* @param absolute 是否是绝对定位,绝对定位不会受到transform的影响
|
||||
*/
|
||||
animate(
|
||||
name: AnimationIds,
|
||||
x: number,
|
||||
y: number,
|
||||
absolute: boolean = false
|
||||
) {
|
||||
const animate = core.material.animates[name];
|
||||
const data: AnimateData = {
|
||||
index: 0,
|
||||
start: this.frame,
|
||||
obj: animate,
|
||||
centerX: x,
|
||||
centerY: y,
|
||||
absolute,
|
||||
sound: false
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制动画,动画结束时兑现返回的Promise
|
||||
* @param animate 动画信息
|
||||
* @returns
|
||||
*/
|
||||
draw(animate: AnimateData): Promise<void> {
|
||||
return new Promise(res => {
|
||||
if (animate.absolute) {
|
||||
this.absoluteAnimates.add(animate);
|
||||
} else {
|
||||
this.staticAnimates.add(animate);
|
||||
}
|
||||
animate.onEnd = () => {
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据动画名称、坐标、定位绘制动画
|
||||
* @param name 动画名称
|
||||
* @param absolute 是否是绝对定位
|
||||
*/
|
||||
drawAnimate(
|
||||
name: AnimationIds,
|
||||
x: number,
|
||||
y: number,
|
||||
absolute: boolean = false
|
||||
) {
|
||||
return this.draw(this.animate(name, x, y, absolute));
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
super.destroy();
|
||||
this.removeTicker(this.delegation);
|
||||
adapter.remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = new RenderAdapter<Animate>('animate');
|
||||
adapter.receive('drawAnimate', (item, name, x, y, absolute) => {
|
||||
return item.drawAnimate(name, x, y, absolute);
|
||||
});
|
||||
adapter.receiveGlobal('drawHeroAnimate', name => {
|
||||
const execute: Promise<void>[] = [];
|
||||
LayerGroupAnimate.animateList.forEach(v => {
|
||||
execute.push(v.drawHeroAnimate(name));
|
||||
});
|
||||
return Promise.all(execute);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { logger } from '@motajs/common';
|
||||
import { MotaOffscreenCanvas2D, RenderItem } from '@motajs/render-core';
|
||||
|
||||
interface BlockCacherEvent {
|
||||
split: [];
|
||||
beforeClear: [index: number];
|
||||
}
|
||||
|
||||
interface BlockData {
|
||||
/** 横向宽度,包括rest的那一个块 */
|
||||
width: number;
|
||||
/** 纵向宽度,包括rest的那一个块 */
|
||||
height: number;
|
||||
/** 横向最后一个块的宽度 */
|
||||
restWidth: number;
|
||||
/** 纵向最后一个块的高度 */
|
||||
restHeight: number;
|
||||
}
|
||||
|
||||
export interface IBlockCacheable {
|
||||
/**
|
||||
* 摧毁这个缓存元素
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单分块缓存类,内容包含元素与分块两种,其中元素是最小单元,分块是缓存单元。
|
||||
* 拿楼层举例,假如我将楼层按照13x13划分缓存,那么元素就是每个图块,而分块就是这13x13的缓存分块。
|
||||
* 为方便区分,在相关函数的注释最后,都会有`xx -> yy`的说明,
|
||||
* 其中xx说明传入的数据是元素还是分块的数据,而yy表示其返回值或转换为的值
|
||||
*/
|
||||
export class BlockCacher<
|
||||
T extends IBlockCacheable
|
||||
> extends EventEmitter<BlockCacherEvent> {
|
||||
/** 区域宽度 */
|
||||
width: number;
|
||||
/** 区域高度 */
|
||||
height: number;
|
||||
/** 区域面积 */
|
||||
area: number = 0;
|
||||
/** 分块大小 */
|
||||
blockSize: number;
|
||||
/** 分块信息 */
|
||||
blockData: BlockData = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
restWidth: 0,
|
||||
restHeight: 0
|
||||
};
|
||||
/** 缓存深度,例如填4的时候表示每格包含4个缓存 */
|
||||
cacheDepth: number = 1;
|
||||
|
||||
/** 缓存内容,计算公式为 (x + y * width) * depth + deep */
|
||||
cache: Map<number, T> = new Map();
|
||||
|
||||
constructor(
|
||||
width: number,
|
||||
height: number,
|
||||
size: number,
|
||||
depth: number = 1
|
||||
) {
|
||||
super();
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.blockSize = size;
|
||||
this.cacheDepth = depth;
|
||||
this.split();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置区域大小
|
||||
* @param width 区域宽度
|
||||
* @param height 区域高度
|
||||
*/
|
||||
size(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.split();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置分块大小
|
||||
*/
|
||||
setBlockSize(size: number) {
|
||||
this.blockSize = size;
|
||||
this.split();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存深度,设置后会自动将旧缓存移植到新缓存中,最大值为31
|
||||
* @param depth 缓存深度
|
||||
*/
|
||||
setCacheDepth(depth: number) {
|
||||
if (depth > 31) {
|
||||
logger.error(11);
|
||||
return;
|
||||
}
|
||||
const old = this.cache;
|
||||
const before = this.cacheDepth;
|
||||
this.cache = new Map();
|
||||
old.forEach((v, k) => {
|
||||
const index = Math.floor(k / before);
|
||||
const deep = k % before;
|
||||
this.cache.set(index * depth + deep, v);
|
||||
});
|
||||
old.clear();
|
||||
this.cacheDepth = depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行分块
|
||||
*/
|
||||
split() {
|
||||
this.blockData = {
|
||||
width: Math.ceil(this.width / this.blockSize),
|
||||
height: Math.ceil(this.height / this.blockSize),
|
||||
restWidth: this.width % this.blockSize,
|
||||
restHeight: this.height % this.blockSize
|
||||
};
|
||||
this.area = this.blockData.width * this.blockData.height;
|
||||
this.emit('split');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定块的索引(分块->void)
|
||||
* @param index 要清除的缓存索引
|
||||
* @param deep 清除哪些深度的缓存,至多31位二进制数,例如填0b111就是清除前三层的索引
|
||||
*/
|
||||
clearCache(index: number, deep: number) {
|
||||
const depth = this.cacheDepth;
|
||||
for (let i = 0; i < depth; i++) {
|
||||
if (deep & (1 << i)) {
|
||||
const nowIndex = index * this.cacheDepth + i;
|
||||
const item = this.cache.get(nowIndex);
|
||||
item?.destroy();
|
||||
this.cache.delete(nowIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定索引的缓存,与 {@link clearCache} 不同的是,这里会直接清空对应索引的缓存,而不是指定分块的缓存(元素->void)
|
||||
*/
|
||||
clearCacheByIndex(index: number) {
|
||||
const item = this.cache.get(index);
|
||||
item?.destroy();
|
||||
this.cache.delete(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clearAllCache() {
|
||||
this.cache.forEach(v => v.destroy());
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分块的横纵坐标获取其索引(分块->分块)
|
||||
*/
|
||||
getIndex(x: number, y: number) {
|
||||
return x + y * this.blockData.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据元素位置获取分块索引(注意是单个元素的位置,而不是分块的位置)(元素->分块)
|
||||
*/
|
||||
getIndexByLoc(x: number, y: number) {
|
||||
return this.getIndex(
|
||||
Math.floor(x / this.blockSize),
|
||||
Math.floor(y / this.blockSize)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据块的索引获取其位置(分块->分块)
|
||||
*/
|
||||
getBlockXYByIndex(index: number): LocArr {
|
||||
const width = this.blockData.width;
|
||||
return [index % width, Math.floor(index / width)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个元素位置所在的分块位置(即使它不在任何分块内)(元素->分块)
|
||||
*/
|
||||
getBlockXY(x: number, y: number): LocArr {
|
||||
return [Math.floor(x / this.blockSize), Math.floor(y / this.blockSize)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分块坐标与deep获取一个分块的精确索引(分块->分块)
|
||||
*/
|
||||
getPreciseIndex(x: number, y: number, deep: number) {
|
||||
return (x + y * this.blockSize) * this.cacheDepth + deep;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据元素坐标及deep获取元素所在块的精确索引(元素->分块)
|
||||
*/
|
||||
getPreciseIndexByLoc(x: number, y: number, deep: number) {
|
||||
return this.getPreciseIndex(...this.getBlockXY(x, y), deep);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定元素区域内的缓存(注意坐标是元素坐标,而非分块坐标)(元素->分块)
|
||||
* @param deep 缓存清除深度,默认全部清空
|
||||
* @returns 更新区域内的所有有关分块索引
|
||||
*/
|
||||
updateElementArea(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
deep: number = 2 ** 31 - 1
|
||||
) {
|
||||
const [bx, by] = this.getBlockXY(x, y);
|
||||
const [ex, ey] = this.getBlockXY(x + width - 1, y + height - 1);
|
||||
|
||||
return this.updateArea(bx, by, ex - bx, ey - by, deep);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定分块区域内的缓存(注意坐标是分块坐标,而非元素坐标)(分块->分块)
|
||||
* @param deep 缓存清除深度,默认全部清空
|
||||
* @returns 更新区域内的所有分块索引
|
||||
*/
|
||||
updateArea(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
deep: number = 2 ** 31 - 1
|
||||
) {
|
||||
const blocks = this.getIndexOf(x, y, width, height);
|
||||
|
||||
blocks.forEach(v => {
|
||||
this.clearCache(v, deep);
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入分块坐标与范围,获取该区域内包含的所有分块索引(分块->分块)
|
||||
*/
|
||||
getIndexOf(x: number, y: number, width: number, height: number) {
|
||||
const res = new Set<number>();
|
||||
const sx = Math.max(x, 0);
|
||||
const sy = Math.max(y, 0);
|
||||
const ex = Math.min(x + width, this.blockData.width);
|
||||
const ey = Math.min(y + height, this.blockData.height);
|
||||
|
||||
for (let nx = sx; nx <= ex; nx++) {
|
||||
for (let ny = sy; ny <= ey; ny++) {
|
||||
const index = this.getIndex(nx, ny);
|
||||
res.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入元素坐标与范围,获取该区域内包含的所有分块索引(元素->分块)
|
||||
*/
|
||||
getIndexOfElement(x: number, y: number, width: number, height: number) {
|
||||
const [bx, by] = this.getBlockXY(x, y);
|
||||
const [ex, ey] = this.getBlockXY(x + width, y + height);
|
||||
return this.getIndexOf(bx, by, ex - bx, ey - by);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分块索引,获取这个分块所在区域的元素矩形范围(左上角横纵坐标及右下角横纵坐标)(分块->元素)
|
||||
* @param block 分块索引
|
||||
*/
|
||||
getRectOfIndex(block: number) {
|
||||
const [x, y] = this.getBlockXYByIndex(block);
|
||||
return this.getRectOfBlockXY(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分块坐标,获取这个分块所在区域的元素矩形范围(左上角横纵坐标及右下角横纵坐标)(分块->元素)
|
||||
* @param x 分块横坐标
|
||||
* @param y 分块纵坐标
|
||||
*/
|
||||
getRectOfBlockXY(x: number, y: number) {
|
||||
return [
|
||||
x * this.blockSize,
|
||||
y * this.blockSize,
|
||||
(x + 1) * this.blockSize,
|
||||
(y + 1) * this.blockSize
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 摧毁这个块缓存
|
||||
*/
|
||||
destroy() {
|
||||
this.clearAllCache();
|
||||
}
|
||||
}
|
||||
|
||||
export interface ICanvasCacheItem extends IBlockCacheable {
|
||||
readonly canvas: MotaOffscreenCanvas2D;
|
||||
symbol: number;
|
||||
}
|
||||
|
||||
export class CanvasCacheItem implements ICanvasCacheItem {
|
||||
constructor(
|
||||
public readonly canvas: MotaOffscreenCanvas2D,
|
||||
public readonly symbol: number,
|
||||
public readonly element: RenderItem<any>
|
||||
) {}
|
||||
|
||||
destroy(): void {
|
||||
this.element.deleteCanvas(this.canvas);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
MotaOffscreenCanvas2D,
|
||||
SizedCanvasImageSource
|
||||
} from '@motajs/render-core';
|
||||
|
||||
// 经过测试(https://www.measurethat.net/Benchmarks/Show/30741/1/drawimage-img-vs-canvas-vs-bitmap-cropping-fix-loading)
|
||||
// 得出结论,ImageBitmap和Canvas的绘制性能不如Image,于是直接画Image就行,所以缓存基本上就是存Image
|
||||
|
||||
type ImageMapKeys = Exclude<Cls, 'tileset' | 'autotile'>;
|
||||
type ImageMap = Record<ImageMapKeys, HTMLImageElement>;
|
||||
|
||||
const i = (img: ImageMapKeys) => {
|
||||
return core.material.images[img];
|
||||
};
|
||||
|
||||
const imageMap: Partial<ImageMap> = {};
|
||||
|
||||
interface AutotileCache {
|
||||
parent?: Set<AllNumbersOf<'autotile'>>;
|
||||
frame: number;
|
||||
cache: Record<string, HTMLCanvasElement>;
|
||||
}
|
||||
type AutotileCaches = Record<AllNumbersOf<'autotile'>, AutotileCache>;
|
||||
|
||||
interface TextureRequire {
|
||||
tileset: Record<string, HTMLImageElement>;
|
||||
material: Record<ImageMapKeys, HTMLImageElement>;
|
||||
autotile: AutotileCaches;
|
||||
images: Record<ImageIds, HTMLImageElement>;
|
||||
}
|
||||
|
||||
interface RenderableDataBase {
|
||||
/** 图块的总帧数 */
|
||||
frame: number;
|
||||
/** 对应图块属性的动画帧数,-1表示没有设定,0表示第一帧 */
|
||||
animate: number;
|
||||
/** 是否是大怪物 */
|
||||
bigImage: boolean;
|
||||
render: [x: number, y: number, width: number, height: number][];
|
||||
}
|
||||
|
||||
export interface RenderableData extends RenderableDataBase {
|
||||
image: SizedCanvasImageSource;
|
||||
autotile: false;
|
||||
}
|
||||
|
||||
export interface AutotileRenderable extends RenderableDataBase {
|
||||
image: Record<string, SizedCanvasImageSource>;
|
||||
autotile: true;
|
||||
bigImage: false;
|
||||
}
|
||||
|
||||
class TextureCache {
|
||||
tileset!: Record<string, HTMLImageElement>;
|
||||
material: Record<ImageMapKeys, HTMLImageElement>;
|
||||
autotile!: AutotileCaches;
|
||||
images!: Record<ImageIds, HTMLImageElement>;
|
||||
|
||||
idNumberMap!: IdToNumber;
|
||||
|
||||
/** 渲染信息 */
|
||||
renderable: Map<number, RenderableData | AutotileRenderable> = new Map();
|
||||
/** 自动元件额外连接信息,用于对非自身图块进行连接 */
|
||||
autoConn: Map<number, Set<number>> = new Map();
|
||||
/** 行走图朝向绑定 */
|
||||
characterDirection: Record<Dir2, number> = {
|
||||
down: 0,
|
||||
left: 1,
|
||||
right: 2,
|
||||
up: 3,
|
||||
leftup: 1,
|
||||
leftdown: 1,
|
||||
rightup: 2,
|
||||
rightdown: 2
|
||||
};
|
||||
/** 行走图转向顺序 */
|
||||
characterTurn: Dir[] = ['up', 'right', 'down', 'left'];
|
||||
characterTurn2: Dir2[] = ['leftup', 'rightup', 'rightdown', 'leftdown'];
|
||||
|
||||
constructor() {
|
||||
this.material = imageMap as Record<ImageMapKeys, HTMLImageElement>;
|
||||
}
|
||||
|
||||
init() {
|
||||
const map = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
|
||||
// @ts-expect-error 无法推导
|
||||
this.idNumberMap = {};
|
||||
for (const [key, { id }] of Object.entries(map)) {
|
||||
// @ts-expect-error 无法推导
|
||||
this.idNumberMap[id] = parseInt(key) as AllNumbers;
|
||||
}
|
||||
this.tileset = core.material.images.tilesets;
|
||||
this.autotile = splitAutotiles(this.idNumberMap);
|
||||
this.images = core.material.images.images;
|
||||
this.calRenderable();
|
||||
this.calAutotileConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取纹理
|
||||
* @param type 纹理类型
|
||||
* @param key 纹理名称
|
||||
*/
|
||||
require<T extends keyof TextureRequire, K extends keyof TextureRequire[T]>(
|
||||
type: T,
|
||||
key: K
|
||||
): TextureRequire[T][K] {
|
||||
return this[type][key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每个图块的可渲染信息
|
||||
*/
|
||||
private calRenderable() {
|
||||
const map = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
|
||||
for (const key of Object.keys(map)) {
|
||||
this.calRenderableByNum(parseInt(key));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图块数字计算出它的渲染信息
|
||||
* @param num 图块数字
|
||||
*/
|
||||
calRenderableByNum(
|
||||
num: number
|
||||
): RenderableData | AutotileRenderable | null {
|
||||
const map = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
|
||||
const enemys = enemys_fcae963b_31c9_42b4_b48c_bb48d09f3f80;
|
||||
const icons = icons_4665ee12_3a1f_44a4_bea3_0fccba634dc1;
|
||||
|
||||
/** 特判空图块与空气墙 */
|
||||
if (num === 0 || num === 17) return null;
|
||||
|
||||
// 额外素材
|
||||
if (num >= 10000) {
|
||||
const offset = core.getTilesetOffset(num);
|
||||
if (!offset) return null;
|
||||
const { image, x, y } = offset;
|
||||
const data: RenderableData = {
|
||||
image: this.tileset[image],
|
||||
frame: 1,
|
||||
render: [[x * 32, y * 32, 32, 32]],
|
||||
animate: 0,
|
||||
autotile: false,
|
||||
bigImage: false
|
||||
};
|
||||
this.renderable.set(num, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = map[num as Exclude<AllNumbers, 0>];
|
||||
// 地狱般的分支if
|
||||
if (data) {
|
||||
let { faceIds, bigImage } = data;
|
||||
const { cls, id, animate } = data;
|
||||
if (cls === 'enemys' || cls === 'enemy48') {
|
||||
// 怪物需要特殊处理,因为它的大怪物信息不在 maps 里面
|
||||
({ bigImage, faceIds } = enemys[id as EnemyIds]);
|
||||
}
|
||||
if (bigImage) {
|
||||
const image = core.material.images.images[bigImage];
|
||||
if (!image) {
|
||||
logger.warn(10, id);
|
||||
return null;
|
||||
}
|
||||
let line = 0;
|
||||
if (faceIds) {
|
||||
const arr = ['down', 'left', 'right', 'up'];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (faceIds[arr[i] as Dir] === id) {
|
||||
line = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalLines = image.width / image.height >= 2 ? 1 : 4;
|
||||
const w = Math.round(image.width / 4);
|
||||
const h = Math.round(image.height / totalLines);
|
||||
const y = h * line;
|
||||
const data: RenderableData = {
|
||||
image,
|
||||
frame: 4,
|
||||
render: [
|
||||
[0, y, w, h],
|
||||
[w, y, w, h],
|
||||
[w * 2, y, w, h],
|
||||
[w * 3, y, w, h]
|
||||
],
|
||||
animate: (animate ?? 0) - 1,
|
||||
autotile: false,
|
||||
bigImage: true
|
||||
};
|
||||
this.renderable.set(num, data);
|
||||
return data;
|
||||
}
|
||||
// enemy48和npc48都应该视为大怪物
|
||||
if (cls === 'enemy48' || cls === 'npc48') {
|
||||
const img = core.material.images[cls];
|
||||
if (!img) return null;
|
||||
// @ts-expect-error 无法推导
|
||||
const line = icons[cls][id];
|
||||
const w = 32;
|
||||
const h = 48;
|
||||
const y = h * line;
|
||||
const data: RenderableData = {
|
||||
image: img,
|
||||
frame: 4,
|
||||
render: [
|
||||
[0, y, w, h],
|
||||
[w, y, w, h],
|
||||
[w * 2, y, w, h],
|
||||
[w * 3, y, w, h]
|
||||
],
|
||||
animate: (animate ?? 0) - 1,
|
||||
autotile: false,
|
||||
bigImage: true
|
||||
};
|
||||
this.renderable.set(num, data);
|
||||
return data;
|
||||
}
|
||||
// 自动元件
|
||||
if (cls === 'autotile') {
|
||||
if (!this.autotile) return null;
|
||||
const auto = this.autotile[num as AllNumbersOf<'autotile'>];
|
||||
const cell = 32;
|
||||
const render: [number, number, number, number][] = [];
|
||||
if (auto.frame >= 1) {
|
||||
render.push([0, 0, cell, cell]);
|
||||
}
|
||||
if (auto.frame >= 3) {
|
||||
render.push(
|
||||
[cell, 0, cell, cell],
|
||||
[cell * 2, 0, cell, cell]
|
||||
);
|
||||
}
|
||||
if (auto.frame >= 4) {
|
||||
render.push([cell * 3, 0, cell, cell]);
|
||||
}
|
||||
const data: AutotileRenderable = {
|
||||
image: auto.cache,
|
||||
frame: auto.frame,
|
||||
render,
|
||||
autotile: true,
|
||||
bigImage: false,
|
||||
animate: (animate ?? 0) - 1
|
||||
};
|
||||
this.renderable.set(num, data);
|
||||
return data;
|
||||
} else {
|
||||
const image =
|
||||
core.material.images[
|
||||
cls as Exclude<Cls, 'tileset' | 'autotile'>
|
||||
];
|
||||
if (!image) return null;
|
||||
const frame = core.getAnimateFrames(cls);
|
||||
const cell = 32;
|
||||
// @ts-expect-error 无法推导
|
||||
const offset = (icons[cls][id] as number) * cell;
|
||||
const render: [number, number, number, number][] = [
|
||||
[0, offset, cell, cell]
|
||||
];
|
||||
if (frame === 2) {
|
||||
render.push([cell, offset, cell, cell]);
|
||||
}
|
||||
if (frame === 4) {
|
||||
render.push(
|
||||
[cell, offset, cell, cell],
|
||||
[cell * 2, offset, cell, cell],
|
||||
[cell * 3, offset, cell, cell]
|
||||
);
|
||||
}
|
||||
const data: RenderableData = {
|
||||
image,
|
||||
frame: frame,
|
||||
render,
|
||||
autotile: false,
|
||||
bigImage: false,
|
||||
animate: (animate ?? 0) - 1
|
||||
};
|
||||
this.renderable.set(num, data);
|
||||
return data;
|
||||
}
|
||||
} else {
|
||||
logger.warn(11, num.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个图块的渲染信息,自动元件会特别标明autotile属性
|
||||
* @param num 图块数字
|
||||
*/
|
||||
getRenderable(num: number) {
|
||||
return this.renderable.get(num) ?? this.calRenderableByNum(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算自动元件的额外连接
|
||||
*/
|
||||
private calAutotileConnections() {
|
||||
const icons = icons_4665ee12_3a1f_44a4_bea3_0fccba634dc1;
|
||||
const maps = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
|
||||
|
||||
Object.keys(icons.autotile).forEach(v => {
|
||||
const num = this.idNumberMap[v as AllIdsOf<'autotile'>];
|
||||
const data = maps[num];
|
||||
const { autotileConnection } = data;
|
||||
if (!autotileConnection) return;
|
||||
const list = new Set<AllNumbers>();
|
||||
autotileConnection.forEach(v => {
|
||||
if (typeof v === 'number') {
|
||||
list.add(v);
|
||||
} else {
|
||||
list.add(this.idNumberMap[v]);
|
||||
}
|
||||
});
|
||||
this.autoConn.set(num, list);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自动元件的额外连接情况
|
||||
* @param num 自动元件的图块数字
|
||||
*/
|
||||
getAutotileConnections(num: number) {
|
||||
return this.autoConn.get(num);
|
||||
}
|
||||
}
|
||||
|
||||
export const texture = new TextureCache();
|
||||
|
||||
// 3x4 与 2x3 的自动元件信息
|
||||
// 将自动元件按 16x16 切分后,数组分别表示 左上 右上 右下 左下 所在图块位置
|
||||
const bigAutotile: Record<number, [number, number, number, number]> = {};
|
||||
const smallAutotile: Record<number, [number, number, number, number]> = {};
|
||||
|
||||
function getAutotileIndices() {
|
||||
// 应当从 0 - 255 进行枚举
|
||||
// 二进制从高位到低位依次是 左上 上 右上 右 右下 下 左下 左
|
||||
// 有兴趣可以研究下这个算法
|
||||
const get = (
|
||||
target: Record<number, [number, number, number, number]>,
|
||||
mode: 1 | 2
|
||||
) => {
|
||||
const h = mode === 1 ? 4 : 2;
|
||||
const v = mode === 1 ? 24 : 8;
|
||||
const luo = mode === 1 ? 12 : 8; // leftup origin
|
||||
const ruo = mode === 1 ? 17 : 11; // rightup origin
|
||||
const ldo = mode === 1 ? 42 : 20; // leftdown origin
|
||||
const rdo = mode === 1 ? 47 : 23; // rightdown origin
|
||||
const luc = mode === 1 ? 4 : 2; // leftup corner
|
||||
const ruc = mode === 1 ? 5 : 3; // rightup corner
|
||||
const rdc = mode === 1 ? 11 : 7; // rightdown corner
|
||||
const ldc = mode === 1 ? 10 : 6; // leftdown corner
|
||||
|
||||
for (let i = 0; i <= 0b11111111; i++) {
|
||||
let lu = luo; // leftup
|
||||
let ru = ruo; // rightup
|
||||
let ld = ldo; // leftdown
|
||||
let rd = rdo; // rightdown
|
||||
|
||||
// 先看四个方向,最后看斜角方向
|
||||
if (i & 0b00000001) {
|
||||
lu += h;
|
||||
ld += h;
|
||||
if (i & 0b00010000) {
|
||||
ru += h / 2;
|
||||
rd += h / 2;
|
||||
}
|
||||
}
|
||||
if (i & 0b00000100) {
|
||||
ld -= v;
|
||||
rd -= v;
|
||||
if (i & 0b01000000) {
|
||||
lu -= v / 2;
|
||||
ru -= v / 2;
|
||||
}
|
||||
}
|
||||
if (i & 0b00010000) {
|
||||
ru -= h;
|
||||
rd -= h;
|
||||
if (i & 0b00000001) {
|
||||
lu -= h / 2;
|
||||
ld -= h / 2;
|
||||
}
|
||||
}
|
||||
if (i & 0b01000000) {
|
||||
lu += v;
|
||||
ru += v;
|
||||
if (i & 0b00000100) {
|
||||
ld += v / 2;
|
||||
rd += v / 2;
|
||||
}
|
||||
}
|
||||
// 斜角
|
||||
if ((i & 0b11000001) === 0b01000001) {
|
||||
lu = luc;
|
||||
}
|
||||
if ((i & 0b01110000) === 0b01010000) {
|
||||
ru = ruc;
|
||||
}
|
||||
if ((i & 0b00011100) === 0b00010100) {
|
||||
rd = rdc;
|
||||
}
|
||||
if ((i & 0b00000111) === 0b00000101) {
|
||||
ld = ldc;
|
||||
}
|
||||
target[i] = [lu, ru, rd, ld];
|
||||
}
|
||||
};
|
||||
|
||||
get(bigAutotile, 1);
|
||||
get(smallAutotile, 2);
|
||||
}
|
||||
getAutotileIndices();
|
||||
|
||||
function getRepeatMap() {
|
||||
// 实际上3x4与2x3的重复映射是一致的,因此只需要计算一个就行了
|
||||
// 这里使用2x3的进行计算
|
||||
const calculated: Record<string, number> = {};
|
||||
const repeatMap: Record<number, number> = {};
|
||||
for (const [num, [lu, ru, rd, ld]] of Object.entries(smallAutotile)) {
|
||||
const n = lu + ru * 24 + rd * 24 ** 2 + ld * 24 ** 3;
|
||||
calculated[num] = n;
|
||||
for (const [num2, n2] of Object.entries(calculated)) {
|
||||
if (n2 === n) {
|
||||
repeatMap[Number(num)] = Number(num2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return repeatMap;
|
||||
}
|
||||
|
||||
function splitAutotiles(map: IdToNumber): AutotileCaches {
|
||||
const cache: Partial<AutotileCaches> = {};
|
||||
/** 重复映射,由于自动元件只有48种,其余的208种是重复的,因此需要获取重复映射 */
|
||||
const repeatMap: Record<number, number> = getRepeatMap();
|
||||
/** 每个自动元件左上角32*32的内容,用于判断父子关系 */
|
||||
const masterMap: Partial<Record<AllNumbersOf<'autotile'>, string>> = {};
|
||||
|
||||
for (const [key, img] of Object.entries(core.material.images.autotile)) {
|
||||
const auto = map[key as AllIdsOf<'autotile'>];
|
||||
|
||||
// 判断自动元件类型
|
||||
let mode: 1 | 2 = 1;
|
||||
let frame = 1;
|
||||
if (img.width === 384) {
|
||||
frame = 4;
|
||||
} else if (img.width === 192) {
|
||||
mode = 2;
|
||||
frame = 3;
|
||||
} else if (img.width === 64) {
|
||||
mode = 2;
|
||||
}
|
||||
|
||||
cache[auto] = {
|
||||
frame,
|
||||
cache: {}
|
||||
};
|
||||
|
||||
// 父子关系,截取本图块的左上角存入map
|
||||
const master = new MotaOffscreenCanvas2D();
|
||||
master.setHD(false);
|
||||
master.setAntiAliasing(false);
|
||||
master.size(32, 32);
|
||||
master.ctx.drawImage(img, 0, 0, 32, 32, 0, 0, 32, 32);
|
||||
masterMap[auto] = master.canvas.toDataURL('image/png');
|
||||
|
||||
// 自动图块的绘制信息
|
||||
for (let i = 0; i <= 0b11111111; i++) {
|
||||
const re = repeatMap[i];
|
||||
if (re) {
|
||||
const cached = cache[auto]!.cache[re];
|
||||
if (cached) {
|
||||
cache[auto]!.cache[i] = cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (mode === 1 ? bigAutotile : smallAutotile)[i];
|
||||
const row = mode === 1 ? 6 : 4;
|
||||
const info: [number, number][] = data.map(v => [
|
||||
(v % row) * 16,
|
||||
Math.floor(v / row) * 16
|
||||
]);
|
||||
|
||||
const canvas = new MotaOffscreenCanvas2D();
|
||||
canvas.setHD(false);
|
||||
canvas.setAntiAliasing(false);
|
||||
canvas.size(32 * frame, 32);
|
||||
const ctx = canvas.ctx;
|
||||
for (let i = 0; i < frame; i++) {
|
||||
const dx = 32 * i;
|
||||
const sx1 = info[0][0] + (i * row * 32) / 2;
|
||||
const sx2 = info[1][0] + (i * row * 32) / 2;
|
||||
const sx3 = info[2][0] + (i * row * 32) / 2;
|
||||
const sx4 = info[3][0] + (i * row * 32) / 2;
|
||||
const sy1 = info[0][1];
|
||||
const sy2 = info[1][1];
|
||||
const sy3 = info[2][1];
|
||||
const sy4 = info[3][1];
|
||||
|
||||
ctx.drawImage(img, sx1, sy1, 16, 16, dx, 0, 16, 16);
|
||||
ctx.drawImage(img, sx2, sy2, 16, 16, dx + 16, 0, 16, 16);
|
||||
ctx.drawImage(img, sx3, sy3, 16, 16, dx + 16, 16, 16, 16);
|
||||
ctx.drawImage(img, sx4, sy4, 16, 16, dx, 16, 16, 16);
|
||||
}
|
||||
cache[auto]!.cache[i] = canvas.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
const judge = new MotaOffscreenCanvas2D();
|
||||
judge.setHD(false);
|
||||
judge.setAntiAliasing(false);
|
||||
judge.size(32, 32);
|
||||
// 进行父子关系判断
|
||||
for (const [key, img] of Object.entries(core.material.images.autotile)) {
|
||||
const auto = map[key as AllIdsOf<'autotile'>];
|
||||
|
||||
// 只针对3*4的图块进行,截取第一行中间的,然后判断
|
||||
judge.clear();
|
||||
judge.ctx.drawImage(img, 32, 0, 32, 32, 0, 0, 32, 32);
|
||||
const data = judge.canvas.toDataURL('image/png');
|
||||
|
||||
for (const [key, data2] of Object.entries(masterMap)) {
|
||||
const auto2 = map[key as AllIdsOf<'autotile'>];
|
||||
|
||||
if (data === data2) {
|
||||
cache[auto]!.parent ??= new Set();
|
||||
cache[auto]!.parent!.add(auto2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cache as AutotileCaches;
|
||||
}
|
||||
|
||||
export function createCache() {
|
||||
const { loading } = Mota.require('@user/data-base');
|
||||
loading.once('loaded', () => {
|
||||
[
|
||||
'enemys',
|
||||
'enemy48',
|
||||
'npcs',
|
||||
'npc48',
|
||||
'terrains',
|
||||
'items',
|
||||
'animates'
|
||||
].forEach(v => (imageMap[v as ImageMapKeys] = i(v as ImageMapKeys)));
|
||||
texture.init();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { Animation, TimingFn, Transition } from 'mutate-animate';
|
||||
import { RenderItem, Transform } from '@motajs/render-core';
|
||||
import { logger } from '@motajs/common';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
export interface ICameraTranslate {
|
||||
readonly type: 'translate';
|
||||
readonly from: Camera;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ICameraRotate {
|
||||
readonly type: 'rotate';
|
||||
readonly from: Camera;
|
||||
/** 旋转角,单位弧度 */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface ICameraScale {
|
||||
readonly type: 'scale';
|
||||
readonly from: Camera;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type CameraOperation = ICameraTranslate | ICameraScale | ICameraRotate;
|
||||
|
||||
interface CameraEvent {
|
||||
destroy: [];
|
||||
}
|
||||
|
||||
export class Camera extends EventEmitter<CameraEvent> {
|
||||
/** 当前绑定的渲染元素 */
|
||||
readonly binded: RenderItem;
|
||||
/** 目标变换矩阵,默认与 `this.binded.transform` 同引用 */
|
||||
transform: Transform;
|
||||
|
||||
/** 委托ticker的id */
|
||||
private delegation: number;
|
||||
/** 所有的动画id */
|
||||
private animationIds: Set<number> = new Set();
|
||||
|
||||
/** 是否需要更新视角 */
|
||||
private needUpdate: boolean = false;
|
||||
/** 是否启用摄像机 */
|
||||
private enabled: boolean = true;
|
||||
|
||||
/** 变换操作列表,因为矩阵乘法跟顺序有关,因此需要把各个操作拆分成列表进行 */
|
||||
protected operation: CameraOperation[] = [];
|
||||
|
||||
/** 渲染元素到摄像机的映射 */
|
||||
private static cameraMap: Map<RenderItem, Camera> = new Map();
|
||||
|
||||
/**
|
||||
* 获取一个渲染元素的摄像机,如果不存在则为它创建一个并返回。注意使用`new Camera`创建的摄像机不在此列
|
||||
* @param item 渲染元素
|
||||
*/
|
||||
static for(item: RenderItem) {
|
||||
const camera = this.cameraMap.get(item);
|
||||
if (!camera) {
|
||||
const ca = new Camera(item);
|
||||
this.cameraMap.set(item, ca);
|
||||
return ca;
|
||||
} else {
|
||||
return camera;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(item: RenderItem) {
|
||||
super();
|
||||
|
||||
this.binded = item;
|
||||
|
||||
this.delegation = item.delegateTicker(() => this.tick());
|
||||
this.transform = item.transform;
|
||||
|
||||
item.on('destroy', () => {
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
const ca = Camera.cameraMap.get(item);
|
||||
if (ca && ca.enabled) {
|
||||
logger.warn(22);
|
||||
}
|
||||
}
|
||||
|
||||
private tick = () => {
|
||||
if (!this.needUpdate || !this.enabled) return;
|
||||
const trans = this.transform;
|
||||
trans.reset();
|
||||
for (const o of this.operation) {
|
||||
if (o.type === 'translate') {
|
||||
trans.translate(-o.x, -o.y);
|
||||
} else if (o.type === 'rotate') {
|
||||
trans.rotate(o.angle);
|
||||
} else {
|
||||
trans.scale(o.x, o.y);
|
||||
}
|
||||
}
|
||||
this.binded.update(this.binded);
|
||||
this.needUpdate = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 禁用这个摄像机
|
||||
*/
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用这个摄像机
|
||||
*/
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在下一帧进行强制更新
|
||||
*/
|
||||
requestUpdate() {
|
||||
this.needUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个变换操作
|
||||
* @param operation 要移除的操作
|
||||
*/
|
||||
removeOperation(operation: CameraOperation) {
|
||||
const index = this.operation.indexOf(operation);
|
||||
if (index === -1) return;
|
||||
this.operation.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空变换操作列表
|
||||
*/
|
||||
clearOperation() {
|
||||
this.operation.splice(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个平移操作
|
||||
* @returns 添加的平移变换操作
|
||||
*/
|
||||
addTranslate(): ICameraTranslate {
|
||||
const item: ICameraTranslate = {
|
||||
type: 'translate',
|
||||
x: 0,
|
||||
y: 0,
|
||||
from: this
|
||||
};
|
||||
this.operation.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个旋转操作
|
||||
* @returns 添加的旋转变换操作
|
||||
*/
|
||||
addRotate(): ICameraRotate {
|
||||
const item: ICameraRotate = {
|
||||
type: 'rotate',
|
||||
angle: 0,
|
||||
from: this
|
||||
};
|
||||
this.operation.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个放缩操作
|
||||
* @returns 添加的放缩变换操作
|
||||
*/
|
||||
addScale(): ICameraScale {
|
||||
const item: ICameraScale = {
|
||||
type: 'scale',
|
||||
x: 1,
|
||||
y: 1,
|
||||
from: this
|
||||
};
|
||||
this.operation.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 施加动画
|
||||
* @param time 动画时长
|
||||
* @param update 每帧的更新函数
|
||||
*/
|
||||
applyAnimation(time: number, update: () => void) {
|
||||
const delegation = this.binded.delegateTicker(
|
||||
() => {
|
||||
update();
|
||||
this.needUpdate = true;
|
||||
},
|
||||
time,
|
||||
() => {
|
||||
update();
|
||||
this.needUpdate = true;
|
||||
this.animationIds.delete(delegation);
|
||||
}
|
||||
);
|
||||
this.animationIds.add(delegation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个平移操作实施动画
|
||||
* @param operation 平移操作
|
||||
* @param animate 动画实例
|
||||
* @param time 动画时长
|
||||
*/
|
||||
applyTranslateAnimation(
|
||||
operation: ICameraTranslate,
|
||||
animate: Animation,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(20);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.x = animate.x;
|
||||
operation.y = animate.y;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个旋转操作实施动画
|
||||
* @param operation 旋转操作
|
||||
* @param animate 动画实例
|
||||
* @param time 动画时长
|
||||
*/
|
||||
applyRotateAnimation(
|
||||
operation: ICameraRotate,
|
||||
animate: Animation,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(20);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.angle = animate.angle;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个缩放操作实施动画
|
||||
* @param operation 缩放操作
|
||||
* @param animate 动画实例
|
||||
* @param time 动画时长
|
||||
*/
|
||||
applyScaleAnimation(
|
||||
operation: ICameraScale,
|
||||
animate: Animation,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(20);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.x = animate.size;
|
||||
operation.y = animate.size;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个平移操作实施渐变,使用渐变的 x,y 值,即`transition.value.x`与`transition.value.y`
|
||||
* @param operation 平移操作
|
||||
* @param animate 渐变实例
|
||||
* @param time 渐变时长
|
||||
*/
|
||||
applyTranslateTransition(
|
||||
operation: ICameraTranslate,
|
||||
animate: Transition,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(21);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.x = animate.value.x;
|
||||
operation.y = animate.value.y;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个旋转操作实施渐变,使用渐变的 angle 值,即`transition.value.angle`
|
||||
* @param operation 旋转操作
|
||||
* @param animate 渐变实例
|
||||
* @param time 渐变时长
|
||||
*/
|
||||
applyRotateTransition(
|
||||
operation: ICameraRotate,
|
||||
animate: Transition,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(21);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.angle = animate.value.angle;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一个缩放操作实施渐变,使用渐变的 size 值,即`transition.value.size`
|
||||
* @param operation 缩放操作
|
||||
* @param animate 渐变实例
|
||||
* @param time 渐变时长
|
||||
*/
|
||||
applyScaleTransition(
|
||||
operation: ICameraScale,
|
||||
animate: Transition,
|
||||
time: number
|
||||
) {
|
||||
if (operation.from !== this) {
|
||||
logger.warn(21);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
operation.x = animate.value.size;
|
||||
operation.y = animate.value.size;
|
||||
};
|
||||
|
||||
this.applyAnimation(time, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有动画
|
||||
*/
|
||||
stopAllAnimates() {
|
||||
this.animationIds.forEach(v => this.binded.removeTicker(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* 摧毁这个摄像机,当绑定元素被摧毁之后摄像机会一并摧毁,如果这个摄像机不使用了,一定要将它摧毁
|
||||
*/
|
||||
destroy() {
|
||||
this.binded.removeTicker(this.delegation);
|
||||
this.animationIds.forEach(v => this.binded.removeTicker(v));
|
||||
Camera.cameraMap.delete(this.binded);
|
||||
this.emit('destroy');
|
||||
}
|
||||
}
|
||||
|
||||
interface CameraAnimationBase {
|
||||
type: string;
|
||||
time: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface TranslateAnimation extends CameraAnimationBase {
|
||||
type: 'translate';
|
||||
timing: TimingFn;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface TranslateAsAnimation extends CameraAnimationBase {
|
||||
type: 'translateAs';
|
||||
timing: TimingFn<2>;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface RotateAnimation extends CameraAnimationBase {
|
||||
type: 'rotate';
|
||||
timing: TimingFn;
|
||||
angle: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ScaleAnimation extends CameraAnimationBase {
|
||||
type: 'scale';
|
||||
timing: TimingFn;
|
||||
scale: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export type CameraAnimationData =
|
||||
| TranslateAnimation
|
||||
| TranslateAsAnimation
|
||||
| RotateAnimation
|
||||
| ScaleAnimation;
|
||||
|
||||
export interface CameraAnimationExecution {
|
||||
data: CameraAnimationData[];
|
||||
animation: Animation;
|
||||
}
|
||||
|
||||
interface CameraAnimationEvent {
|
||||
animate: [
|
||||
operation: CameraOperation,
|
||||
execution: CameraAnimationExecution,
|
||||
item: CameraAnimationData
|
||||
];
|
||||
}
|
||||
|
||||
export class CameraAnimation extends EventEmitter<CameraAnimationEvent> {
|
||||
camera: Camera;
|
||||
|
||||
/** 动画开始时刻 */
|
||||
private startTime: number = 0;
|
||||
/** 动画结束时刻 */
|
||||
private endTime: number = 0;
|
||||
/** 委托ticker的id */
|
||||
private delegation: number;
|
||||
/** 动画是否开始 */
|
||||
private started: boolean = false;
|
||||
|
||||
/** 每个摄像机操作的动画映射 */
|
||||
private animateMap: Map<CameraOperation, CameraAnimationExecution> =
|
||||
new Map();
|
||||
|
||||
constructor(camera: Camera) {
|
||||
super();
|
||||
|
||||
this.camera = camera;
|
||||
this.delegation = camera.binded.delegateTicker(this.tick);
|
||||
}
|
||||
|
||||
private tick = () => {
|
||||
if (!this.started) return;
|
||||
const now = Date.now();
|
||||
const time = now - this.startTime;
|
||||
if (now - this.startTime > this.endTime + 50) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
this.animateMap.forEach((exe, ope) => {
|
||||
const data = exe.data;
|
||||
if (data.length === 0) return;
|
||||
const item = data[0];
|
||||
if (item.start < time) {
|
||||
this.executeAnimate(exe, item);
|
||||
data.shift();
|
||||
this.emit('animate', ope, exe, item);
|
||||
}
|
||||
});
|
||||
this.camera.requestUpdate();
|
||||
};
|
||||
|
||||
private executeAnimate(
|
||||
execution: CameraAnimationExecution,
|
||||
animate: CameraAnimationData
|
||||
) {
|
||||
if (animate.type === 'translateAs') {
|
||||
const ani = this.ensureAnimate(execution);
|
||||
ani.time(animate.time).moveAs(animate.timing);
|
||||
} else if (animate.type === 'translate') {
|
||||
const ani = this.ensureAnimate(execution);
|
||||
const { x, y, time, timing } = animate;
|
||||
ani.mode(timing).time(time).move(x, y);
|
||||
} else if (animate.type === 'rotate') {
|
||||
const ani = this.ensureAnimate(execution);
|
||||
const { angle, time, timing } = animate;
|
||||
ani.mode(timing).time(time).rotate(angle);
|
||||
} else {
|
||||
const ani = this.ensureAnimate(execution);
|
||||
const { scale, time, timing } = animate;
|
||||
ani.mode(timing).time(time).scale(scale);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAnimate(execution: CameraAnimationExecution) {
|
||||
if (execution.animation) return execution.animation;
|
||||
const ani = new Animation();
|
||||
execution.animation = ani;
|
||||
return ani;
|
||||
}
|
||||
|
||||
private ensureOperation(operation: CameraOperation) {
|
||||
if (!this.animateMap.has(operation)) {
|
||||
const data: CameraAnimationExecution = {
|
||||
data: [],
|
||||
animation: new Animation()
|
||||
};
|
||||
this.animateMap.set(operation, data);
|
||||
return data;
|
||||
} else {
|
||||
return this.animateMap.get(operation)!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个平移动画
|
||||
* @param operation 摄像机操作对象
|
||||
* @param x 目标横坐标
|
||||
* @param y 目标纵坐标
|
||||
* @param time 动画时长
|
||||
* @param start 动画开始时间
|
||||
* @param timing 动画的缓动函数
|
||||
*/
|
||||
translate(
|
||||
operation: ICameraTranslate,
|
||||
x: number,
|
||||
y: number,
|
||||
time: number,
|
||||
start: number,
|
||||
timing: TimingFn
|
||||
) {
|
||||
const exe = this.ensureOperation(operation);
|
||||
const data: TranslateAnimation = {
|
||||
type: 'translate',
|
||||
timing,
|
||||
x: x * 32,
|
||||
y: y * 32,
|
||||
time,
|
||||
start
|
||||
};
|
||||
exe.data.push(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个旋转动画
|
||||
* @param operation 摄像机操作
|
||||
* @param angle 目标旋转弧度,单位弧度
|
||||
* @param time 动画时长
|
||||
* @param start 动画开始时间
|
||||
* @param timing 动画的缓动函数
|
||||
*/
|
||||
rotate(
|
||||
operation: ICameraRotate,
|
||||
angle: number,
|
||||
time: number,
|
||||
start: number,
|
||||
timing: TimingFn
|
||||
) {
|
||||
const exe = this.ensureOperation(operation);
|
||||
const data: RotateAnimation = {
|
||||
type: 'rotate',
|
||||
timing,
|
||||
angle,
|
||||
time,
|
||||
start
|
||||
};
|
||||
exe.data.push(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个缩放动画
|
||||
* @param operation 摄像机操作
|
||||
* @param scale 目标缩放倍率
|
||||
* @param time 动画时长
|
||||
* @param start 动画开始时间
|
||||
* @param timing 动画的缓动函数
|
||||
*/
|
||||
scale(
|
||||
operation: ICameraScale,
|
||||
scale: number,
|
||||
time: number,
|
||||
start: number,
|
||||
timing: TimingFn
|
||||
) {
|
||||
const exe = this.ensureOperation(operation);
|
||||
const data: ScaleAnimation = {
|
||||
type: 'scale',
|
||||
timing,
|
||||
scale,
|
||||
time,
|
||||
start
|
||||
};
|
||||
exe.data.push(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始执行这个动画
|
||||
*/
|
||||
start() {
|
||||
if (this.started) return;
|
||||
this.startTime = Date.now();
|
||||
this.started = true;
|
||||
let endTime = 0;
|
||||
this.animateMap.forEach((exe, ope) => {
|
||||
const data = exe.data;
|
||||
data.sort((a, b) => a.start - b.start);
|
||||
const end = data.at(-1);
|
||||
if (!end) return;
|
||||
const t = end.start + end.time;
|
||||
if (t > endTime) endTime = t;
|
||||
const cam = this.camera;
|
||||
|
||||
if (ope.type === 'translate') {
|
||||
cam.applyTranslateAnimation(ope, exe.animation, t + 100);
|
||||
} else if (ope.type === 'rotate') {
|
||||
cam.applyRotateAnimation(ope, exe.animation, t + 100);
|
||||
} else {
|
||||
cam.applyScaleAnimation(ope, exe.animation, t + 100);
|
||||
}
|
||||
});
|
||||
this.endTime = endTime + this.startTime;
|
||||
this.tick();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.camera.binded.removeTicker(this.delegation);
|
||||
this.camera.stopAllAnimates();
|
||||
this.animateMap.forEach(v => {
|
||||
v.animation.ticker.destroy();
|
||||
});
|
||||
this.animateMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import {
|
||||
ERenderItemEvent,
|
||||
RenderItem,
|
||||
MotaOffscreenCanvas2D,
|
||||
Transform,
|
||||
transformCanvas
|
||||
} from '@motajs/render-core';
|
||||
import { logger } from '@motajs/common';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { IDamageEnemy, IEnemyCollection, MapDamage } from '@motajs/types';
|
||||
import { BlockCacher, ICanvasCacheItem, CanvasCacheItem } from './block';
|
||||
import {
|
||||
ILayerGroupRenderExtends,
|
||||
LayerGroupFloorBinder,
|
||||
LayerGroup,
|
||||
Layer,
|
||||
calNeedRenderOf
|
||||
} from './layer';
|
||||
import { MAP_BLOCK_WIDTH, MAP_HEIGHT, MAP_WIDTH } from '../shared';
|
||||
|
||||
/**
|
||||
* 根据伤害大小获取颜色
|
||||
* @param damage 伤害大小
|
||||
*/
|
||||
export function getDamageColor(damage: number): string {
|
||||
if (typeof damage !== 'number') return '#f00';
|
||||
if (damage === 0) return '#2f2';
|
||||
if (damage < 0) return '#7f7';
|
||||
if (damage < core.status.hero.hp / 3) return '#fff';
|
||||
if (damage < (core.status.hero.hp * 2) / 3) return '#ff4';
|
||||
if (damage < core.status.hero.hp) return '#f93';
|
||||
return '#f22';
|
||||
}
|
||||
|
||||
interface EFloorDamageEvent {
|
||||
update: [floor: FloorIds];
|
||||
}
|
||||
|
||||
export class FloorDamageExtends
|
||||
extends EventEmitter<EFloorDamageEvent>
|
||||
implements ILayerGroupRenderExtends
|
||||
{
|
||||
id: string = 'floor-damage';
|
||||
|
||||
floorBinder!: LayerGroupFloorBinder;
|
||||
group!: LayerGroup;
|
||||
sprite!: Damage;
|
||||
|
||||
/**
|
||||
* 立刻刷新伤害渲染
|
||||
*/
|
||||
update(floor: FloorIds) {
|
||||
if (!this.sprite || !floor) return;
|
||||
const map = core.status.maps[floor];
|
||||
this.sprite.setMapSize(map.width, map.height);
|
||||
const { ensureFloorDamage } = Mota.require('@user/data-state');
|
||||
ensureFloorDamage(floor);
|
||||
const enemy = core.status.maps[floor].enemy;
|
||||
|
||||
this.sprite.updateCollection(enemy);
|
||||
this.emit('update', floor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建显伤层
|
||||
*/
|
||||
private create() {
|
||||
if (this.sprite) return;
|
||||
const sprite = new Damage();
|
||||
sprite.setZIndex(80);
|
||||
this.group.appendChild(sprite);
|
||||
this.sprite = sprite;
|
||||
}
|
||||
|
||||
private onUpdate = (floor: FloorIds) => {
|
||||
if (!this.floorBinder.bindThisFloor) {
|
||||
const { ensureFloorDamage } = Mota.require('@user/data-state');
|
||||
ensureFloorDamage(floor);
|
||||
core.status.maps[floor].enemy.calRealAttribute();
|
||||
}
|
||||
this.update(floor);
|
||||
};
|
||||
|
||||
// private onSetBlock = (x: number, y: number, floor: FloorIds) => {
|
||||
// this.sprite.enemy?.once('extract', () => {
|
||||
// if (floor !== this.sprite.enemy?.floorId) return;
|
||||
// this.sprite.updateBlocks();
|
||||
// });
|
||||
// if (!this.floorBinder.bindThisFloor) {
|
||||
// this.sprite.enemy?.extract();
|
||||
// }
|
||||
// };
|
||||
|
||||
/**
|
||||
* 进行楼层更新监听
|
||||
*/
|
||||
private listen() {
|
||||
this.floorBinder.on('update', this.onUpdate);
|
||||
// this.floorBinder.on('setBlock', this.onSetBlock);
|
||||
}
|
||||
|
||||
awake(group: LayerGroup): void {
|
||||
const ex = group.getExtends('floor-binder');
|
||||
if (ex instanceof LayerGroupFloorBinder) {
|
||||
this.floorBinder = ex;
|
||||
this.group = group;
|
||||
this.create();
|
||||
this.listen();
|
||||
} else {
|
||||
logger.warn(17);
|
||||
group.removeExtends('floor-damage');
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(_group: LayerGroup): void {
|
||||
this.floorBinder.off('update', this.onUpdate);
|
||||
// this.floorBinder.off('setBlock', this.onSetBlock);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DamageRenderable {
|
||||
x: number;
|
||||
y: number;
|
||||
align: CanvasTextAlign;
|
||||
baseline: CanvasTextBaseline;
|
||||
text: string;
|
||||
color: CanvasStyle;
|
||||
font?: string;
|
||||
stroke?: CanvasStyle;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export interface EDamageEvent extends ERenderItemEvent {
|
||||
setMapSize: [width: number, height: number];
|
||||
beforeDamageRender: [need: Set<number>, transform: Transform];
|
||||
updateBlocks: [blocks: Set<number>];
|
||||
dirtyUpdate: [block: number];
|
||||
}
|
||||
|
||||
export class Damage extends RenderItem<EDamageEvent> {
|
||||
mapWidth: number = 0;
|
||||
mapHeight: number = 0;
|
||||
|
||||
block: BlockCacher<ICanvasCacheItem>;
|
||||
/** 键表示分块索引,值表示在这个分块上的渲染信息(当然实际渲染位置可以不在这个分块上) */
|
||||
renderable: Map<number, Set<DamageRenderable>> = new Map();
|
||||
|
||||
/** 当前渲染怪物列表 */
|
||||
enemy?: IEnemyCollection;
|
||||
/** 每个分块中包含的怪物集合 */
|
||||
blockData: Map<number, Map<number, IDamageEnemy>> = new Map();
|
||||
/** 单元格大小 */
|
||||
cellSize: number = 32;
|
||||
|
||||
/** 默认伤害字体 */
|
||||
font: string = '300 9px Verdana';
|
||||
/** 默认描边样式,当伤害文字不存在描边属性时会使用此属性 */
|
||||
strokeStyle: CanvasStyle = '#000';
|
||||
/** 默认描边宽度 */
|
||||
strokeWidth: number = 2;
|
||||
|
||||
/** 要懒更新的所有分块 */
|
||||
private dirtyBlocks: Set<number> = new Set();
|
||||
|
||||
constructor() {
|
||||
super('absolute', false, true);
|
||||
|
||||
this.block = new BlockCacher(0, 0, MAP_BLOCK_WIDTH, 1);
|
||||
this.type = 'absolute';
|
||||
this.size(MAP_WIDTH, MAP_HEIGHT);
|
||||
this.setHD(true);
|
||||
this.setAntiAliasing(true);
|
||||
}
|
||||
|
||||
protected render(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
transform: Transform
|
||||
): void {
|
||||
this.renderDamage(canvas, transform);
|
||||
}
|
||||
|
||||
private onExtract = () => {
|
||||
if (this.enemy) this.updateCollection(this.enemy);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置地图大小,后面应紧跟更新怪物列表
|
||||
*/
|
||||
setMapSize(width: number, height: number) {
|
||||
this.mapWidth = width;
|
||||
this.mapHeight = height;
|
||||
this.enemy = void 0;
|
||||
this.blockData.clear();
|
||||
this.renderable.clear();
|
||||
this.block.size(width, height);
|
||||
|
||||
// 预留blockData
|
||||
const w = this.block.blockData.width;
|
||||
const h = this.block.blockData.height;
|
||||
const num = w * h;
|
||||
for (let i = 0; i < num; i++) {
|
||||
this.blockData.set(i, new Map());
|
||||
this.renderable.set(i, new Set());
|
||||
this.dirtyBlocks.add(i);
|
||||
}
|
||||
|
||||
this.emit('setMapSize', width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置每个图块的大小
|
||||
*/
|
||||
setCellSize(size: number) {
|
||||
this.cellSize = size;
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新怪物列表。更新后,{@link Damage.enemy} 会丢失原来的怪物列表引用,换为传入的列表引用
|
||||
* @param enemy 怪物列表
|
||||
*/
|
||||
updateCollection(enemy: IEnemyCollection) {
|
||||
if (this.enemy !== enemy) {
|
||||
this.enemy?.off('calculated', this.onExtract);
|
||||
enemy.on('calculated', this.onExtract);
|
||||
}
|
||||
this.enemy = enemy;
|
||||
this.blockData.forEach(v => v.clear());
|
||||
this.renderable.forEach(v => v.clear());
|
||||
this.block.clearAllCache();
|
||||
const w = this.block.blockData.width;
|
||||
const h = this.block.blockData.height;
|
||||
const num = w * h;
|
||||
for (let i = 0; i < num; i++) {
|
||||
this.dirtyBlocks.add(i);
|
||||
}
|
||||
|
||||
enemy.list.forEach(v => {
|
||||
if (isNil(v.x) || isNil(v.y)) return;
|
||||
const index = this.block.getIndexByLoc(v.x, v.y);
|
||||
this.blockData.get(index)?.set(v.y * this.mapWidth + v.x, v);
|
||||
});
|
||||
// this.updateBlocks();
|
||||
|
||||
this.update(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定矩形区域内的渲染信息
|
||||
* @param x 左上角横坐标
|
||||
* @param y 左上角纵坐标
|
||||
* @param width 宽度
|
||||
* @param height 高度
|
||||
*/
|
||||
updateRenderable(x: number, y: number, width: number, height: number) {
|
||||
this.updateBlocks(this.block.updateElementArea(x, y, width, height));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定分块
|
||||
* @param blocks 要更新的分块集合
|
||||
* @param map 是否更新地图伤害
|
||||
*/
|
||||
updateBlocks(blocks?: Set<number>) {
|
||||
if (blocks) {
|
||||
blocks.forEach(v => this.dirtyBlocks.add(v));
|
||||
this.emit('updateBlocks', blocks);
|
||||
} else {
|
||||
this.blockData.forEach((_v, i) => {
|
||||
this.dirtyBlocks.add(i);
|
||||
});
|
||||
this.emit('updateBlocks', new Set(this.blockData.keys()));
|
||||
}
|
||||
this.update(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定位置的怪物信息
|
||||
*/
|
||||
updateEnemyOn(x: number, y: number) {
|
||||
const enemy = this.enemy?.get(x, y);
|
||||
const block = this.block.getIndexByLoc(x, y);
|
||||
const data = this.blockData.get(block);
|
||||
const index = x + y * this.mapWidth;
|
||||
if (!data) return;
|
||||
if (!enemy) {
|
||||
data.delete(index);
|
||||
} else {
|
||||
data.set(index, enemy);
|
||||
}
|
||||
|
||||
this.update(this);
|
||||
|
||||
// 渲染懒更新,优化性能表现
|
||||
this.dirtyBlocks.add(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个分块
|
||||
* @param block 更新的分块
|
||||
* @param map 是否更新地图伤害
|
||||
*/
|
||||
private updateBlock(block: number, map: boolean = true) {
|
||||
const data = this.blockData.get(block);
|
||||
if (!data) return;
|
||||
|
||||
this.block.clearCache(block, 1);
|
||||
const renderable = this.renderable.get(block)!;
|
||||
|
||||
renderable.clear();
|
||||
data.forEach(v => this.extract(v, renderable));
|
||||
if (map) this.extractMapDamage(block, renderable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将怪物解析为renderable的伤害
|
||||
* @param enemy 怪物
|
||||
* @param block 怪物所属分块
|
||||
*/
|
||||
private extract(enemy: IDamageEnemy, block: Set<DamageRenderable>) {
|
||||
if (enemy.progress !== 4) return;
|
||||
const x = enemy.x!;
|
||||
const y = enemy.y!;
|
||||
const { damage } = enemy.calDamage();
|
||||
const cri = enemy.calCritical(1)[0]?.atkDelta ?? Infinity;
|
||||
|
||||
const dam1: DamageRenderable = {
|
||||
align: 'left',
|
||||
baseline: 'alphabetic',
|
||||
text: isFinite(damage) ? core.formatBigNumber(damage, true) : '???',
|
||||
color: getDamageColor(damage),
|
||||
x: x * this.cellSize + 1,
|
||||
y: y * this.cellSize + this.cellSize - 1
|
||||
};
|
||||
const dam2: DamageRenderable = {
|
||||
align: 'left',
|
||||
baseline: 'alphabetic',
|
||||
text: isFinite(cri) ? core.formatBigNumber(cri, true) : '?',
|
||||
color: '#fff',
|
||||
x: x * this.cellSize + 1,
|
||||
y: y * this.cellSize + this.cellSize - 11
|
||||
};
|
||||
block.add(dam1).add(dam2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析指定分块的地图伤害
|
||||
* @param block 分块索引
|
||||
*/
|
||||
private extractMapDamage(block: number, renderable: Set<DamageRenderable>) {
|
||||
if (!this.enemy) return;
|
||||
const damage = this.enemy.mapDamage;
|
||||
const [sx, sy, ex, ey] = this.block.getRectOfIndex(block);
|
||||
for (let x = sx; x < ex; x++) {
|
||||
for (let y = sy; y < ey; y++) {
|
||||
const loc = `${x},${y}`;
|
||||
const dam = damage[loc];
|
||||
if (!dam) continue;
|
||||
this.pushMapDamage(x, y, renderable, dam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析所有地图伤害
|
||||
*/
|
||||
private extractAllMapDamage() {
|
||||
// todo: 测试性能,这样真的会更快吗?或许能更好的优化?或者是根本不需要这个函数?
|
||||
if (!this.enemy) return;
|
||||
for (const [loc, enemy] of Object.entries(this.enemy.mapDamage)) {
|
||||
const [sx, sy] = loc.split(',');
|
||||
const x = Number(sx);
|
||||
const y = Number(sy);
|
||||
const block = this.renderable.get(this.block.getIndexByLoc(x, y))!;
|
||||
this.pushMapDamage(x, y, block, enemy);
|
||||
}
|
||||
}
|
||||
|
||||
private pushMapDamage(
|
||||
x: number,
|
||||
y: number,
|
||||
block: Set<DamageRenderable>,
|
||||
dam: MapDamage
|
||||
) {
|
||||
// todo: 这个应当可以自定义,通过地图伤害注册实现
|
||||
let text = '';
|
||||
const color = '#fa3';
|
||||
const font = '300 9px Verdana';
|
||||
if (dam.damage > 0) {
|
||||
text = core.formatBigNumber(dam.damage, true);
|
||||
} else if (dam.ambush) {
|
||||
text = `!`;
|
||||
} else if (dam.repulse) {
|
||||
text = '阻';
|
||||
}
|
||||
|
||||
const mapDam: DamageRenderable = {
|
||||
align: 'center',
|
||||
baseline: 'middle',
|
||||
text,
|
||||
color,
|
||||
font,
|
||||
x: x * this.cellSize + this.cellSize / 2,
|
||||
y: y * this.cellSize + this.cellSize / 2
|
||||
};
|
||||
block.add(mapDam);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算需要渲染哪些块
|
||||
*/
|
||||
calNeedRender(transform: Transform) {
|
||||
if (this.parent instanceof LayerGroup) {
|
||||
// 如果处于地图组中,每个地图的渲染区域应该是一样的,因此可以缓存优化
|
||||
return this.parent.cacheNeedRender(transform, this.block);
|
||||
} else if (this.parent instanceof Layer) {
|
||||
// 如果是地图的子元素,直接调用Layer的计算函数
|
||||
return this.parent.calNeedRender(transform);
|
||||
} else {
|
||||
return calNeedRenderOf(transform, this.cellSize, this.block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染伤害层
|
||||
* @param transform 变换矩阵
|
||||
*/
|
||||
renderDamage(canvas: MotaOffscreenCanvas2D, transform: Transform) {
|
||||
// console.time('damage');
|
||||
const { ctx } = canvas;
|
||||
ctx.save();
|
||||
transformCanvas(canvas, transform);
|
||||
|
||||
const render = this.calNeedRender(transform);
|
||||
const block = this.block;
|
||||
const cell = this.cellSize;
|
||||
const size = cell * block.blockSize;
|
||||
|
||||
this.emit('beforeDamageRender', render, transform);
|
||||
|
||||
render.forEach(v => {
|
||||
const [x, y] = block.getBlockXYByIndex(v);
|
||||
const bx = x * block.blockSize;
|
||||
const by = y * block.blockSize;
|
||||
const px = bx * cell;
|
||||
const py = by * cell;
|
||||
|
||||
// todo: 是否真的需要缓存
|
||||
// 检查有没有缓存
|
||||
const cache = block.cache.get(v);
|
||||
if (cache && cache.symbol === cache.canvas.symbol) {
|
||||
ctx.drawImage(cache.canvas.canvas, px, py, size, size);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dirtyBlocks.has(v)) {
|
||||
this.updateBlock(v, true);
|
||||
}
|
||||
this.emit('dirtyUpdate', v);
|
||||
|
||||
// 否则依次渲染并写入缓存
|
||||
const temp = block.cache.get(v)?.canvas ?? this.requireCanvas();
|
||||
temp.clear();
|
||||
temp.setHD(true);
|
||||
temp.setAntiAliasing(true);
|
||||
temp.size(size, size);
|
||||
const { ctx: ct } = temp;
|
||||
|
||||
ct.translate(-px, -py);
|
||||
ct.lineJoin = 'round';
|
||||
ct.lineCap = 'round';
|
||||
|
||||
const render = this.renderable.get(v);
|
||||
|
||||
render?.forEach(v => {
|
||||
if (!v) return;
|
||||
ct.fillStyle = v.color;
|
||||
ct.textAlign = v.align;
|
||||
ct.textBaseline = v.baseline;
|
||||
ct.font = v.font ?? this.font;
|
||||
ct.strokeStyle = v.stroke ?? this.strokeStyle;
|
||||
ct.lineWidth = v.strokeWidth ?? this.strokeWidth;
|
||||
|
||||
ct.strokeText(v.text, v.x, v.y);
|
||||
ct.fillText(v.text, v.x, v.y);
|
||||
});
|
||||
|
||||
ctx.drawImage(temp.canvas, px, py, size, size);
|
||||
block.cache.set(v, new CanvasCacheItem(temp, temp.symbol, this));
|
||||
});
|
||||
ctx.restore();
|
||||
// console.timeEnd('damage');
|
||||
}
|
||||
|
||||
protected handleProps(
|
||||
key: string,
|
||||
_prevValue: any,
|
||||
nextValue: any
|
||||
): boolean {
|
||||
switch (key) {
|
||||
case 'mapWidth':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.setMapSize(nextValue, this.mapHeight);
|
||||
return true;
|
||||
case 'mapHeight':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.setMapSize(this.mapWidth, nextValue);
|
||||
return true;
|
||||
case 'cellSize':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.setCellSize(nextValue);
|
||||
return true;
|
||||
case 'enemy':
|
||||
if (!this.assertType(nextValue, 'object', key)) return false;
|
||||
this.updateCollection(nextValue);
|
||||
return true;
|
||||
case 'font':
|
||||
if (!this.assertType(nextValue, 'string', key)) return false;
|
||||
this.font = nextValue;
|
||||
this.update();
|
||||
return true;
|
||||
case 'strokeStyle':
|
||||
this.strokeStyle = nextValue;
|
||||
this.update();
|
||||
return true;
|
||||
case 'strokeWidth':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.strokeWidth = nextValue;
|
||||
this.update();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
super.destroy();
|
||||
this.block.destroy();
|
||||
this.enemy?.off('extract', this.onExtract);
|
||||
}
|
||||
}
|
||||
|
||||
// const adapter = new RenderAdapter<Damage>('damage');
|
||||
@@ -0,0 +1,54 @@
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { RenderItem } from '@motajs/render-core';
|
||||
|
||||
export interface IAnimateFrame {
|
||||
updateFrameAnimate(frame: number, time: number): void;
|
||||
}
|
||||
|
||||
interface RenderEvent {
|
||||
animateFrame: [frame: number, time: number];
|
||||
}
|
||||
|
||||
class RenderEmits extends EventEmitter<RenderEvent> {
|
||||
private framer: Set<IAnimateFrame> = new Set();
|
||||
|
||||
/**
|
||||
* 添加一个可更新帧动画的对象
|
||||
*/
|
||||
addFramer(framer: IAnimateFrame) {
|
||||
this.framer.add(framer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个可更新帧动画的对象
|
||||
*/
|
||||
removeFramer(framer: IAnimateFrame) {
|
||||
this.framer.delete(framer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有帧动画
|
||||
* @param frame 帧数
|
||||
* @param time 帧动画时刻
|
||||
*/
|
||||
emitAnimateFrame(frame: number, time: number) {
|
||||
this.framer.forEach(v => v.updateFrameAnimate(frame, time));
|
||||
this.emit('animateFrame', frame, time);
|
||||
}
|
||||
}
|
||||
|
||||
export const renderEmits = new RenderEmits();
|
||||
|
||||
export function createFrame() {
|
||||
Mota.require('@user/data-base').hook.once('reset', () => {
|
||||
let lastTime = 0;
|
||||
RenderItem.ticker.add(time => {
|
||||
if (!core.isPlaying()) return;
|
||||
if (time - lastTime > core.values.animateSpeed) {
|
||||
RenderItem.animatedFrame++;
|
||||
lastTime = time;
|
||||
renderEmits.emitAnimateFrame(RenderItem.animatedFrame, time);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { RenderAdapter, SizedCanvasImageSource } from '@motajs/render-core';
|
||||
import { logger } from '@motajs/common';
|
||||
import { ILayerRenderExtends, Layer, LayerMovingRenderable } from './layer';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import { texture } from './cache';
|
||||
import { TimingFn } from 'mutate-animate';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
// type HeroMovingStatus = 'stop' | 'moving' | 'moving-as';
|
||||
// export const enum HeroMovingStatus {}
|
||||
|
||||
interface HeroRenderEvent {
|
||||
stepEnd: [];
|
||||
moveTick: [x: number, y: number];
|
||||
append: [renderable: LayerMovingRenderable[]];
|
||||
}
|
||||
|
||||
export class HeroRenderer
|
||||
extends EventEmitter<HeroRenderEvent>
|
||||
implements ILayerRenderExtends
|
||||
{
|
||||
id: string = 'floor-hero';
|
||||
|
||||
/** 勇士的图片资源 */
|
||||
image?: SizedCanvasImageSource;
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
|
||||
/** 勇士的渲染信息 */
|
||||
renderable?: LayerMovingRenderable;
|
||||
layer!: Layer;
|
||||
|
||||
/** 当前移动帧数 */
|
||||
movingFrame: number = 0;
|
||||
/** 是否正在移动 */
|
||||
moving: boolean = false;
|
||||
/** 是否正在播放动画,与移动分开以实现原地踏步 */
|
||||
animate: boolean = false;
|
||||
|
||||
/** 勇士移动速度 */
|
||||
speed: number = 100;
|
||||
/** 勇士移动的真正速度,经过录像修正 */
|
||||
realSpeed: number = 100;
|
||||
|
||||
/** 当前的移动方向 */
|
||||
moveDir: Dir2 = 'down';
|
||||
/** 当前移动的勇士显示方向 */
|
||||
showDir: Dir = 'down';
|
||||
/** 帧动画是否反向播放,例如后退时就应该设为true */
|
||||
animateReverse: boolean = false;
|
||||
|
||||
/** 勇士移动定时器id */
|
||||
private moveId: number = -1;
|
||||
/** 上一次帧数切换的时间 */
|
||||
private lastFrameTime: number = 0;
|
||||
/** 上一步走到格子上的时间 */
|
||||
private lastStepTime: number = 0;
|
||||
/** 执行当前步移动的Promise */
|
||||
private moveDetached?: Promise<void>;
|
||||
/** endMove的Promise */
|
||||
private moveEnding?: Promise<void>;
|
||||
/**
|
||||
* 这一步的移动方向,与{@link moveDir}不同的是,在这一步走完之前,它都不会变,
|
||||
* 当这一步走完之后,才会将其设置为{@link moveDir}的值
|
||||
*/
|
||||
stepDir: Dir2 = 'down';
|
||||
/** 每步的格子增量 */
|
||||
private stepDelta: Loc = { x: 0, y: 1 };
|
||||
|
||||
/**
|
||||
* 设置勇士所用的图片资源
|
||||
* @param image 图片资源
|
||||
*/
|
||||
setImage(image: SizedCanvasImageSource) {
|
||||
this.image = image;
|
||||
this.split();
|
||||
this.resetRenderable(true);
|
||||
this.layer.requestUpdateMoving();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置移动的移动速度
|
||||
* @param speed 移动速度
|
||||
*/
|
||||
setMoveSpeed(speed: number) {
|
||||
this.speed = speed;
|
||||
this.fixMoveSpeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割勇士图像,生成renderable信息
|
||||
*/
|
||||
split() {
|
||||
this.cellWidth = this.image!.width / 4;
|
||||
this.cellHeight = this.image!.height / 4;
|
||||
this.generateRenderable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成渲染信息
|
||||
*/
|
||||
generateRenderable() {
|
||||
if (!this.image) return;
|
||||
this.renderable = {
|
||||
image: this.image,
|
||||
frame: 4,
|
||||
x: core.status.hero.loc.x,
|
||||
y: core.status.hero.loc.y,
|
||||
zIndex: core.status.hero.loc.y,
|
||||
autotile: false,
|
||||
bigImage: true,
|
||||
render: this.getRenderFromDir(this.showDir),
|
||||
animate: 0,
|
||||
alpha: 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据方向获取勇士的裁切信息
|
||||
* @param dir 方向
|
||||
*/
|
||||
getRenderFromDir(dir: Dir): [number, number, number, number][] {
|
||||
if (!this.cellWidth || !this.cellHeight) return [];
|
||||
const index = texture.characterDirection[dir];
|
||||
const y = index * this.cellHeight;
|
||||
const res: [number, number, number, number][] = [0, 1, 2, 3].map(v => {
|
||||
return [v * this.cellWidth!, y, this.cellWidth!, this.cellHeight!];
|
||||
});
|
||||
if (this.animateReverse) return res.reverse();
|
||||
else return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始勇士帧动画
|
||||
*/
|
||||
startAnimate() {
|
||||
this.animate = true;
|
||||
this.lastFrameTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束勇士帧动画
|
||||
*/
|
||||
endAnimate() {
|
||||
this.animate = false;
|
||||
this.resetRenderable(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置帧动画是否反向播放
|
||||
* @param reverse 帧动画是否反向播放
|
||||
*/
|
||||
setAnimateReversed(reverse: boolean) {
|
||||
this.animateReverse = reverse;
|
||||
this.resetRenderable(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置勇士的动画显示朝向
|
||||
* @param dir 显示朝向
|
||||
*/
|
||||
setAnimateDir(dir: Dir) {
|
||||
if (dir !== this.showDir) {
|
||||
this.showDir = dir;
|
||||
this.resetRenderable(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置renderable状态,恢复至第一帧状态
|
||||
* @param getInfo 是否重新获取渲染信息
|
||||
*/
|
||||
resetRenderable(getInfo: boolean) {
|
||||
this.movingFrame = 0;
|
||||
|
||||
if (this.renderable) {
|
||||
this.renderable.animate = 0;
|
||||
if (getInfo) {
|
||||
this.renderable.render = this.getRenderFromDir(this.showDir);
|
||||
}
|
||||
}
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 勇士帧动画定时器
|
||||
*/
|
||||
private animateTick(time: number) {
|
||||
if (!this.animate) return;
|
||||
if (time - this.lastFrameTime > this.speed) {
|
||||
this.lastFrameTime = time;
|
||||
this.movingFrame++;
|
||||
this.movingFrame %= 4;
|
||||
if (this.renderable) this.renderable.animate = this.movingFrame;
|
||||
}
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 勇士移动定时器
|
||||
*/
|
||||
private moveTick(time: number) {
|
||||
if (!this.renderable) return;
|
||||
|
||||
if (this.moving) {
|
||||
const progress = (time - this.lastStepTime) / this.realSpeed;
|
||||
|
||||
const { x: dx, y: dy } = this.stepDelta;
|
||||
const { x, y } = core.status.hero.loc;
|
||||
if (progress >= 1) {
|
||||
this.renderable.x = x + dx;
|
||||
this.renderable.y = y + dy;
|
||||
this.fixMoveSpeed();
|
||||
this.emit('stepEnd');
|
||||
} else {
|
||||
const rx = dx * progress + x;
|
||||
const ry = dy * progress + y;
|
||||
this.renderable.x = rx;
|
||||
this.renderable.y = ry;
|
||||
}
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
this.emit('moveTick', this.renderable.x, this.renderable.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进行下一步的移动准备,设置移动信息
|
||||
*/
|
||||
private step() {
|
||||
this.stepDir = this.moveDir;
|
||||
this.lastStepTime = Date.now();
|
||||
this.stepDelta = core.utils.scan2[this.stepDir];
|
||||
this.turn(this.stepDir);
|
||||
}
|
||||
|
||||
private fixMoveSpeed() {
|
||||
if (!core.isReplaying()) {
|
||||
this.realSpeed = this.speed;
|
||||
} else {
|
||||
const replay = core.status.replay.speed;
|
||||
this.realSpeed = replay === 24 ? 1 : this.speed / replay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备开始移动
|
||||
*/
|
||||
readyMove() {
|
||||
this.moving = true;
|
||||
this.fixMoveSpeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动勇士
|
||||
*/
|
||||
move(dir: Dir2): Promise<void> {
|
||||
if (!this.moving) {
|
||||
logger.error(12);
|
||||
return Promise.reject(
|
||||
'Cannot moving hero while hero not in moving!'
|
||||
);
|
||||
}
|
||||
|
||||
this.moveDir = dir;
|
||||
if (this.moveDetached) {
|
||||
return this.moveDetached;
|
||||
} else {
|
||||
this.step();
|
||||
this.moveDetached = new Promise(res => {
|
||||
this.once('stepEnd', () => {
|
||||
this.moveDetached = void 0;
|
||||
res();
|
||||
});
|
||||
});
|
||||
return this.moveDetached;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束勇士的移动过程
|
||||
*/
|
||||
endMove(): Promise<void> {
|
||||
if (!this.moving) return Promise.resolve();
|
||||
if (this.moveEnding) return this.moveEnding;
|
||||
else {
|
||||
const promise = new Promise<void>(resolve => {
|
||||
this.once('stepEnd', () => {
|
||||
this.moveEnding = void 0;
|
||||
this.moving = false;
|
||||
const { x, y } = core.status.hero.loc;
|
||||
this.setHeroLoc(x, y);
|
||||
this.render();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return (this.moveEnding = promise);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 勇士转向,不填表示顺时针转一个方向
|
||||
* @param dir 移动方向
|
||||
*/
|
||||
turn(dir?: Dir2): void {
|
||||
if (!dir) {
|
||||
const index = texture.characterTurn2.indexOf(this.stepDir);
|
||||
if (index === -1) {
|
||||
const length = texture.characterTurn.length;
|
||||
const index = texture.characterTurn.indexOf(
|
||||
this.stepDir as Dir
|
||||
);
|
||||
const next = texture.characterTurn[index % length];
|
||||
return this.turn(next);
|
||||
} else {
|
||||
return this.turn(texture.characterTurn[index]);
|
||||
}
|
||||
}
|
||||
this.moveDir = dir;
|
||||
this.stepDir = dir;
|
||||
|
||||
if (!this.renderable) return;
|
||||
this.renderable.render = this.getRenderFromDir(this.showDir);
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置勇士的坐标
|
||||
* @param x 横坐标
|
||||
* @param y 纵坐标
|
||||
*/
|
||||
setHeroLoc(x?: number, y?: number) {
|
||||
if (!this.renderable) return;
|
||||
if (!isNil(x)) {
|
||||
this.renderable.x = x;
|
||||
}
|
||||
if (!isNil(y)) {
|
||||
this.renderable.y = y;
|
||||
}
|
||||
this.emit('moveTick', this.renderable.x, this.renderable.y);
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按照指定函数移动勇士
|
||||
* @param x 目标横坐标
|
||||
* @param y 目标纵坐标
|
||||
* @param time 移动时间
|
||||
* @param fn 移动函数,传入一个完成度(范围0-1),返回一个三元素数组,表示横纵格子坐标,可以是小数。
|
||||
* 第三个元素表示图块纵深,一般图块的纵深就是其纵坐标,当地图上有大怪物时,此举可以辅助渲染,
|
||||
* 否则可能会导致移动过程中与大怪物的层级关系不正确,比如全在大怪物身后。注意不建议频繁改动这个值,
|
||||
* 因为此举会导致层级的重新排序,降低渲染性能。
|
||||
*/
|
||||
moveAs(x: number, y: number, time: number, fn: TimingFn<3>): Promise<void> {
|
||||
if (!this.moving) return Promise.resolve();
|
||||
if (!this.renderable) return Promise.resolve();
|
||||
const nowZIndex = fn(0)[2];
|
||||
const startTime = Date.now();
|
||||
return new Promise(res => {
|
||||
this.layer.delegateTicker(
|
||||
() => {
|
||||
if (!this.renderable) return;
|
||||
const now = Date.now();
|
||||
const progress = (now - startTime) / time;
|
||||
const [nx, ny, nz] = fn(progress);
|
||||
this.renderable.x = nx;
|
||||
this.renderable.y = ny;
|
||||
this.renderable.zIndex = nz;
|
||||
if (nz !== nowZIndex) {
|
||||
this.layer.sortMovingRenderable();
|
||||
}
|
||||
this.emit('moveTick', this.renderable.x, this.renderable.y);
|
||||
this.layer.update(this.layer);
|
||||
},
|
||||
time,
|
||||
() => {
|
||||
this.moving = false;
|
||||
if (!this.renderable) return res();
|
||||
this.renderable.x = x;
|
||||
this.renderable.y = y;
|
||||
this.emit('moveTick', this.renderable.x, this.renderable.y);
|
||||
this.layer.update(this.layer);
|
||||
res();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染勇士
|
||||
*/
|
||||
render() {
|
||||
if (!this.renderable) return;
|
||||
if (!this.animate) {
|
||||
this.renderable.animate = 0;
|
||||
} else {
|
||||
this.renderable.animate = this.movingFrame;
|
||||
}
|
||||
this.layer.update(this.layer);
|
||||
}
|
||||
|
||||
awake(layer: Layer): void {
|
||||
this.layer = layer;
|
||||
adapter.add(this);
|
||||
this.moveId = layer.delegateTicker(() => {
|
||||
const time = Date.now();
|
||||
this.animateTick(time);
|
||||
this.moveTick(time);
|
||||
});
|
||||
if (core.status.hero) {
|
||||
const image = core.status.hero.image;
|
||||
this.setImage(core.material.images.images[image]);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(layer: Layer): void {
|
||||
adapter.remove(this);
|
||||
layer.removeTicker(this.moveId);
|
||||
}
|
||||
|
||||
onMovingUpdate(_layer: Layer, renderable: LayerMovingRenderable[]): void {
|
||||
if (this.renderable) {
|
||||
renderable.push(this.renderable);
|
||||
this.emit('append', renderable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = new RenderAdapter<HeroRenderer>('hero-adapter');
|
||||
adapter.receive('readyMove', item => {
|
||||
item.readyMove();
|
||||
return Promise.resolve();
|
||||
});
|
||||
adapter.receive('move', (item, dir: Dir) => {
|
||||
return item.move(dir);
|
||||
});
|
||||
adapter.receive('endMove', item => {
|
||||
return item.endMove();
|
||||
});
|
||||
adapter.receive(
|
||||
'moveAs',
|
||||
(item, x: number, y: number, time: number, fn: TimingFn<3>) => {
|
||||
return item.moveAs(x, y, time, fn);
|
||||
}
|
||||
);
|
||||
adapter.receive('setHeroLoc', (item, x?: number, y?: number) => {
|
||||
item.setHeroLoc(x, y);
|
||||
return Promise.resolve();
|
||||
});
|
||||
adapter.receive('turn', (item, dir: Dir2) => {
|
||||
item.turn(dir);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// 同步适配函数,这些函数用于同步设置信息等
|
||||
adapter.receiveSync('setImage', (item, image: SizedCanvasImageSource) => {
|
||||
item.setImage(image);
|
||||
});
|
||||
adapter.receiveSync('setMoveSpeed', (item, speed: number) => {
|
||||
item.setMoveSpeed(speed);
|
||||
});
|
||||
adapter.receiveSync('setAnimateReversed', (item, reverse: boolean) => {
|
||||
item.setAnimateReversed(reverse);
|
||||
});
|
||||
adapter.receiveSync('startAnimate', item => {
|
||||
item.startAnimate();
|
||||
});
|
||||
adapter.receiveSync('endAnimate', item => {
|
||||
item.endAnimate();
|
||||
});
|
||||
adapter.receiveSync('setAnimateDir', (item, dir: Dir) => {
|
||||
item.setAnimateDir(dir);
|
||||
});
|
||||
|
||||
// 不分同步fallback,用于适配现在的样板,之后会删除
|
||||
adapter.receiveSync('setHeroLoc', (item, x?: number, y?: number) => {
|
||||
item.setHeroLoc(x, y);
|
||||
});
|
||||
adapter.receiveSync('turn', (item, dir: Dir2) => {
|
||||
item.turn(dir);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { standardElementNoCache, tagMap } from '@motajs/render-vue';
|
||||
import { createCache } from './cache';
|
||||
import { createFrame } from './frame';
|
||||
import { createLayer, Layer, LayerGroup } from './layer';
|
||||
import { createViewport } from './viewport';
|
||||
import { Icon, Winskin } from './misc';
|
||||
import { Animate } from './animate';
|
||||
import { createItemDetail } from './itemDetail';
|
||||
|
||||
export function createElements() {
|
||||
createCache();
|
||||
createFrame();
|
||||
createLayer();
|
||||
createViewport();
|
||||
createItemDetail();
|
||||
|
||||
// ----- 注册标签
|
||||
|
||||
tagMap.register('winskin', (_0, _1, props) => {
|
||||
if (!props)
|
||||
return new Winskin(core.material.images.images['winskin.png']);
|
||||
else {
|
||||
const {
|
||||
image = core.material.images.images['winskin.png'],
|
||||
type = 'static'
|
||||
} = props;
|
||||
return new Winskin(image, type);
|
||||
}
|
||||
});
|
||||
tagMap.register('layer', (_0, _1, props) => {
|
||||
if (!props) return new Layer();
|
||||
else {
|
||||
const { ex } = props;
|
||||
const l = new Layer();
|
||||
|
||||
if (ex) {
|
||||
(ex as any[]).forEach(v => {
|
||||
l.extends(v);
|
||||
});
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
});
|
||||
tagMap.register('layer-group', (_0, _1, props) => {
|
||||
if (!props) return new LayerGroup();
|
||||
else {
|
||||
const { ex, layers } = props;
|
||||
const l = new LayerGroup();
|
||||
|
||||
if (ex) {
|
||||
(ex as any[]).forEach(v => {
|
||||
l.extends(v);
|
||||
});
|
||||
}
|
||||
if (layers) {
|
||||
(layers as any[]).forEach(v => {
|
||||
l.addLayer(v);
|
||||
});
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
});
|
||||
tagMap.register('animation', (_0, _1, _props) => {
|
||||
return new Animate();
|
||||
});
|
||||
tagMap.register('icon', standardElementNoCache(Icon));
|
||||
}
|
||||
|
||||
export * from './animate';
|
||||
export * from './block';
|
||||
export * from './cache';
|
||||
export * from './camera';
|
||||
export * from './damage';
|
||||
export * from './frame';
|
||||
export * from './hero';
|
||||
export * from './itemDetail';
|
||||
export * from './layer';
|
||||
export * from './misc';
|
||||
export * from './props';
|
||||
export * from './utils';
|
||||
export * from './viewport';
|
||||
@@ -0,0 +1,270 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import { mainSetting } from '@motajs/legacy-ui';
|
||||
import { hook } from '@user/data-base';
|
||||
import { ItemState } from '@user/data-state';
|
||||
import { Damage, DamageRenderable, FloorDamageExtends } from './damage';
|
||||
import {
|
||||
ILayerGroupRenderExtends,
|
||||
LayerGroup,
|
||||
LayerGroupFloorBinder
|
||||
} from './layer';
|
||||
|
||||
interface ItemDetailData {
|
||||
x: number;
|
||||
y: number;
|
||||
diff: Record<string | symbol, number | undefined>;
|
||||
}
|
||||
|
||||
interface ItemData {
|
||||
id: AllIdsOf<'items'>;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function createItemDetail() {
|
||||
hook.on('setBlock', (x, y, _floorId, block) => {
|
||||
FloorItemDetail.listened.forEach(v => {
|
||||
v.setBlock(block, x, y);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class FloorItemDetail implements ILayerGroupRenderExtends {
|
||||
id: string = 'item-detail';
|
||||
|
||||
group!: LayerGroup;
|
||||
floorBinder!: LayerGroupFloorBinder;
|
||||
damage!: FloorDamageExtends;
|
||||
sprite!: Damage;
|
||||
|
||||
/** 每个分块中包含的物品信息 */
|
||||
blockData: Map<number, Map<number, ItemData>> = new Map();
|
||||
|
||||
/** 需要更新的分块 */
|
||||
private dirtyBlock: Set<number> = new Set();
|
||||
/** 道具详细信息 */
|
||||
private detailData: Map<number, Map<number, ItemDetailData>> = new Map();
|
||||
|
||||
static detailColor: Record<string, CanvasStyle> = {
|
||||
atk: '#FF7A7A',
|
||||
atkper: '#FF7A7A',
|
||||
def: '#00E6F1',
|
||||
defper: '#00E6F1',
|
||||
mdef: '#6EFF83',
|
||||
mdefper: '#6EFF83',
|
||||
hp: '#A4FF00',
|
||||
hpmax: '#F9FF00',
|
||||
hpmaxper: '#F9FF00',
|
||||
mana: '#c66',
|
||||
manaper: '#c66'
|
||||
};
|
||||
|
||||
static listened: Set<FloorItemDetail> = new Set();
|
||||
|
||||
private onBeforeDamageRender = (block: number) => {
|
||||
if (!mainSetting.getValue('screen.itemDetail')) return;
|
||||
if (this.dirtyBlock.has(block)) {
|
||||
this.sprite.block.clearCache(block, 1);
|
||||
}
|
||||
this.render(block);
|
||||
};
|
||||
|
||||
private onUpdateMapSize = (width: number, height: number) => {
|
||||
this.updateMapSize(width, height);
|
||||
};
|
||||
|
||||
private onUpdate = () => {
|
||||
this.updateItems();
|
||||
};
|
||||
|
||||
private onUpdateBlocks = (blocks: Set<number>) => {
|
||||
blocks.forEach(v => {
|
||||
this.dirtyBlock.add(v);
|
||||
});
|
||||
};
|
||||
|
||||
private listen() {
|
||||
this.sprite.on('dirtyUpdate', this.onBeforeDamageRender);
|
||||
this.sprite.on('setMapSize', this.onUpdateMapSize);
|
||||
this.sprite.on('updateBlocks', this.onUpdateBlocks);
|
||||
this.damage.on('update', this.onUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新地图大小
|
||||
*/
|
||||
updateMapSize(width: number, height: number) {
|
||||
this.blockData.clear();
|
||||
|
||||
// 预留blockData
|
||||
this.sprite.block.size(width, height);
|
||||
const data = this.sprite.block.blockData;
|
||||
const num = data.width * data.height;
|
||||
for (let i = 0; i <= num; i++) {
|
||||
this.blockData.set(i, new Map());
|
||||
this.detailData.set(i, new Map());
|
||||
this.dirtyBlock.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新全地图的物品,并进行分块存储
|
||||
*/
|
||||
updateItems() {
|
||||
const floor = this.floorBinder.getFloor();
|
||||
if (!floor) return;
|
||||
core.extractBlocks(floor);
|
||||
|
||||
core.status.maps[floor].blocks.forEach(v => {
|
||||
if (v.event.cls !== 'items' || v.disable) return;
|
||||
const id = v.event.id as AllIdsOf<'items'>;
|
||||
const item = core.material.items[id];
|
||||
if (item.cls === 'constants' || item.cls === 'tools') return;
|
||||
const x = v.x;
|
||||
const y = v.y;
|
||||
const block = this.sprite.block.getIndexByLoc(x, y);
|
||||
const index = x + y * this.sprite.mapWidth;
|
||||
const blockData = this.blockData.get(block);
|
||||
blockData?.set(index, { x, y, id });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置图块
|
||||
* @param block 图块数字
|
||||
* @param x 横坐标
|
||||
* @param y 纵坐标
|
||||
*/
|
||||
setBlock(block: AllNumbers, x: number, y: number) {
|
||||
const map = maps_90f36752_8815_4be8_b32b_d7fad1d0542e;
|
||||
const index = this.sprite.block.getIndexByLoc(x, y);
|
||||
const itemIndex = x + y * this.sprite.mapWidth;
|
||||
const blockData = this.blockData.get(index);
|
||||
this.dirtyBlock.add(index);
|
||||
if (block === 0) {
|
||||
blockData?.delete(itemIndex);
|
||||
return;
|
||||
}
|
||||
const cls = map[block].cls;
|
||||
if (cls !== 'items') {
|
||||
blockData?.delete(itemIndex);
|
||||
return;
|
||||
}
|
||||
const id = map[block].id;
|
||||
blockData?.set(itemIndex, { x, y, id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定分块中的物品信息
|
||||
* @param block 要计算的分块
|
||||
*/
|
||||
calAllItems(block: Set<number>) {
|
||||
const enable = mainSetting.getValue('screen.itemDetail');
|
||||
if (!core.status.thisMap || !enable) return;
|
||||
if (this.dirtyBlock.size === 0 || block.size === 0) return;
|
||||
|
||||
let diff: Record<string | symbol, number | undefined> = {};
|
||||
const before = core.status.hero;
|
||||
const hero = structuredClone(core.status.hero);
|
||||
const handler: ProxyHandler<any> = {
|
||||
set(target, key, v) {
|
||||
diff[key] = v - (target[key] || 0);
|
||||
if (!diff[key]) diff[key] = void 0;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
core.status.hero = new Proxy(hero, handler);
|
||||
|
||||
core.setFlag('__statistics__', true);
|
||||
this.dirtyBlock.forEach(v => {
|
||||
const data = this.blockData.get(v);
|
||||
const detail = this.detailData.get(v);
|
||||
detail?.clear();
|
||||
if (!data) return;
|
||||
data.forEach(v => {
|
||||
const { id, x, y } = v;
|
||||
const index = x + y * this.sprite.mapWidth;
|
||||
diff = {};
|
||||
|
||||
const item = core.material.items[id];
|
||||
if (item.cls === 'equips') {
|
||||
// 装备也显示
|
||||
const diff: Record<string, any> = {
|
||||
...(item.equip.value ?? {})
|
||||
};
|
||||
const per = item.equip.percentage ?? {};
|
||||
for (const name of Object.keys(per)) {
|
||||
const n = name as SelectKey<HeroStatus, number>;
|
||||
diff[name + 'per'] = per[n].toString() + '%';
|
||||
}
|
||||
detail?.set(index, { x, y, diff });
|
||||
return;
|
||||
}
|
||||
|
||||
ItemState.item(id)?.itemEffectFn?.();
|
||||
detail?.set(index, { x, y, diff });
|
||||
});
|
||||
});
|
||||
core.status.hero = before;
|
||||
window.hero = before;
|
||||
window.flags = before.flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并渲染指定格子里面的物品
|
||||
* @param block 需要渲染的格子
|
||||
*/
|
||||
render(block: number) {
|
||||
if (this.dirtyBlock.has(block)) {
|
||||
this.calAllItems(new Set([block]));
|
||||
}
|
||||
const data = this.detailData;
|
||||
this.dirtyBlock.delete(block);
|
||||
const info = data.get(block);
|
||||
if (!info) return;
|
||||
info.forEach(({ x, y, diff }) => {
|
||||
let n = 0;
|
||||
for (const [key, value] of Object.entries(diff)) {
|
||||
if (!value) continue;
|
||||
const color = FloorItemDetail.detailColor[key] ?? '#fff';
|
||||
const text = core.formatBigNumber(value, 4);
|
||||
const renderable: DamageRenderable = {
|
||||
x: x * this.sprite.cellSize + 2,
|
||||
y: y * this.sprite.cellSize + 31 - n * 10,
|
||||
text,
|
||||
color,
|
||||
align: 'left',
|
||||
baseline: 'alphabetic'
|
||||
};
|
||||
this.sprite.renderable.get(block)?.add(renderable);
|
||||
n++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
awake(group: LayerGroup): void {
|
||||
this.group = group;
|
||||
|
||||
const binder = group.getExtends('floor-binder');
|
||||
const damage = group.getExtends('floor-damage');
|
||||
if (
|
||||
binder instanceof LayerGroupFloorBinder &&
|
||||
damage instanceof FloorDamageExtends
|
||||
) {
|
||||
this.floorBinder = binder;
|
||||
this.damage = damage;
|
||||
this.sprite = damage.sprite;
|
||||
this.listen();
|
||||
FloorItemDetail.listened.add(this);
|
||||
} else {
|
||||
logger.warn(1001);
|
||||
group.removeExtends('item-detail');
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(_group: LayerGroup): void {
|
||||
this.sprite.off('beforeDamageRender', this.onBeforeDamageRender);
|
||||
this.sprite.off('setMapSize', this.onUpdateMapSize);
|
||||
FloorItemDetail.listened.delete(this);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,332 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import {
|
||||
ERenderItemEvent,
|
||||
RenderItem,
|
||||
RenderItemPosition,
|
||||
MotaOffscreenCanvas2D,
|
||||
Transform,
|
||||
SizedCanvasImageSource
|
||||
} from '@motajs/render-core';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { RenderableData, AutotileRenderable, texture } from './cache';
|
||||
import { IAnimateFrame, renderEmits } from './frame';
|
||||
|
||||
export interface EIconEvent extends ERenderItemEvent {}
|
||||
|
||||
export class Icon extends RenderItem<EIconEvent> implements IAnimateFrame {
|
||||
/** 图标id */
|
||||
icon: AllNumbers = 0;
|
||||
/** 帧数 */
|
||||
frame: number = 0;
|
||||
/** 是否启用动画 */
|
||||
animate: boolean = false;
|
||||
/** 图标的渲染信息 */
|
||||
private renderable?: RenderableData | AutotileRenderable;
|
||||
|
||||
private pendingIcon?: AllNumbers;
|
||||
|
||||
constructor(type: RenderItemPosition, cache?: boolean, fall?: boolean) {
|
||||
super(type, cache, fall);
|
||||
this.setAntiAliasing(false);
|
||||
this.setHD(false);
|
||||
}
|
||||
|
||||
protected render(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
_transform: Transform
|
||||
): void {
|
||||
const ctx = canvas.ctx;
|
||||
const renderable = this.renderable;
|
||||
if (!renderable) return;
|
||||
const [x, y, w, h] = renderable.render[0];
|
||||
const cw = this.width;
|
||||
const ch = this.height;
|
||||
const frame = this.animate
|
||||
? RenderItem.animatedFrame % renderable.frame
|
||||
: this.frame;
|
||||
|
||||
if (!this.animate) {
|
||||
if (renderable.autotile) {
|
||||
ctx.drawImage(renderable.image[0], x, y, w, h, 0, 0, cw, ch);
|
||||
} else {
|
||||
ctx.drawImage(renderable.image, x, y, w, h, 0, 0, cw, ch);
|
||||
}
|
||||
} else {
|
||||
const [x1, y1, w1, h1] = renderable.render[frame];
|
||||
if (renderable.autotile) {
|
||||
const img = renderable.image[0];
|
||||
ctx.drawImage(img, x1, y1, w1, h1, 0, 0, cw, ch);
|
||||
} else {
|
||||
ctx.drawImage(renderable.image, x1, y1, w1, h1, 0, 0, cw, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置图标
|
||||
* @param id 图标id
|
||||
*/
|
||||
setIcon(id: AllIdsWithNone | AllNumbers) {
|
||||
if (id === 0 || id === 'none') {
|
||||
this.renderable = void 0;
|
||||
return;
|
||||
}
|
||||
const num = typeof id === 'number' ? id : texture.idNumberMap[id];
|
||||
|
||||
const { loading } = Mota.require('@user/data-base');
|
||||
if (loading.loaded) {
|
||||
this.setIconRenderable(num);
|
||||
} else {
|
||||
if (isNil(this.pendingIcon)) {
|
||||
loading.once('loaded', () => {
|
||||
this.setIconRenderable(this.pendingIcon ?? 0);
|
||||
delete this.pendingIcon;
|
||||
});
|
||||
}
|
||||
this.pendingIcon = num;
|
||||
}
|
||||
}
|
||||
|
||||
private setIconRenderable(num: AllNumbers) {
|
||||
const renderable = texture.getRenderable(num);
|
||||
|
||||
if (!renderable) {
|
||||
logger.warn(43, num.toString());
|
||||
return;
|
||||
} else {
|
||||
this.icon = num;
|
||||
this.renderable = renderable;
|
||||
this.frame = renderable.frame;
|
||||
}
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动画帧
|
||||
*/
|
||||
updateFrameAnimate(): void {
|
||||
if (this.animate) this.update(this);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
renderEmits.removeFramer(this);
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
protected handleProps(
|
||||
key: string,
|
||||
_prevValue: any,
|
||||
nextValue: any
|
||||
): boolean {
|
||||
switch (key) {
|
||||
case 'icon':
|
||||
this.setIcon(nextValue);
|
||||
return true;
|
||||
case 'animate':
|
||||
if (!this.assertType(nextValue, 'boolean', key)) return false;
|
||||
this.animate = nextValue;
|
||||
if (nextValue) renderEmits.addFramer(this);
|
||||
else renderEmits.removeFramer(this);
|
||||
this.update();
|
||||
return true;
|
||||
case 'frame':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.frame = nextValue;
|
||||
this.update();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface WinskinPatterns {
|
||||
top: CanvasPattern;
|
||||
left: CanvasPattern;
|
||||
bottom: CanvasPattern;
|
||||
right: CanvasPattern;
|
||||
}
|
||||
|
||||
export interface EWinskinEvent extends ERenderItemEvent {}
|
||||
|
||||
export class Winskin extends RenderItem<EWinskinEvent> {
|
||||
image: SizedCanvasImageSource;
|
||||
/** 边框宽度,32表示原始宽度 */
|
||||
borderSize: number = 32;
|
||||
/** 图片名称 */
|
||||
imageName?: string;
|
||||
|
||||
private pendingImage?: ImageIds;
|
||||
private patternCache?: WinskinPatterns;
|
||||
private patternTransform: DOMMatrix;
|
||||
|
||||
// todo: 跨上下文可能是未定义行为,需要上下文无关化
|
||||
private static patternMap: Map<string, WinskinPatterns> = new Map();
|
||||
|
||||
constructor(
|
||||
image: SizedCanvasImageSource,
|
||||
type: RenderItemPosition = 'static'
|
||||
) {
|
||||
super(type, false, false);
|
||||
this.image = image;
|
||||
this.setAntiAliasing(false);
|
||||
|
||||
if (window.DOMMatrix) {
|
||||
this.patternTransform = new DOMMatrix();
|
||||
} else if (window.WebKitCSSMatrix) {
|
||||
this.patternTransform = new WebKitCSSMatrix();
|
||||
} else {
|
||||
this.patternTransform = new SVGMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
private generatePattern() {
|
||||
const pattern = this.requireCanvas(true, false);
|
||||
pattern.setScale(1);
|
||||
const img = this.image;
|
||||
pattern.size(32, 16);
|
||||
pattern.setHD(false);
|
||||
pattern.setAntiAliasing(false);
|
||||
const ctx = pattern.ctx;
|
||||
ctx.drawImage(img, 144, 0, 32, 16, 0, 0, 32, 16);
|
||||
const topPattern = ctx.createPattern(pattern.canvas, 'repeat');
|
||||
ctx.clearRect(0, 0, 32, 16);
|
||||
ctx.drawImage(img, 144, 48, 32, 16, 0, 0, 32, 16);
|
||||
const bottomPattern = ctx.createPattern(pattern.canvas, 'repeat');
|
||||
ctx.clearRect(0, 0, 32, 16);
|
||||
pattern.size(16, 32);
|
||||
ctx.drawImage(img, 128, 16, 16, 32, 0, 0, 16, 32);
|
||||
const leftPattern = ctx.createPattern(pattern.canvas, 'repeat');
|
||||
ctx.clearRect(0, 0, 16, 32);
|
||||
ctx.drawImage(img, 176, 16, 16, 32, 0, 0, 16, 32);
|
||||
const rightPattern = ctx.createPattern(pattern.canvas, 'repeat');
|
||||
if (!topPattern || !bottomPattern || !leftPattern || !rightPattern) {
|
||||
return null;
|
||||
}
|
||||
const winskinPattern: WinskinPatterns = {
|
||||
top: topPattern,
|
||||
bottom: bottomPattern,
|
||||
left: leftPattern,
|
||||
right: rightPattern
|
||||
};
|
||||
if (this.imageName) {
|
||||
Winskin.patternMap.set(this.imageName, winskinPattern);
|
||||
}
|
||||
this.patternCache = winskinPattern;
|
||||
this.deleteCanvas(pattern);
|
||||
return winskinPattern;
|
||||
}
|
||||
|
||||
private getPattern() {
|
||||
if (!this.imageName) {
|
||||
if (this.patternCache) return this.patternCache;
|
||||
return this.generatePattern();
|
||||
} else {
|
||||
const pattern = Winskin.patternMap.get(this.imageName);
|
||||
if (pattern) return pattern;
|
||||
return this.generatePattern();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
_transform: Transform
|
||||
): void {
|
||||
const ctx = canvas.ctx;
|
||||
const img = this.image;
|
||||
const w = this.width;
|
||||
const h = this.height;
|
||||
const pad = this.borderSize / 2;
|
||||
// 背景
|
||||
ctx.drawImage(img, 0, 0, 128, 128, 2, 2, w - 4, h - 4);
|
||||
const pattern = this.getPattern();
|
||||
if (!pattern) return;
|
||||
const { top, left, right, bottom } = pattern;
|
||||
top.setTransform(this.patternTransform);
|
||||
left.setTransform(this.patternTransform);
|
||||
right.setTransform(this.patternTransform);
|
||||
bottom.setTransform(this.patternTransform);
|
||||
// 上下左右边框
|
||||
ctx.save();
|
||||
ctx.fillStyle = top;
|
||||
ctx.translate(pad, 0);
|
||||
ctx.fillRect(0, 0, w - pad * 2, pad);
|
||||
ctx.fillStyle = bottom;
|
||||
ctx.translate(0, h - pad);
|
||||
ctx.fillRect(0, 0, w - pad * 2, pad);
|
||||
ctx.fillStyle = left;
|
||||
ctx.translate(-pad, pad * 2 - h);
|
||||
ctx.fillRect(0, 0, pad, h - pad * 2);
|
||||
ctx.fillStyle = right;
|
||||
ctx.translate(w - pad, 0);
|
||||
ctx.fillRect(0, 0, pad, h - pad * 2);
|
||||
ctx.restore();
|
||||
// 四个角的边框
|
||||
ctx.drawImage(img, 128, 0, 16, 16, 0, 0, pad, pad);
|
||||
ctx.drawImage(img, 176, 0, 16, 16, w - pad, 0, pad, pad);
|
||||
ctx.drawImage(img, 128, 48, 16, 16, 0, h - pad, pad, pad);
|
||||
ctx.drawImage(img, 176, 48, 16, 16, w - pad, h - pad, pad, pad);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置winskin图片
|
||||
* @param image winskin图片
|
||||
*/
|
||||
setImage(image: SizedCanvasImageSource) {
|
||||
this.image = image;
|
||||
this.patternCache = void 0;
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过图片名称设置winskin
|
||||
* @param name 图片名称
|
||||
*/
|
||||
setImageByName(name: ImageIds) {
|
||||
const { loading } = Mota.require('@user/data-base');
|
||||
if (loading.loaded) {
|
||||
const image = core.material.images.images[name];
|
||||
this.setImage(image);
|
||||
} else {
|
||||
if (isNil(this.pendingImage)) {
|
||||
loading.once('loaded', () => {
|
||||
const id = this.pendingImage;
|
||||
if (!id) return;
|
||||
const image = core.material.images.images[id];
|
||||
this.setImage(image);
|
||||
delete this.pendingImage;
|
||||
});
|
||||
}
|
||||
this.pendingImage = name;
|
||||
}
|
||||
this.imageName = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置边框大小
|
||||
* @param size 边框大小
|
||||
*/
|
||||
setBorderSize(size: number) {
|
||||
this.borderSize = size;
|
||||
this.patternTransform.a = size / 32;
|
||||
this.patternTransform.d = size / 32;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected handleProps(
|
||||
key: string,
|
||||
_prevValue: any,
|
||||
nextValue: any
|
||||
): boolean {
|
||||
switch (key) {
|
||||
case 'image':
|
||||
if (!this.assertType(nextValue, 'string', key)) return false;
|
||||
this.setImageByName(nextValue);
|
||||
return true;
|
||||
case 'borderSize':
|
||||
if (!this.assertType(nextValue, 'number', key)) return false;
|
||||
this.setBorderSize(nextValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BaseProps, TagDefine } from '@motajs/render-vue';
|
||||
import { CanvasStyle, Transform } from '@motajs/render-core';
|
||||
import {
|
||||
ILayerGroupRenderExtends,
|
||||
FloorLayer,
|
||||
ILayerRenderExtends,
|
||||
ELayerEvent,
|
||||
ELayerGroupEvent
|
||||
} from './layer';
|
||||
import { EAnimateEvent } from './animate';
|
||||
import { EIconEvent, EWinskinEvent } from './misc';
|
||||
import { IEnemyCollection } from '@motajs/types';
|
||||
|
||||
export interface AnimateProps extends BaseProps {}
|
||||
|
||||
export interface DamageProps extends BaseProps {
|
||||
mapWidth?: number;
|
||||
mapHeight?: number;
|
||||
cellSize?: number;
|
||||
enemy?: IEnemyCollection;
|
||||
font?: string;
|
||||
strokeStyle?: CanvasStyle;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export interface IconProps extends BaseProps {
|
||||
/** 图标 id 或数字 */
|
||||
icon: AllNumbers | AllIds;
|
||||
/** 显示图标的第几帧 */
|
||||
frame?: number;
|
||||
/** 是否开启动画,开启后 frame 参数无效 */
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export interface WinskinProps extends BaseProps {
|
||||
/** winskin 的图片 id */
|
||||
image: ImageIds;
|
||||
/** 边框大小 */
|
||||
borderSize?: number;
|
||||
}
|
||||
|
||||
export interface LayerGroupProps extends BaseProps {
|
||||
cellSize?: number;
|
||||
blockSize?: number;
|
||||
floorId?: FloorIds;
|
||||
bindThisFloor?: boolean;
|
||||
camera?: Transform;
|
||||
ex?: readonly ILayerGroupRenderExtends[];
|
||||
layers?: readonly FloorLayer[];
|
||||
}
|
||||
|
||||
export interface LayerProps extends BaseProps {
|
||||
layer?: FloorLayer;
|
||||
mapWidth?: number;
|
||||
mapHeight?: number;
|
||||
cellSize?: number;
|
||||
background?: AllNumbers;
|
||||
floorImage?: FloorAnimate[];
|
||||
ex?: readonly ILayerRenderExtends[];
|
||||
}
|
||||
|
||||
declare module 'vue/jsx-runtime' {
|
||||
namespace JSX {
|
||||
export interface IntrinsicElements {
|
||||
layer: TagDefine<LayerProps, ELayerEvent>;
|
||||
'layer-group': TagDefine<LayerGroupProps, ELayerGroupEvent>;
|
||||
animation: TagDefine<AnimateProps, EAnimateEvent>;
|
||||
icon: TagDefine<IconProps, EIconEvent>;
|
||||
winskin: TagDefine<WinskinProps, EWinskinEvent>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { RenderAdapter } from '@motajs/render-core';
|
||||
import { FloorViewport } from './viewport';
|
||||
|
||||
export function disableViewport() {
|
||||
const adapter = RenderAdapter.get<FloorViewport>('viewport');
|
||||
if (!adapter) return;
|
||||
adapter.sync('disable');
|
||||
}
|
||||
|
||||
export function enableViewport() {
|
||||
const adapter = RenderAdapter.get<FloorViewport>('viewport');
|
||||
if (!adapter) return;
|
||||
adapter.sync('enable');
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { RenderAdapter } from '@motajs/render-core';
|
||||
import { HeroRenderer } from './hero';
|
||||
import { ILayerGroupRenderExtends, LayerGroup } from './layer';
|
||||
import { LayerGroupFloorBinder } from './layer';
|
||||
import { hyper, TimingFn } from 'mutate-animate';
|
||||
import {
|
||||
MAP_BLOCK_HEIGHT,
|
||||
MAP_BLOCK_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
MAP_WIDTH
|
||||
} from '../shared';
|
||||
|
||||
export class FloorViewport implements ILayerGroupRenderExtends {
|
||||
id: string = 'viewport';
|
||||
|
||||
group!: LayerGroup;
|
||||
hero?: HeroRenderer;
|
||||
binder?: LayerGroupFloorBinder;
|
||||
|
||||
/** 是否启用视角控制拓展 */
|
||||
enabled: boolean = true;
|
||||
/** 是否自动限定视角范围至地图范围 */
|
||||
boundX: boolean = true;
|
||||
boundY: boolean = true;
|
||||
|
||||
/** 渐变的速率曲线 */
|
||||
transitionFn: TimingFn = hyper('sin', 'out');
|
||||
/** 瞬移的速率曲线 */
|
||||
mutateFn: TimingFn = hyper('sin', 'out');
|
||||
|
||||
/** 突变时的渐变时长 */
|
||||
transitionTime: number = 600;
|
||||
|
||||
/** 当前视角位置 */
|
||||
private nx: number = 0;
|
||||
private ny: number = 0;
|
||||
/** 移动时的偏移位置 */
|
||||
private ox: number = 0;
|
||||
private oy: number = 0;
|
||||
/** 移动时的偏移最大值 */
|
||||
private maxOffset: number = 1;
|
||||
|
||||
/** 委托ticker */
|
||||
private delegation: number = -1;
|
||||
/** 渐变委托ticker */
|
||||
private transition: number = -1;
|
||||
/** 移动委托ticker */
|
||||
private moving: number = -1;
|
||||
/** 是否在渐变过程中 */
|
||||
private inTransition: boolean = false;
|
||||
/** 是否在移动过程中 */
|
||||
private inMoving: boolean = false;
|
||||
|
||||
/** 移动的监听函数 */
|
||||
private movingFramer?: () => void;
|
||||
|
||||
/**
|
||||
* 禁用自动视角控制
|
||||
*/
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用自动视角控制
|
||||
*/
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
const { x, y } = core.status.hero.loc;
|
||||
const { x: nx, y: ny } = this.group.camera;
|
||||
const halfWidth = MAP_WIDTH / 2;
|
||||
const halfHeight = MAP_HEIGHT / 2;
|
||||
const cell = this.group.cellSize;
|
||||
const half = cell / 2;
|
||||
this.applyPosition(
|
||||
-(nx - halfWidth + half) / this.group.cellSize,
|
||||
-(ny - halfHeight + half) / this.group.cellSize
|
||||
);
|
||||
this.mutateTo(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否自动限定视角范围至地图范围
|
||||
* @param boundX 是否自动限定水平视角范围
|
||||
* @param boundY 是否自动限定竖直视角范围
|
||||
*/
|
||||
setAutoBound(boundX: boolean = this.boundX, boundY: boolean = this.boundY) {
|
||||
this.boundX = boundX;
|
||||
this.boundY = boundY;
|
||||
this.group.requestBeforeFrame(() => {
|
||||
this.setPosition(this.nx, this.ny);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入视角的目标位置,将其限定在地图范围内后返回
|
||||
* @param x 图格横坐标
|
||||
* @param y 图格纵坐标
|
||||
*/
|
||||
getBoundedPosition(x: number, y: number) {
|
||||
if (!this.checkDependency()) return { x, y };
|
||||
if (!this.boundX && !this.boundY) return { x, y };
|
||||
const width = MAP_BLOCK_WIDTH;
|
||||
const height = MAP_BLOCK_HEIGHT;
|
||||
const minX = (width - 1) / 2;
|
||||
const minY = (height - 1) / 2;
|
||||
const floor = core.status.maps[this.binder!.getFloor()];
|
||||
const maxX = floor.width - minX - 1;
|
||||
const maxY = floor.height - minY - 1;
|
||||
|
||||
return {
|
||||
x: this.boundX ? core.clamp(x, minX, maxX) : x,
|
||||
y: this.boundY ? core.clamp(y, minY, maxY) : y
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视角位置
|
||||
* @param x 目标图格横坐标
|
||||
* @param y 目标图格纵坐标
|
||||
*/
|
||||
setPosition(x: number, y: number) {
|
||||
if (!this.enabled) return;
|
||||
const { x: nx, y: ny } = this.getBoundedPosition(x, y);
|
||||
this.group.removeTicker(this.transition, false);
|
||||
this.applyPosition(nx, ny);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始移动
|
||||
*/
|
||||
startMove() {
|
||||
if (this.inMoving) return;
|
||||
this.inMoving = true;
|
||||
this.createMoveTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束移动
|
||||
*/
|
||||
endMove() {
|
||||
this.inMoving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当勇士通过移动改变至指定位置时移动视角
|
||||
* @param x 目标图格横坐标
|
||||
* @param y 目标图格纵坐标
|
||||
*/
|
||||
moveTo(x: number, y: number, time: number = 200) {
|
||||
if (!this.enabled) return;
|
||||
const { x: nx, y: ny } = this.getBoundedPosition(x, y);
|
||||
if (this.inTransition) {
|
||||
const distance = Math.hypot(this.nx - nx, this.ny - ny);
|
||||
const t = core.clamp(distance * time, time, time * 3);
|
||||
this.createTransition(nx, ny, t, this.transitionFn);
|
||||
}
|
||||
}
|
||||
|
||||
private createMoveTransition() {
|
||||
if (!this.checkDependency()) return;
|
||||
let xTarget: number = 0;
|
||||
let yTarget: number = 0;
|
||||
let xStart: number = this.ox;
|
||||
let yStart: number = this.oy;
|
||||
let xStartTime: number = Date.now();
|
||||
let yStartTime: number = Date.now();
|
||||
let ending: boolean = false;
|
||||
// 这个数等于 sinh(2),用这个数的话,可以正好在刚开始移动的时候达到1的斜率,效果会比较好
|
||||
const transitionTime = this.hero!.speed * 3.626860407847019;
|
||||
|
||||
const setTargetX = (x: number, time: number) => {
|
||||
if (x === xTarget) return;
|
||||
xTarget = x;
|
||||
xStartTime = time;
|
||||
xStart = this.ox;
|
||||
};
|
||||
const setTargetY = (y: number, time: number) => {
|
||||
if (y === yTarget) return;
|
||||
yTarget = y;
|
||||
yStart = this.oy;
|
||||
yStartTime = time;
|
||||
};
|
||||
|
||||
if (this.movingFramer) {
|
||||
this.hero!.off('moveTick', this.movingFramer);
|
||||
}
|
||||
this.movingFramer = () => {
|
||||
if (this.inTransition) return;
|
||||
const now = Date.now();
|
||||
if (!this.inMoving && !ending) {
|
||||
setTargetX(0, now);
|
||||
setTargetY(0, now);
|
||||
ending = true;
|
||||
}
|
||||
if (!ending) {
|
||||
const dir = this.hero!.stepDir;
|
||||
const { x, y } = core.utils.scan2[dir];
|
||||
setTargetX(-x * this.maxOffset, now);
|
||||
setTargetY(-y * this.maxOffset, now);
|
||||
}
|
||||
|
||||
if (!this.hero!.renderable) return;
|
||||
|
||||
const { x, y } = this.hero!.renderable;
|
||||
const { x: nx, y: ny } = this.getBoundedPosition(x, y);
|
||||
this.applyPosition(nx, ny);
|
||||
|
||||
if (ending) {
|
||||
if (this.ox === xTarget && this.oy === yTarget) {
|
||||
this.hero!.off('moveTick', this.movingFramer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// todo: 效果太差了,需要优化
|
||||
return;
|
||||
if (this.ox !== xTarget) {
|
||||
const time = transitionTime * Math.abs(xStart - xTarget);
|
||||
const progress = (now - xStartTime) / time;
|
||||
if (progress > 1) {
|
||||
this.ox = xTarget;
|
||||
} else {
|
||||
const p = this.transitionFn(progress);
|
||||
this.ox = (xTarget - xStart) * p + xStart;
|
||||
}
|
||||
}
|
||||
if (this.oy !== yTarget) {
|
||||
const time = transitionTime * Math.abs(yStart - yTarget);
|
||||
const progress = (now - yStartTime) / time;
|
||||
if (progress > 1) {
|
||||
this.oy = yTarget;
|
||||
} else {
|
||||
const p = this.transitionFn(progress);
|
||||
this.oy = (yTarget - yStart) * p + yStart;
|
||||
}
|
||||
}
|
||||
};
|
||||
this.hero!.on('moveTick', this.movingFramer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当勇士位置突变至指定位置时移动视角
|
||||
* @param x 目标图格横坐标
|
||||
* @param y 目标图格纵坐标
|
||||
*/
|
||||
mutateTo(x: number, y: number, time: number = this.transitionTime) {
|
||||
if (!this.enabled) return;
|
||||
const { x: nx, y: ny } = this.getBoundedPosition(x, y);
|
||||
this.createTransition(nx, ny, time, this.mutateFn);
|
||||
}
|
||||
|
||||
private createTransition(x: number, y: number, time: number, fn: TimingFn) {
|
||||
const start = Date.now();
|
||||
const end = start + time;
|
||||
const sx = this.nx;
|
||||
const sy = this.ny;
|
||||
const dx = x - sx;
|
||||
const dy = y - sy;
|
||||
|
||||
this.inTransition = true;
|
||||
this.group.removeTicker(this.transition, false);
|
||||
this.transition = this.group.delegateTicker(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
if (now >= end) {
|
||||
this.group.removeTicker(this.transition, true);
|
||||
return;
|
||||
}
|
||||
const progress = fn((now - start) / time);
|
||||
const tx = dx * progress;
|
||||
const ty = dy * progress;
|
||||
this.applyPosition(tx + sx, ty + sy);
|
||||
},
|
||||
time,
|
||||
() => {
|
||||
this.applyPosition(x, y);
|
||||
this.inTransition = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private applyPosition(x: number, y: number) {
|
||||
if (!this.enabled) return;
|
||||
if (x === this.nx && y === this.ny) return;
|
||||
const halfWidth = MAP_WIDTH / 2;
|
||||
const halfHeight = MAP_HEIGHT / 2;
|
||||
const cell = this.group.cellSize;
|
||||
const half = cell / 2;
|
||||
this.nx = x;
|
||||
this.ny = y;
|
||||
const { x: bx, y: by } = this.getBoundedPosition(x, y);
|
||||
const rx = bx * cell - halfWidth + half;
|
||||
const ry = by * cell - halfHeight + half;
|
||||
core.bigmap.offsetX = rx;
|
||||
core.bigmap.offsetY = ry;
|
||||
this.group.camera.setTranslate(-rx, -ry);
|
||||
this.group.update(this.group);
|
||||
}
|
||||
|
||||
private checkDependency() {
|
||||
if (this.hero && this.binder) return true;
|
||||
const group = this.group;
|
||||
const ex1 = group.getLayer('event')?.getExtends('floor-hero');
|
||||
const ex2 = group.getExtends('floor-binder');
|
||||
if (
|
||||
ex1 instanceof HeroRenderer &&
|
||||
ex2 instanceof LayerGroupFloorBinder
|
||||
) {
|
||||
this.hero = ex1;
|
||||
this.binder = ex2;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
awake(group: LayerGroup): void {
|
||||
this.group = group;
|
||||
adapter.add(this);
|
||||
}
|
||||
|
||||
onDestroy(group: LayerGroup): void {
|
||||
group.removeTicker(this.delegation);
|
||||
group.removeTicker(this.transition);
|
||||
group.removeTicker(this.moving);
|
||||
adapter.remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = new RenderAdapter<FloorViewport>('viewport');
|
||||
adapter.receive('mutateTo', (item, x, y, time) => {
|
||||
item.mutateTo(x, y, time);
|
||||
return Promise.resolve();
|
||||
});
|
||||
adapter.receive('moveTo', (item, x, y, time) => {
|
||||
item.moveTo(x, y, time);
|
||||
return Promise.resolve();
|
||||
});
|
||||
adapter.receive('setPosition', (item, x, y) => {
|
||||
item.setPosition(x, y);
|
||||
return Promise.resolve();
|
||||
});
|
||||
adapter.receiveSync('disable', item => {
|
||||
item.disable();
|
||||
});
|
||||
adapter.receiveSync('enable', item => {
|
||||
item.enable();
|
||||
});
|
||||
adapter.receiveSync('startMove', item => {
|
||||
item.startMove();
|
||||
});
|
||||
adapter.receiveSync('endMove', item => {
|
||||
item.endMove();
|
||||
});
|
||||
|
||||
export function createViewport() {
|
||||
const { hook } = Mota.require('@user/data-base');
|
||||
hook.on('changingFloor', (_, loc) => {
|
||||
adapter.all('setPosition', loc.x, loc.y);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Shader, ShaderProgram } from '@motajs/render-core';
|
||||
|
||||
export abstract class EffectBase<T> {
|
||||
/** 当前使用的程序 */
|
||||
protected program: ShaderProgram | null = null;
|
||||
/** 当前使用的着色器渲染元素 */
|
||||
protected shader: Shader | null = null;
|
||||
|
||||
/**
|
||||
* 在一个着色器元素上创建效果
|
||||
* @param shader 着色器程序
|
||||
* @param options 本效果的配置信息
|
||||
*/
|
||||
create(shader: Shader, options: T) {
|
||||
const vs = this.getVertex(options);
|
||||
const fs = this.getFragment(options);
|
||||
const program = shader.createProgram(ShaderProgram);
|
||||
program.vs(vs);
|
||||
program.fs(fs);
|
||||
program.requestCompile();
|
||||
|
||||
this.program = program;
|
||||
this.shader = shader;
|
||||
|
||||
shader.useProgram(program);
|
||||
this.initProgram(program, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取片段着色器代码
|
||||
* @param options 配置信息
|
||||
*/
|
||||
protected abstract getVertex(options: T): string;
|
||||
|
||||
/**
|
||||
* 获取顶点着色器代码
|
||||
* @param options 配置信息
|
||||
*/
|
||||
protected abstract getFragment(options: T): string;
|
||||
|
||||
/**
|
||||
* 初始化着色器程序
|
||||
* @param program 着色器程序
|
||||
* @param options 配置信息
|
||||
*/
|
||||
abstract initProgram(program: ShaderProgram, options: T): void;
|
||||
|
||||
/**
|
||||
* 更新着色器渲染
|
||||
*/
|
||||
requestUpdate() {
|
||||
this.shader?.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用此着色器
|
||||
*/
|
||||
use() {
|
||||
if (!this.program || !this.shader) return;
|
||||
this.shader.useProgram(this.program);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
ITransformUpdatable,
|
||||
ShaderProgram,
|
||||
Transform3D
|
||||
} from '@motajs/render-core';
|
||||
import { EffectBase } from './base';
|
||||
|
||||
export class Image3DEffect
|
||||
extends EffectBase<void>
|
||||
implements ITransformUpdatable
|
||||
{
|
||||
/** 图片的模型变换 */
|
||||
private model: Transform3D = new Transform3D();
|
||||
/** 视角变换 */
|
||||
private view: Transform3D = new Transform3D();
|
||||
/** 投影变换 */
|
||||
private proj: Transform3D = new Transform3D();
|
||||
|
||||
protected getVertex(): string {
|
||||
return /* glsl */ `
|
||||
uniform mat4 u_imageTransform;
|
||||
|
||||
void main() {
|
||||
v_texCoord = a_texCoord;
|
||||
gl_Position = u_imageTransform * a_position;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected getFragment(): string {
|
||||
return /* glsl */ `
|
||||
out vec4 color;
|
||||
|
||||
void main() {
|
||||
color = texture(u_sampler, v_texCoord);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
initProgram(program: ShaderProgram): void {
|
||||
if (!this.shader) return;
|
||||
program.defineUniformMatrix(
|
||||
'u_imageTransform',
|
||||
this.shader.U_MATRIX_4x4
|
||||
);
|
||||
this.proj.perspective(Math.PI / 2, 1, 0.01, 1000);
|
||||
this.view.lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]);
|
||||
this.model.bind(this);
|
||||
this.view.bind(this);
|
||||
this.proj.bind(this);
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
updateTransform(): void {
|
||||
if (!this.shader || !this.program) return;
|
||||
const matrix = this.program.getMatrix('u_imageTransform');
|
||||
if (!matrix) return;
|
||||
const trans = this.proj.multiply(this.view).multiply(this.model);
|
||||
matrix.set(false, Array.from(trans.mat));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模型变换
|
||||
* @param model 模型变换
|
||||
*/
|
||||
setModel(model: Transform3D) {
|
||||
this.model.bind();
|
||||
this.model = model;
|
||||
model.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视角变换
|
||||
* @param model 视角变换
|
||||
*/
|
||||
setView(view: Transform3D) {
|
||||
this.view.bind();
|
||||
this.view = view;
|
||||
view.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置投影变换
|
||||
* @param model 投影变换
|
||||
*/
|
||||
setProj(proj: Transform3D) {
|
||||
this.proj.bind();
|
||||
this.proj = proj;
|
||||
proj.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型变换
|
||||
*/
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视角变换
|
||||
*/
|
||||
getView() {
|
||||
return this.view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取投影变换
|
||||
*/
|
||||
getProj() {
|
||||
return this.proj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './base';
|
||||
export * from './image3d';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createApp, Font } from '@motajs/render';
|
||||
import { defineComponent } from 'vue';
|
||||
import { DEFAULT_FONT, MAIN_HEIGHT, MAIN_WIDTH } from './shared';
|
||||
import { hook, loading } from '@user/data-base';
|
||||
import { createElements } from './elements';
|
||||
import { mainRenderer } from './renderer';
|
||||
import { createUI } from './ui';
|
||||
import { createAction } from './action';
|
||||
import { sceneController } from './scene';
|
||||
import { GameTitleUI } from './ui/title';
|
||||
import { createWeather } from './weather';
|
||||
|
||||
export function createGameRenderer() {
|
||||
const App = defineComponent(_props => {
|
||||
return () => (
|
||||
<container noanti width={MAIN_WIDTH} height={MAIN_HEIGHT}>
|
||||
{sceneController.render()}
|
||||
</container>
|
||||
);
|
||||
});
|
||||
|
||||
mainRenderer.setAntiAliasing(false);
|
||||
mainRenderer.hide();
|
||||
createApp(App).mount(mainRenderer);
|
||||
|
||||
console.log(mainRenderer);
|
||||
}
|
||||
|
||||
export function createRender() {
|
||||
createElements();
|
||||
createUI();
|
||||
createAction();
|
||||
createWeather();
|
||||
|
||||
loading.on('loaded', () => {
|
||||
sceneController.open(GameTitleUI, {});
|
||||
mainRenderer.show();
|
||||
});
|
||||
|
||||
hook.on('restart', () => {
|
||||
sceneController.closeAll();
|
||||
sceneController.open(GameTitleUI, {});
|
||||
});
|
||||
|
||||
Font.setDefaults(DEFAULT_FONT);
|
||||
}
|
||||
|
||||
export * from './components';
|
||||
export * from './elements';
|
||||
export * from './fx';
|
||||
export * from './ui';
|
||||
export * from './utils';
|
||||
export * from './weather';
|
||||
export * from './renderer';
|
||||
export * from './scene';
|
||||
export * from './shared';
|
||||
export * from './use';
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
wrapInstancedComponent,
|
||||
MotaOffscreenCanvas2D,
|
||||
RenderItem,
|
||||
RenderItemPosition,
|
||||
Transform
|
||||
} from '@motajs/render';
|
||||
|
||||
// 渲染端的向后兼容用,会充当两个版本间过渡的作用
|
||||
class Change extends RenderItem {
|
||||
private tips: string[] = [];
|
||||
/** 当前小贴士 */
|
||||
private usingTip: string = '';
|
||||
/** 透明度 */
|
||||
private backAlpha: number = 0;
|
||||
private title: string = '';
|
||||
|
||||
constructor(type: RenderItemPosition) {
|
||||
super(type, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置楼传过程中的小贴士
|
||||
*/
|
||||
setTips(tip: string[]) {
|
||||
this.tips = tip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置标题
|
||||
*/
|
||||
setTitle(title: string) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示楼层切换的从透明变黑的动画
|
||||
* @param time 动画时长
|
||||
*/
|
||||
showChange(time: number) {
|
||||
const length = this.tips.length;
|
||||
const tip = this.tips[Math.floor(Math.random() * length)] ?? '';
|
||||
this.usingTip = tip;
|
||||
|
||||
return new Promise<void>(res => {
|
||||
const start = Date.now();
|
||||
const id = this.delegateTicker(
|
||||
() => {
|
||||
const dt = Date.now() - start;
|
||||
const progress = dt / time;
|
||||
if (progress > 1) {
|
||||
this.backAlpha = 1;
|
||||
this.removeTicker(id);
|
||||
} else {
|
||||
this.backAlpha = progress;
|
||||
}
|
||||
this.update();
|
||||
},
|
||||
10000,
|
||||
res
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示楼层切换从黑到透明的动画
|
||||
* @param time 动画时长
|
||||
*/
|
||||
async hideChange(time: number) {
|
||||
return new Promise<void>(res => {
|
||||
const start = Date.now();
|
||||
const id = this.delegateTicker(
|
||||
() => {
|
||||
const dt = Date.now() - start;
|
||||
const progress = dt / time;
|
||||
if (progress > 1) {
|
||||
this.removeTicker(id);
|
||||
this.backAlpha = 0;
|
||||
} else {
|
||||
this.backAlpha = 1 - progress;
|
||||
}
|
||||
this.update();
|
||||
},
|
||||
10000,
|
||||
res
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected render(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
_transform: Transform
|
||||
): void {
|
||||
if (this.backAlpha === 0) return;
|
||||
const ctx = canvas.ctx;
|
||||
ctx.globalAlpha = this.backAlpha;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '32px "normal"';
|
||||
ctx.fillText(this.title, canvas.width / 2, canvas.height * 0.4);
|
||||
ctx.font = '16px "normal"';
|
||||
if (this.usingTip.length > 0) {
|
||||
ctx.fillText(
|
||||
'小贴士:' + this.usingTip,
|
||||
canvas.width / 2,
|
||||
canvas.height * 0.75
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FloorChange = wrapInstancedComponent(() => new Change('static'));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { MotaRenderer } from '@motajs/render-core';
|
||||
import { MAIN_WIDTH, MAIN_HEIGHT } from './shared';
|
||||
|
||||
export const mainRenderer = new MotaRenderer({
|
||||
canvas: '#render-main',
|
||||
width: MAIN_WIDTH,
|
||||
height: MAIN_HEIGHT
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { UIController } from '@motajs/system-ui';
|
||||
|
||||
export const sceneController = new UIController('main-scene');
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ElementLocator } from '@motajs/render-core';
|
||||
import { Font } from '@motajs/render-style';
|
||||
|
||||
// 本文件为 UI 配置文件,你可以修改下面的每个常量来控制 UI 的显示参数,每个常量都有注释说明
|
||||
|
||||
//#region 地图
|
||||
|
||||
/** 每个格子的宽高 */
|
||||
export const CELL_SIZE = 32;
|
||||
/** 地图格子宽度,此处仅影响画面,不影响游戏内逻辑,游戏内逻辑地图大小请在 core.js 中修改 */
|
||||
export const MAP_BLOCK_WIDTH = 13;
|
||||
/** 地图格子高度,此处仅影响画面,不影响游戏内逻辑,游戏内逻辑地图大小请在 core.js 中修改 */
|
||||
export const MAP_BLOCK_HEIGHT = 13;
|
||||
/** 地图像素宽度 */
|
||||
export const MAP_WIDTH = CELL_SIZE * MAP_BLOCK_WIDTH;
|
||||
/** 地图像素高度 */
|
||||
export const MAP_HEIGHT = CELL_SIZE * MAP_BLOCK_HEIGHT;
|
||||
/** 地图宽度的一半 */
|
||||
export const HALF_MAP_WIDTH = MAP_WIDTH / 2;
|
||||
/** 地图高度的一半 */
|
||||
export const HALF_MAP_HEIGHT = MAP_HEIGHT / 2;
|
||||
|
||||
//#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;
|
||||
|
||||
//#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
|
||||
];
|
||||
|
||||
//#region 通用配置
|
||||
|
||||
/** 弹框的宽度,使用在内置 UI 与组件中,包括确认框、选择框、等待框等 */
|
||||
export const POP_BOX_WIDTH = MAP_WIDTH / 2;
|
||||
/** 默认字体 */
|
||||
export const DEFAULT_FONT = new Font('Verdana', 16);
|
||||
|
||||
//#region 存档界面
|
||||
|
||||
/** 存档缩略图尺寸 */
|
||||
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;
|
||||
|
||||
//#region 标题界面
|
||||
|
||||
/** 标题文字中心横坐标 */
|
||||
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;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import {
|
||||
GameUI,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps,
|
||||
UIController
|
||||
} from '@motajs/system-ui';
|
||||
import { defineComponent } from 'vue';
|
||||
import { MAIN_HEIGHT, MAIN_WIDTH } from '../shared';
|
||||
|
||||
export const mainUIController = new UIController('main-ui');
|
||||
|
||||
export interface MainBackgroundProps extends DefaultProps, UIComponentProps {}
|
||||
|
||||
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);
|
||||
|
||||
export const MainBackgroundUI = new GameUI('main-background', MainBackground);
|
||||
|
||||
export function createMainController() {
|
||||
mainUIController.setBackground(MainBackgroundUI, {});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createMainController } from './controller';
|
||||
|
||||
export function createUI() {
|
||||
createMainController();
|
||||
}
|
||||
|
||||
export * from './controller';
|
||||
export * from './main';
|
||||
export * from './save';
|
||||
export * from './settings';
|
||||
export * from './statusBar';
|
||||
export * from './toolbar';
|
||||
export * from './viewmap';
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
Props,
|
||||
Font,
|
||||
IActionEvent,
|
||||
MotaOffscreenCanvas2D,
|
||||
Sprite,
|
||||
onTick,
|
||||
transformCanvas
|
||||
} from '@motajs/render';
|
||||
import { WeatherController } from '../weather';
|
||||
import {
|
||||
defineComponent,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue';
|
||||
import { Textbox, Tip } from '../components';
|
||||
import { GameUI } from '@motajs/system-ui';
|
||||
import {
|
||||
ENABLE_RIGHT_STATUS_BAR,
|
||||
MAIN_HEIGHT,
|
||||
MAIN_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
MAP_WIDTH,
|
||||
RIGHT_STATUS_POS,
|
||||
STATUS_BAR_HEIGHT,
|
||||
STATUS_BAR_WIDTH
|
||||
} from '../shared';
|
||||
import {
|
||||
ILeftHeroStatus,
|
||||
IRightHeroStatus,
|
||||
LeftStatusBar,
|
||||
RightStatusBar
|
||||
} from './statusBar';
|
||||
import { ReplayingStatus } from './toolbar';
|
||||
import { getHeroStatusOn } from '@user/data-state';
|
||||
import { hook } from '@user/data-base';
|
||||
import { FloorDamageExtends, FloorItemDetail } from '../elements';
|
||||
import { FloorChange } from '../legacy/fallback';
|
||||
import { mainUIController } from './controller';
|
||||
import {
|
||||
ILayerGroupRenderExtends,
|
||||
LayerGroupAnimate,
|
||||
FloorViewport,
|
||||
ILayerRenderExtends,
|
||||
HeroRenderer,
|
||||
LayerDoorAnimate,
|
||||
LayerGroup
|
||||
} from '../elements';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
const MainScene = defineComponent(() => {
|
||||
//#region 基本定义
|
||||
const layerGroupExtends: ILayerGroupRenderExtends[] = [
|
||||
new FloorDamageExtends(),
|
||||
new FloorItemDetail(),
|
||||
new LayerGroupAnimate(),
|
||||
new FloorViewport()
|
||||
];
|
||||
const eventExtends: ILayerRenderExtends[] = [
|
||||
new HeroRenderer(),
|
||||
new LayerDoorAnimate()
|
||||
];
|
||||
const mainTextboxProps: Props<typeof Textbox> = {
|
||||
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 map = shallowRef<LayerGroup>();
|
||||
const hideStatus = ref(false);
|
||||
const locked = ref(false);
|
||||
const weather = new WeatherController();
|
||||
weather.extern('main');
|
||||
|
||||
onMounted(() => {
|
||||
if (map.value) {
|
||||
weather.bind(map.value);
|
||||
}
|
||||
});
|
||||
|
||||
const replayStatus: ReplayingStatus = reactive({
|
||||
replaying: false,
|
||||
playing: false,
|
||||
speed: 1,
|
||||
played: 0,
|
||||
total: 0
|
||||
});
|
||||
const leftStatus: ILeftHeroStatus = reactive({
|
||||
hp: 0,
|
||||
hpmax: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
atk: 0,
|
||||
def: 0,
|
||||
mdef: 0,
|
||||
money: 0,
|
||||
exp: 0,
|
||||
up: 0,
|
||||
yellowKey: 0,
|
||||
blueKey: 0,
|
||||
redKey: 0,
|
||||
greenKey: 0,
|
||||
pickaxe: 0,
|
||||
bomb: 0,
|
||||
centerFly: 0,
|
||||
poison: false,
|
||||
weak: false,
|
||||
curse: false,
|
||||
floor: 'MT0',
|
||||
lv: '',
|
||||
replay: replayStatus
|
||||
});
|
||||
const rightStatus: IRightHeroStatus = reactive({
|
||||
exampleHard: 0
|
||||
});
|
||||
|
||||
//#region 状态更新
|
||||
const updateStatus = () => {
|
||||
if (!core.status || !core.status.hero || !core.status.floorId) return;
|
||||
hideStatus.value = core.getFlag('hideStatusBar', false);
|
||||
|
||||
const hero = core.status.hero;
|
||||
leftStatus.atk = getHeroStatusOn('atk');
|
||||
leftStatus.hp = getHeroStatusOn('hp');
|
||||
leftStatus.hpmax = getHeroStatusOn('hpmax');
|
||||
leftStatus.mana = getHeroStatusOn('mana');
|
||||
leftStatus.manamax = getHeroStatusOn('manamax');
|
||||
leftStatus.def = getHeroStatusOn('def');
|
||||
leftStatus.mdef = getHeroStatusOn('mdef');
|
||||
leftStatus.money = getHeroStatusOn('money');
|
||||
leftStatus.exp = getHeroStatusOn('exp');
|
||||
leftStatus.up = core.getNextLvUpNeed() ?? 0;
|
||||
leftStatus.yellowKey = core.itemCount('yellowKey');
|
||||
leftStatus.blueKey = core.itemCount('blueKey');
|
||||
leftStatus.redKey = core.itemCount('redKey');
|
||||
leftStatus.greenKey = core.itemCount('greenKey');
|
||||
leftStatus.pickaxe = core.itemCount('pickaxe');
|
||||
leftStatus.bomb = core.itemCount('bomb');
|
||||
leftStatus.centerFly = core.itemCount('centerFly');
|
||||
leftStatus.poison = core.getFlag('poison', true);
|
||||
leftStatus.weak = core.getFlag('weak', true);
|
||||
leftStatus.curse = core.getFlag('curse', true);
|
||||
leftStatus.floor = core.status.floorId;
|
||||
leftStatus.lv = core.getLvName(hero.lv);
|
||||
|
||||
const { pausing, speed, toReplay, totalList } = core.status.replay;
|
||||
replayStatus.replaying = core.isReplaying();
|
||||
replayStatus.playing = !pausing;
|
||||
replayStatus.speed = speed;
|
||||
replayStatus.played = totalList.length - toReplay.length;
|
||||
replayStatus.total = totalList.length;
|
||||
|
||||
rightStatus.exampleHard = flags.hard;
|
||||
};
|
||||
|
||||
const updateDataFallback = () => {
|
||||
// 更新 locked 状态
|
||||
locked.value = core.status.lockControl;
|
||||
};
|
||||
|
||||
// 监听状态栏更新事件
|
||||
hook.on('statusBarUpdate', updateStatus);
|
||||
hook.on('statusBarUpdate', updateDataFallback);
|
||||
|
||||
onUnmounted(() => {
|
||||
hook.off('statusBarUpdate', updateStatus);
|
||||
hook.off('statusBarUpdate', updateDataFallback);
|
||||
});
|
||||
|
||||
//#region sprite 渲染
|
||||
|
||||
let lastLength = 0;
|
||||
onTick(() => {
|
||||
const len = core.status.stepPostfix?.length ?? 0;
|
||||
if (len !== lastLength) {
|
||||
mapMiscSprite.value?.update();
|
||||
lastLength = len;
|
||||
}
|
||||
});
|
||||
|
||||
const mapMiscSprite = ref<Sprite>();
|
||||
|
||||
const renderMapMisc = (canvas: MotaOffscreenCanvas2D) => {
|
||||
const step = core.status.stepPostfix;
|
||||
const camera = map.value?.camera;
|
||||
if (!step || !camera) return;
|
||||
transformCanvas(canvas, camera);
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//#region 交互监听
|
||||
|
||||
/**
|
||||
* 对于 registerAction 的 fallback
|
||||
*/
|
||||
const clickMap = (ev: IActionEvent) => {
|
||||
const bx = Math.floor(ev.offsetX / 32);
|
||||
const by = Math.floor(ev.offsetY / 32);
|
||||
core.doRegisteredAction('onup', bx, by, ev.offsetX, ev.offsetY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 对于 registerAction 的 fallback
|
||||
*/
|
||||
const downMap = (ev: IActionEvent) => {
|
||||
const bx = Math.floor(ev.offsetX / 32);
|
||||
const by = Math.floor(ev.offsetY / 32);
|
||||
core.doRegisteredAction('ondown', bx, by, ev.offsetX, ev.offsetY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 对于 registerAction 的 fallback
|
||||
*/
|
||||
const moveMap = (ev: IActionEvent) => {
|
||||
const bx = Math.floor(ev.offsetX / 32);
|
||||
const by = Math.floor(ev.offsetY / 32);
|
||||
core.doRegisteredAction('onmove', bx, by, ev.offsetX, ev.offsetY);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container
|
||||
id="main-scene"
|
||||
width={MAIN_WIDTH}
|
||||
height={MAIN_HEIGHT}
|
||||
noanti
|
||||
nocache
|
||||
>
|
||||
<LeftStatusBar
|
||||
loc={[0, 0, STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT]}
|
||||
status={leftStatus}
|
||||
hidden={hideStatus.value}
|
||||
></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}
|
||||
onClick={clickMap}
|
||||
onDown={downMap}
|
||||
onMove={moveMap}
|
||||
noanti
|
||||
>
|
||||
<layer-group id="layer-main" ex={layerGroupExtends} ref={map}>
|
||||
<layer layer="bg" zIndex={10}></layer>
|
||||
<layer layer="bg2" zIndex={20}></layer>
|
||||
<layer layer="event" zIndex={30} ex={eventExtends}></layer>
|
||||
<layer layer="fg" zIndex={40}></layer>
|
||||
<layer layer="fg2" zIndex={50}></layer>
|
||||
</layer-group>
|
||||
<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}
|
||||
/>
|
||||
<sprite
|
||||
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]}
|
||||
status={rightStatus}
|
||||
hidden={hideStatus.value && ENABLE_RIGHT_STATUS_BAR}
|
||||
></RightStatusBar>
|
||||
<container
|
||||
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
|
||||
hidden={!mainUIController.active.value}
|
||||
zIndex={200}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
||||
export const MainSceneUI = new GameUI('main-scene', MainScene);
|
||||
@@ -0,0 +1,608 @@
|
||||
import { ElementLocator, IWheelEvent } from '@motajs/render-core';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { Font } from '@motajs/render';
|
||||
import {
|
||||
GameUI,
|
||||
IUIMountable,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps
|
||||
} from '@motajs/system-ui';
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
shallowReactive
|
||||
} from 'vue';
|
||||
import { getConfirm, Page, PageExpose, Thumbnail } from '../components';
|
||||
import { useKey } from '../use';
|
||||
import {
|
||||
HALF_HEIGHT,
|
||||
HALF_WIDTH,
|
||||
MAP_WIDTH,
|
||||
POP_BOX_WIDTH,
|
||||
SAVE_DOWN_PAD,
|
||||
SAVE_INTERVAL,
|
||||
SAVE_ITEM_DOWN,
|
||||
SAVE_ITEM_HEIGHT,
|
||||
SAVE_ITEM_SIZE,
|
||||
SAVE_ITEM_TOP,
|
||||
SAVE_PAGES
|
||||
} from '../shared';
|
||||
import { getSave, SaveData, adjustGrid, IGridLayoutData } from '../utils';
|
||||
|
||||
export const enum SaveMode {
|
||||
Save,
|
||||
Load,
|
||||
Other
|
||||
}
|
||||
|
||||
export interface SaveProps extends UIComponentProps, 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 - 1);
|
||||
selected.value = startIndex;
|
||||
pageRef.value?.changePage(
|
||||
Math.floor((core.saves.saveIndex - 1) / (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 - 1) {
|
||||
selected.value++;
|
||||
} else {
|
||||
selected.value = 1;
|
||||
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: IUIMountable,
|
||||
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 {
|
||||
core.drawTip(validation.message);
|
||||
}
|
||||
},
|
||||
onDelete: (index: number, exist: boolean) => {
|
||||
if (!validate) return;
|
||||
const validation = validateDelete(index, exist);
|
||||
if (validation.valid) {
|
||||
core.removeSave(index);
|
||||
} else {
|
||||
core.drawTip(validation.message);
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
res(-2);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSave(
|
||||
controller: IUIMountable,
|
||||
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: IUIMountable,
|
||||
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: IUIMountable,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import { ElementLocator } from '@motajs/render';
|
||||
import {
|
||||
GameUI,
|
||||
IUIMountable,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps
|
||||
} from '@motajs/system-ui';
|
||||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
ChoiceItem,
|
||||
ChoiceKey,
|
||||
Choices,
|
||||
ChoicesProps,
|
||||
getConfirm,
|
||||
waitbox
|
||||
} from '../components';
|
||||
import { mainUi } from '@motajs/legacy-ui';
|
||||
import { gameKey } from '@motajs/system-action';
|
||||
import { generateKeyboardEvent } from '@motajs/system-action';
|
||||
import { getVitualKeyOnce } from '@motajs/legacy-ui';
|
||||
import { getAllSavesData, getSaveData, syncFromServer } from '../utils';
|
||||
import { getInput } from '../components';
|
||||
import { saveWithExist } from './save';
|
||||
import { compressToBase64 } from 'lz-string';
|
||||
import { ViewMapUI } from './viewmap';
|
||||
import { CENTER_LOC, FULL_LOC, MAIN_HEIGHT, POP_BOX_WIDTH } from '../shared';
|
||||
import { useKey } from '../use';
|
||||
|
||||
export interface MainSettingsProps
|
||||
extends Partial<ChoicesProps>,
|
||||
UIComponentProps {
|
||||
loc: ElementLocator;
|
||||
}
|
||||
|
||||
const mainSettingsProps = {
|
||||
props: ['loc', 'controller', 'instance']
|
||||
} 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 [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: {
|
||||
props.controller.open(ViewMapUI, {
|
||||
loc: FULL_LOC
|
||||
});
|
||||
break;
|
||||
}
|
||||
case MainChoice.Replay: {
|
||||
props.controller.open(ReplaySettingsUI, { loc: props.loc });
|
||||
break;
|
||||
}
|
||||
case MainChoice.SyncSave: {
|
||||
props.controller.open(SyncSaveUI, { loc: props.loc });
|
||||
break;
|
||||
}
|
||||
case MainChoice.GameInfo: {
|
||||
props.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 - 32}
|
||||
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();
|
||||
const seed = core.getFlag<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: {
|
||||
core.download(
|
||||
core.firstData.name + '_' + core.formatDate2() + '.h5route',
|
||||
compressToBase64(
|
||||
JSON.stringify({
|
||||
name: core.firstData.name,
|
||||
hard: core.status.hard,
|
||||
seed: core.getFlag('__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}
|
||||
maxHeight={MAIN_HEIGHT - 32}
|
||||
/>
|
||||
);
|
||||
}, 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: {
|
||||
getConfirm(
|
||||
props.controller,
|
||||
'数据统计尚未完工',
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
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 [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
case SyncSaveChoice.ToServer: {
|
||||
props.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: {
|
||||
props.controller.open(DownloadSaveSelectUI, { loc: props.loc });
|
||||
break;
|
||||
}
|
||||
case SyncSaveChoice.FromLocal: {
|
||||
// todo
|
||||
break;
|
||||
}
|
||||
case SyncSaveChoice.ClearLocal: {
|
||||
props.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: {
|
||||
core.playSound('confirm.opus');
|
||||
const confirm = await getConfirm(
|
||||
props.controller,
|
||||
'你确定要同步全部存档么?这可能在存档较多的时候比较慢。',
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
if (confirm) {
|
||||
core.syncSave('all');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SyncSaveChoice.NowSave: {
|
||||
core.playSound('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: IUIMountable,
|
||||
loc: ElementLocator,
|
||||
props?: MainSettingsProps
|
||||
) {
|
||||
controller.open(MainSettingsUI, {
|
||||
...props,
|
||||
loc
|
||||
});
|
||||
}
|
||||
|
||||
export function openReplay(
|
||||
controller: IUIMountable,
|
||||
loc: ElementLocator,
|
||||
props?: MainSettingsProps
|
||||
) {
|
||||
controller.open(ReplaySettingsUI, {
|
||||
...props,
|
||||
loc
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
GameUI,
|
||||
IUIMountable,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps
|
||||
} from '@motajs/system-ui';
|
||||
import { defineComponent } from 'vue';
|
||||
import { waitbox, ListPage, TextContent } from '../components';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
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 UIComponentProps, 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: IUIMountable) {
|
||||
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);
|
||||
@@ -0,0 +1,490 @@
|
||||
import { GameUI, SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { computed, ComputedRef, defineComponent, shallowReactive } from 'vue';
|
||||
import { TextContent } from '../components';
|
||||
import {
|
||||
DefaultProps,
|
||||
ElementLocator,
|
||||
Font,
|
||||
SizedCanvasImageSource
|
||||
} from '@motajs/render';
|
||||
import { MixedToolbar, ReplayingStatus } from './toolbar';
|
||||
import { openViewMap } from './viewmap';
|
||||
import { mainUIController } from './controller';
|
||||
import {
|
||||
MAIN_HEIGHT,
|
||||
MAIN_WIDTH,
|
||||
STATUS_BAR_HEIGHT,
|
||||
STATUS_BAR_WIDTH
|
||||
} from '../shared';
|
||||
|
||||
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 {
|
||||
/** 图标 */
|
||||
icon: SizedCanvasImageSource;
|
||||
/** 属性值,经过格式化 */
|
||||
value: ComputedRef<string>;
|
||||
/** 字体 */
|
||||
font: Font;
|
||||
/** 文字颜色 */
|
||||
color: CanvasStyle;
|
||||
}
|
||||
|
||||
interface KeyLikeItem {
|
||||
/** 属性值,经过格式化 */
|
||||
value: ComputedRef<string>;
|
||||
/** 字体 */
|
||||
font: Font;
|
||||
/** 文字颜色 */
|
||||
color: CanvasStyle;
|
||||
}
|
||||
|
||||
interface KeyLikeInfo {
|
||||
/** 这一行包含的内容 */
|
||||
items: KeyLikeItem[];
|
||||
}
|
||||
|
||||
interface StatusBarProps<T> extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
status: T;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
const statusBarProps = {
|
||||
props: ['loc', 'status', 'hidden']
|
||||
} satisfies SetupComponentOptions<StatusBarProps<unknown>>;
|
||||
|
||||
export const LeftStatusBar = defineComponent<StatusBarProps<ILeftHeroStatus>>(
|
||||
p => {
|
||||
//#region 参数定义
|
||||
|
||||
/** 属性文字的横坐标 */
|
||||
const TEXT_X = 54;
|
||||
/** 图标的横坐标 */
|
||||
const STATUS_PAD = 8;
|
||||
/** 楼层名称的高度 */
|
||||
const TITLE_HEIGHT = 36;
|
||||
/** 状态属性的开始纵坐标 */
|
||||
const STATUS_Y = TITLE_HEIGHT + STATUS_PAD;
|
||||
|
||||
// 可以换成 core.material.images.images['xxx.png'] 来使用全塔属性注册的图片
|
||||
const hpIcon = core.statusBar.icons.hp;
|
||||
const atkIcon = core.statusBar.icons.atk;
|
||||
const defIcon = core.statusBar.icons.def;
|
||||
const mdefIcon = core.statusBar.icons.mdef;
|
||||
const moneyIcon = core.statusBar.icons.money;
|
||||
const expIcon = core.statusBar.icons.exp;
|
||||
const manaIcon = core.statusBar.icons.mana;
|
||||
const lvIcon = core.statusBar.icons.lv;
|
||||
|
||||
const s = p.status;
|
||||
|
||||
/** 常规字体 */
|
||||
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(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}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
},
|
||||
statusBarProps
|
||||
);
|
||||
|
||||
export const RightStatusBar = defineComponent<StatusBarProps<IRightHeroStatus>>(
|
||||
p => {
|
||||
// p.status 就是你在 main.tsx 中传入的属性内容,用法与左侧状态栏完全一致
|
||||
|
||||
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={`游戏难度:${p.status.exampleHard}`}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
};
|
||||
},
|
||||
statusBarProps
|
||||
);
|
||||
|
||||
export const leftStatusBarUI = new GameUI('left-status-bar', LeftStatusBar);
|
||||
export const rightStatusBarUI = new GameUI('right-status-bar', RightStatusBar);
|
||||
@@ -0,0 +1,465 @@
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import {
|
||||
GameUI,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps
|
||||
} from '@motajs/system-ui';
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue';
|
||||
import {
|
||||
BUTTONS_HEIGHT,
|
||||
BUTTONS_WIDTH,
|
||||
BUTTONS_X,
|
||||
BUTTONS_Y,
|
||||
HALF_HEIGHT,
|
||||
HALF_WIDTH,
|
||||
MAIN_HEIGHT,
|
||||
MAIN_WIDTH,
|
||||
TITLE_FILL,
|
||||
TITLE_STROKE,
|
||||
TITLE_STROKE_WIDTH,
|
||||
TITLE_X,
|
||||
TITLE_Y
|
||||
} from '../shared';
|
||||
import { ElementLocator } from '@motajs/render-core';
|
||||
import {
|
||||
ITransitionedController,
|
||||
transitioned,
|
||||
transitionedColor,
|
||||
useKey
|
||||
} from '../use';
|
||||
import { hyper, linear, sleep } from 'mutate-animate';
|
||||
import { Font } from '@motajs/render-style';
|
||||
import { ExitFullscreen, Fullscreen, SoundVolume } from '../components';
|
||||
import { mainSetting, triggerFullscreen } from '@motajs/legacy-ui';
|
||||
import { saveLoad } from './save';
|
||||
import { MainSceneUI } from './main';
|
||||
import { adjustCover } from '../utils';
|
||||
|
||||
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, UIComponentProps {}
|
||||
|
||||
const gameTitleProps = {
|
||||
props: ['controller', 'instance']
|
||||
} satisfies SetupComponentOptions<GameTitleProps>;
|
||||
|
||||
export const GameTitle = defineComponent<GameTitleProps>(props => {
|
||||
const bg = core.material.images.images['bg.jpg'];
|
||||
|
||||
//#region 计算背景图
|
||||
const [width, height] = adjustCover(
|
||||
bg.width,
|
||||
bg.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, hyper('sin', 'out'))!,
|
||||
scale: transitioned(1, 400, hyper('sin', 'out'))!
|
||||
};
|
||||
});
|
||||
|
||||
/** 选择难度界面按钮 */
|
||||
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, hyper('sin', 'out'))!,
|
||||
scale: transitioned(1, 400, hyper('sin', 'out'))!
|
||||
};
|
||||
});
|
||||
// 返回按钮
|
||||
hard.push({
|
||||
code: -1,
|
||||
color: '#aaa',
|
||||
name: '返回',
|
||||
hard: '',
|
||||
colorTrans: transitionedColor('#fff', 400, hyper('sin', 'out'))!
|
||||
});
|
||||
|
||||
/** 声音设置按钮的颜色 */
|
||||
const soundColor = transitionedColor('#ddd', 400, hyper('sin', 'out'))!;
|
||||
|
||||
/** 开始界面按钮的不透明度,选择难度界面的不透明度使用 `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, hyper('sin', 'in-out'))!;
|
||||
|
||||
//#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);
|
||||
@@ -0,0 +1,400 @@
|
||||
import { DefaultProps, ElementLocator, Font } from '@motajs/render';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import {
|
||||
DoubleArrow,
|
||||
NumpadIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
ReplayIcon,
|
||||
RetweetIcon,
|
||||
RollbackIcon,
|
||||
StepForward,
|
||||
ViewMapIcon
|
||||
} from '../components/icons';
|
||||
import { getVitualKeyOnce } from '@motajs/legacy-ui';
|
||||
import { gameKey } from '@motajs/system-action';
|
||||
import { generateKeyboardEvent } from '@motajs/system-action';
|
||||
import { transitioned } from '../use';
|
||||
import { linear } from 'mutate-animate';
|
||||
import { KeyCode } from '@motajs/client-base';
|
||||
import { Progress } from '../components/misc';
|
||||
import { generateBinary } from '@motajs/legacy-common';
|
||||
import { SetupComponentOptions } from '@motajs/system-ui';
|
||||
import { saveSave, saveLoad } from './save';
|
||||
import { mainUIController } from './controller';
|
||||
import { MAIN_HEIGHT, FULL_LOC, POP_BOX_WIDTH, CENTER_LOC } from '../shared';
|
||||
import { openReplay, openSettings } from './settings';
|
||||
import { openViewMap } from './viewmap';
|
||||
|
||||
interface ToolbarProps extends DefaultProps {
|
||||
loc?: ElementLocator;
|
||||
}
|
||||
|
||||
type ToolbarEmits = {
|
||||
numpad: () => void;
|
||||
};
|
||||
|
||||
const toolbarProps = {
|
||||
props: ['loc'],
|
||||
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 bookIcon = core.statusBar.icons.book;
|
||||
const flyIcon = core.statusBar.icons.fly;
|
||||
const toolIcon = core.statusBar.icons.toolbox;
|
||||
const equipIcon = core.statusBar.icons.equipbox;
|
||||
const keyIcon = core.statusBar.icons.keyboard;
|
||||
const shopIcon = core.statusBar.icons.shop;
|
||||
const saveIcon = core.statusBar.icons.save;
|
||||
const loadIcon = core.statusBar.icons.load;
|
||||
const setIcon = core.statusBar.icons.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(mainUIController, FULL_LOC);
|
||||
};
|
||||
const load = () => {
|
||||
saveLoad(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(mainUIController, FULL_LOC);
|
||||
};
|
||||
const replay = () => {
|
||||
const loc = CENTER_LOC.slice() as ElementLocator;
|
||||
loc[2] = POP_BOX_WIDTH;
|
||||
openReplay(mainUIController, loc);
|
||||
};
|
||||
const settings = () => {
|
||||
const loc = CENTER_LOC.slice() as ElementLocator;
|
||||
loc[2] = POP_BOX_WIDTH;
|
||||
loc[3] = MAIN_HEIGHT - 72;
|
||||
openSettings(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']
|
||||
} satisfies SetupComponentOptions<ReplayingProps>;
|
||||
|
||||
export const ReplayingToolbar = defineComponent<ReplayingProps>(props => {
|
||||
const status = props.status;
|
||||
|
||||
const bookIcon = core.statusBar.icons.book;
|
||||
const saveIcon = core.statusBar.icons.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(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} />
|
||||
) : props.status.replaying ? (
|
||||
<ReplayingToolbar loc={props.loc} status={props.status} />
|
||||
) : (
|
||||
<PlayingToolbar loc={props.loc} onNumpad={onNumpad} />
|
||||
);
|
||||
}, replayingProps);
|
||||
@@ -0,0 +1,576 @@
|
||||
import {
|
||||
ElementLocator,
|
||||
IActionEvent,
|
||||
IActionEventBase,
|
||||
IWheelEvent,
|
||||
MotaOffscreenCanvas2D
|
||||
} from '@motajs/render-core';
|
||||
import { BaseProps } from '@motajs/render-vue';
|
||||
import {
|
||||
GameUI,
|
||||
IUIMountable,
|
||||
SetupComponentOptions,
|
||||
UIComponentProps
|
||||
} from '@motajs/system-ui';
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
markRaw,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch
|
||||
} from 'vue';
|
||||
import { FloorSelector } from '../components/floorSelect';
|
||||
import {
|
||||
ILayerGroupRenderExtends,
|
||||
FloorDamageExtends,
|
||||
FloorItemDetail,
|
||||
LayerGroupAnimate,
|
||||
LayerGroup,
|
||||
LayerGroupFloorBinder
|
||||
} from '../elements';
|
||||
import { Font } from '@motajs/render-style';
|
||||
import { clamp, mean } from 'lodash-es';
|
||||
import { calculateStatisticsOne, StatisticsDataOneFloor } from './statistics';
|
||||
import { Tip, TipExpose } from '../components';
|
||||
import { useKey } from '../use';
|
||||
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 '../shared';
|
||||
|
||||
export interface ViewMapProps extends UIComponentProps, 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}
|
||||
/>
|
||||
<sprite
|
||||
loc={[STATUS_BAR_WIDTH, 0, MAP_WIDTH, 64]}
|
||||
render={renderTop}
|
||||
alpha={topAlpha.value}
|
||||
zIndex={10}
|
||||
cursor="pointer"
|
||||
onEnter={enterTop}
|
||||
onLeave={leaveTop}
|
||||
onClick={clickTop}
|
||||
/>
|
||||
<sprite
|
||||
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: IUIMountable,
|
||||
loc: ElementLocator,
|
||||
props?: ViewMapProps
|
||||
) {
|
||||
controller.open(ViewMapUI, {
|
||||
...props,
|
||||
loc,
|
||||
floorId: core.status.floorId
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Hotkey, gameKey } from '@motajs/system-action';
|
||||
import { loading } from '@user/data-base';
|
||||
import { TimingFn, Transition } from 'mutate-animate';
|
||||
import {
|
||||
ComponentInternalInstance,
|
||||
getCurrentInstance,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
Ref
|
||||
} from 'vue';
|
||||
|
||||
export const enum Orientation {
|
||||
/** 横屏 */
|
||||
Landscape,
|
||||
/** 竖屏 */
|
||||
Portrait
|
||||
}
|
||||
|
||||
export type OrientationHook = (
|
||||
orientation: Orientation,
|
||||
width: number,
|
||||
height: number
|
||||
) => void;
|
||||
|
||||
let nowOrientation = Orientation.Landscape;
|
||||
const orientationHooks = new Set<OrientationHook>();
|
||||
|
||||
function checkOrientation() {
|
||||
const before = nowOrientation;
|
||||
// 只要宽度大于高度,那么就视为横屏
|
||||
if (window.innerWidth >= window.innerHeight) {
|
||||
nowOrientation = Orientation.Landscape;
|
||||
} else {
|
||||
nowOrientation = Orientation.Portrait;
|
||||
}
|
||||
if (nowOrientation === before) return;
|
||||
|
||||
orientationHooks.forEach(v => {
|
||||
v(nowOrientation, window.innerWidth, window.innerHeight);
|
||||
});
|
||||
}
|
||||
window.addEventListener('resize', checkOrientation);
|
||||
|
||||
/**
|
||||
* 当屏幕方向改变时执行函数
|
||||
* @param hook 当屏幕方向改变时执行的函数
|
||||
*/
|
||||
export function onOrientationChange(hook: OrientationHook) {
|
||||
onMounted(() => {
|
||||
orientationHooks.add(hook);
|
||||
hook(nowOrientation, window.innerWidth, window.innerHeight);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
orientationHooks.delete(hook);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 当游戏加载完成时执行函数,如果调用此函数时游戏已经加载,那么会立刻调用传入的钩子函数
|
||||
* @param hook 当游戏加载完成时执行的函数
|
||||
*/
|
||||
export function onLoaded(hook: () => void) {
|
||||
if (!loading.loaded) {
|
||||
loading.once('loaded', hook);
|
||||
} else {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
type KeyUsing = [Hotkey, symbol];
|
||||
|
||||
/**
|
||||
* 在组件中定义按键操作
|
||||
* @param noScope 是否不创建新作用域
|
||||
* @param scope 指定作用域,如果 `noScope` 为 `true`,则此项无效
|
||||
*/
|
||||
export function useKey(noScope: boolean = false, scope?: symbol): KeyUsing {
|
||||
if (noScope) {
|
||||
return [gameKey, gameKey.scope];
|
||||
} else {
|
||||
const sym = scope ?? Symbol();
|
||||
if (sym === gameKey.scope) {
|
||||
return [gameKey, gameKey.scope];
|
||||
} else {
|
||||
gameKey.use(sym);
|
||||
onUnmounted(() => {
|
||||
gameKey.dispose();
|
||||
});
|
||||
return [gameKey, sym];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ITransitionedController<T> {
|
||||
readonly ref: Ref<T>;
|
||||
readonly value: T;
|
||||
|
||||
/**
|
||||
* 执行动画,使当前值缓慢变化至目标值
|
||||
* @param value 目标值
|
||||
* @param time 动画时长
|
||||
*/
|
||||
set(value: T, time?: number): void;
|
||||
|
||||
/**
|
||||
* 设置动画的速率曲线
|
||||
* @param timing 速率曲线
|
||||
*/
|
||||
mode(timing: TimingFn): void;
|
||||
|
||||
/**
|
||||
* 设置动画的动画时长
|
||||
* @param time 动画时长
|
||||
*/
|
||||
setTime(time: number): void;
|
||||
}
|
||||
|
||||
class RenderTransition implements ITransitionedController<number> {
|
||||
private static key: number = 0;
|
||||
|
||||
private readonly key: string = `$${RenderTransition.key++}`;
|
||||
|
||||
public readonly ref: Ref<number>;
|
||||
|
||||
set value(v: number) {
|
||||
this.transition.transition(this.key, v);
|
||||
}
|
||||
get value() {
|
||||
return this.transition.value[this.key];
|
||||
}
|
||||
|
||||
constructor(
|
||||
value: number,
|
||||
public readonly transition: Transition,
|
||||
public time: number,
|
||||
public curve: TimingFn
|
||||
) {
|
||||
this.ref = ref(value);
|
||||
transition.value[this.key] = value;
|
||||
transition.ticker.add(() => {
|
||||
this.ref.value = transition.value[this.key];
|
||||
});
|
||||
}
|
||||
|
||||
set(value: number, time: number = this.time): void {
|
||||
this.transition.time(time).mode(this.curve).transition(this.key, value);
|
||||
}
|
||||
|
||||
mode(timing: TimingFn): void {
|
||||
this.curve = timing;
|
||||
}
|
||||
|
||||
setTime(time: number): void {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
|
||||
type ColorRGBA = [number, number, number, number];
|
||||
|
||||
class RenderColorTransition implements ITransitionedController<string> {
|
||||
private static key: number = 0;
|
||||
|
||||
private readonly keyR: string = `$colorR${RenderColorTransition.key++}`;
|
||||
private readonly keyG: string = `$colorG${RenderColorTransition.key++}`;
|
||||
private readonly keyB: string = `$colorB${RenderColorTransition.key++}`;
|
||||
private readonly keyA: string = `$colorA${RenderColorTransition.key++}`;
|
||||
|
||||
public readonly ref: Ref<string>;
|
||||
|
||||
set value(v: string) {
|
||||
this.set(v);
|
||||
}
|
||||
get value() {
|
||||
return this.encodeColor();
|
||||
}
|
||||
|
||||
constructor(
|
||||
value: string,
|
||||
public readonly transition: Transition,
|
||||
public time: number,
|
||||
public curve: TimingFn
|
||||
) {
|
||||
this.ref = ref(value);
|
||||
const [r, g, b, a] = this.decodeColor(value);
|
||||
transition.value[this.keyR] = r;
|
||||
transition.value[this.keyG] = g;
|
||||
transition.value[this.keyB] = b;
|
||||
transition.value[this.keyA] = a;
|
||||
transition.ticker.add(() => {
|
||||
this.ref.value = this.encodeColor();
|
||||
});
|
||||
}
|
||||
|
||||
set(value: string, time: number = this.time): void {
|
||||
this.transitionColor(this.decodeColor(value), time);
|
||||
}
|
||||
|
||||
mode(timing: TimingFn): void {
|
||||
this.curve = timing;
|
||||
}
|
||||
|
||||
setTime(time: number): void {
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
private transitionColor([r, g, b, a]: ColorRGBA, time: number) {
|
||||
this.transition
|
||||
.mode(this.curve)
|
||||
.time(time)
|
||||
.transition(this.keyR, r)
|
||||
.transition(this.keyG, g)
|
||||
.transition(this.keyB, b)
|
||||
.transition(this.keyA, a);
|
||||
}
|
||||
|
||||
private decodeColor(color: string): ColorRGBA {
|
||||
if (color.startsWith('#')) {
|
||||
return this.decodeHash(color);
|
||||
} else if (color.startsWith('rgba')) {
|
||||
return this.decodeRGBA(color);
|
||||
} else if (color.startsWith('rgb')) {
|
||||
return this.decodeRGB(color);
|
||||
}
|
||||
return [0, 0, 0, 1];
|
||||
}
|
||||
|
||||
private decodeHash(color: string): ColorRGBA {
|
||||
switch (color.length) {
|
||||
case 4:
|
||||
return [
|
||||
Number(`0x${color.slice(1, 2).repeat(2)}`),
|
||||
Number(`0x${color.slice(2, 3).repeat(2)}`),
|
||||
Number(`0x${color.slice(3, 4).repeat(2)}`),
|
||||
1
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
Number(`0x${color.slice(1, 2).repeat(2)}`),
|
||||
Number(`0x${color.slice(2, 3).repeat(2)}`),
|
||||
Number(`0x${color.slice(3, 4).repeat(2)}`),
|
||||
Number(`0x${color.slice(4, 5).repeat(2)}`)
|
||||
];
|
||||
case 7:
|
||||
return [
|
||||
Number(`0x${color.slice(1, 3)}`),
|
||||
Number(`0x${color.slice(3, 5)}`),
|
||||
Number(`0x${color.slice(5, 7)}`),
|
||||
1
|
||||
];
|
||||
case 9:
|
||||
return [
|
||||
Number(`0x${color.slice(1, 3)}`),
|
||||
Number(`0x${color.slice(3, 5)}`),
|
||||
Number(`0x${color.slice(5, 7)}`),
|
||||
Number(`0x${color.slice(7, 9)}`)
|
||||
];
|
||||
}
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private decodeRGBA(color: string): ColorRGBA {
|
||||
const data = color.slice(color.indexOf('(') + 1, color.indexOf(')'));
|
||||
const [r, g, b, a] = data.split(',');
|
||||
return [Number(r), Number(g), Number(b), Number(a)];
|
||||
}
|
||||
|
||||
private decodeRGB(color: string): ColorRGBA {
|
||||
const data = color.slice(color.indexOf('(') + 1, color.indexOf(')'));
|
||||
const [r, g, b] = data.split(',');
|
||||
return [Number(r), Number(g), Number(b), 1];
|
||||
}
|
||||
|
||||
private encodeColor() {
|
||||
const r = this.transition.value[this.keyR];
|
||||
const g = this.transition.value[this.keyG];
|
||||
const b = this.transition.value[this.keyB];
|
||||
const a = this.transition.value[this.keyA];
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
}
|
||||
|
||||
const transitionMap = new Map<ComponentInternalInstance, Transition>();
|
||||
|
||||
function checkTransition() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) return null;
|
||||
if (instance.isUnmounted) {
|
||||
const tran = transitionMap.get(instance);
|
||||
tran?.ticker.destroy();
|
||||
transitionMap.delete(instance);
|
||||
return null;
|
||||
}
|
||||
if (!transitionMap.has(instance)) {
|
||||
const tran = new Transition();
|
||||
transitionMap.set(instance, tran);
|
||||
onUnmounted(() => {
|
||||
transitionMap.delete(instance);
|
||||
tran.ticker.destroy();
|
||||
});
|
||||
}
|
||||
const tran = transitionMap.get(instance);
|
||||
if (!tran) return null;
|
||||
return tran;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个渐变数值,当数值发生变化时会缓慢变化值目标值而非突变,只可以用于组件内,不可用于组件外
|
||||
* ```tsx
|
||||
* const value = transitioned(10, 300, linear()); // 创建渐变,初始值为 10,渐变时长 300ms,线性变化
|
||||
* value.set(100); // 渐变至 100
|
||||
*
|
||||
* // 直接在元素上使用
|
||||
* <text text={value.ref.value} />
|
||||
* ```
|
||||
* @param value 初始值
|
||||
* @param time 渐变时长
|
||||
* @param curve 渐变的速率曲线
|
||||
*/
|
||||
export function transitioned(
|
||||
value: number,
|
||||
time: number,
|
||||
curve: TimingFn
|
||||
): ITransitionedController<number> | null {
|
||||
const tran = checkTransition();
|
||||
if (!tran) return null;
|
||||
return new RenderTransition(value, tran, time, curve);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个渐变颜色,当颜色发生变化时会缓慢变化值目标值而非突变,自动判断颜色字符串类型,只可用于组件内,不可用于组件外
|
||||
* ```tsx
|
||||
* // 创建渐变,初始值为 '#fff'(白色),渐变时长 300ms,线性变化
|
||||
* const color = transitionedColor('#fff', 300, linear());
|
||||
* color.set('rgba(129, 30, 40, 0.7)'); // 渐变至这个颜色
|
||||
*
|
||||
* // 直接在元素上使用
|
||||
* <text text='文本' fillStyle={color.ref.value} />
|
||||
* ```
|
||||
* @param color 颜色的初始值
|
||||
* @param time 渐变时长
|
||||
* @param curve 渐变的速率曲线
|
||||
*/
|
||||
export function transitionedColor(
|
||||
color: string,
|
||||
time: number,
|
||||
curve: TimingFn
|
||||
): ITransitionedController<string> | null {
|
||||
const tran = checkTransition();
|
||||
if (!tran) return null;
|
||||
return new RenderColorTransition(color, tran, time, curve);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './layout';
|
||||
export * from './saves';
|
||||
export * from './use';
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ElementLocator } from '@motajs/render-core';
|
||||
|
||||
export interface IGridLayoutData {
|
||||
/** 有多少列 */
|
||||
readonly cols: number;
|
||||
/** 有多少行 */
|
||||
readonly rows: number;
|
||||
/** 去除余留部分后的宽度 */
|
||||
readonly width: number;
|
||||
/** 去除预留部分后的高度 */
|
||||
readonly height: number;
|
||||
/** 元素总数量 */
|
||||
readonly count: number;
|
||||
/** 每个元素的定位,按照从左到右,从左上下的顺序排列,包含所有六个元素 */
|
||||
readonly locs: readonly ElementLocator[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 网格布局
|
||||
* @param width 总宽度
|
||||
* @param height 总高度
|
||||
* @param itemWidth 每个元素的宽度
|
||||
* @param itemHeight 每个元素的高度
|
||||
* @param intervalX 元素的横向间隔
|
||||
* @param intervalY 元素的纵向间隔,不填时等于横向间隔
|
||||
* @returns 网格布局信息
|
||||
*/
|
||||
export function adjustGrid(
|
||||
width: number,
|
||||
height: number,
|
||||
itemWidth: number,
|
||||
itemHeight: number,
|
||||
intervalX: number,
|
||||
intervalY: number = intervalX
|
||||
): IGridLayoutData {
|
||||
const cols = Math.floor((width + intervalX) / (itemWidth + intervalX));
|
||||
const rows = Math.floor((height + intervalY) / (itemHeight + intervalY));
|
||||
const rawWidth = (itemWidth + intervalX) * cols - intervalX;
|
||||
const rawHeight = (itemHeight + intervalY) * rows - intervalY;
|
||||
|
||||
const locs: ElementLocator[] = [];
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const iy = (intervalY + itemHeight) * y;
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const ix = (intervalX + itemWidth) * x;
|
||||
locs.push([ix, iy, itemWidth, itemHeight, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cols,
|
||||
rows,
|
||||
count: cols * rows,
|
||||
width: rawWidth,
|
||||
height: rawHeight,
|
||||
locs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配为覆盖模式,类似于 `object-fit: cover`
|
||||
* @param itemWidth 元素宽度
|
||||
* @param itemHeight 元素高度
|
||||
* @param targetWidth 目标宽度
|
||||
* @param targetHeight 目标高度
|
||||
* @returns 适配为覆盖模式后元素的宽高
|
||||
*/
|
||||
export function adjustCover(
|
||||
itemWidth: number,
|
||||
itemHeight: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number
|
||||
): Readonly<LocArr> {
|
||||
const aspect = itemWidth / itemHeight;
|
||||
const canvasAspect = targetWidth / targetHeight;
|
||||
if (canvasAspect > aspect) {
|
||||
const width = targetWidth;
|
||||
const height = width / aspect;
|
||||
return [width, height];
|
||||
} else {
|
||||
const height = targetHeight;
|
||||
const width = height * aspect;
|
||||
return [width, height];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { compressToBase64, decompressFromBase64 } from 'lz-string';
|
||||
import { getConfirm, waitbox } from '../components';
|
||||
import { IUIMountable } from '@motajs/system-ui';
|
||||
import { SyncSaveFromServerResponse } from '@motajs/client-base';
|
||||
import { CENTER_LOC, POP_BOX_WIDTH } from '../shared';
|
||||
|
||||
export interface SaveData {
|
||||
name: string;
|
||||
version: string;
|
||||
data: Save;
|
||||
}
|
||||
|
||||
export interface SaveDataArray {
|
||||
name: string;
|
||||
version: string;
|
||||
data: Save[];
|
||||
}
|
||||
|
||||
export function getSave(index: number) {
|
||||
return new Promise<SaveData | null>(res => {
|
||||
core.getSave(index, data => {
|
||||
if (!data) {
|
||||
res(null);
|
||||
return;
|
||||
}
|
||||
const content = {
|
||||
name: core.firstData.name,
|
||||
version: core.firstData.version,
|
||||
data: data instanceof Array ? data.at(-1)! : data
|
||||
};
|
||||
res(content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllSaves() {
|
||||
return new Promise<SaveDataArray | null>(res => {
|
||||
core.getAllSaves(saves => {
|
||||
if (!saves) {
|
||||
res(null);
|
||||
return;
|
||||
}
|
||||
const content = {
|
||||
name: core.firstData.name,
|
||||
version: core.firstData.version,
|
||||
data: saves
|
||||
};
|
||||
res(content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSaveData(index: number) {
|
||||
const data = await getSave(index);
|
||||
if (!data) return '';
|
||||
return compressToBase64(JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function getAllSavesData() {
|
||||
const data = await getAllSaves();
|
||||
if (!data) return '';
|
||||
return compressToBase64(JSON.stringify(data));
|
||||
}
|
||||
|
||||
//#region 服务器加载
|
||||
|
||||
const enum FromServerResponse {
|
||||
Success,
|
||||
ErrorCannotParse,
|
||||
ErrorCannotSync,
|
||||
ErrorUnexpectedCode
|
||||
}
|
||||
|
||||
function parseIdPassword(id: string): [string, string] {
|
||||
if (id.length === 7) return [id.slice(0, 4), id.slice(4)];
|
||||
else return [id.slice(0, 6), id.slice(6)];
|
||||
}
|
||||
|
||||
async function parseResponse(response: SyncSaveFromServerResponse) {
|
||||
let msg: Save | Save[] | null = null;
|
||||
try {
|
||||
msg = JSON.parse(decompressFromBase64(response.msg));
|
||||
} catch {
|
||||
// 无视报错
|
||||
}
|
||||
if (!msg) {
|
||||
try {
|
||||
msg = JSON.parse(response.msg);
|
||||
} catch {
|
||||
// 无视报错
|
||||
}
|
||||
}
|
||||
if (msg) {
|
||||
return msg;
|
||||
} else {
|
||||
return FromServerResponse.ErrorCannotParse;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLoad(id: string, password: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('type', 'load');
|
||||
formData.append('name', core.firstData.name);
|
||||
formData.append('id', id);
|
||||
formData.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await fetch('/games/sync.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = (await response.json()) as SyncSaveFromServerResponse;
|
||||
if (data.code === 0) {
|
||||
return parseResponse(data);
|
||||
} else {
|
||||
return FromServerResponse.ErrorUnexpectedCode;
|
||||
}
|
||||
} catch {
|
||||
return FromServerResponse.ErrorCannotSync;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncFromServer(
|
||||
controller: IUIMountable,
|
||||
identifier: string
|
||||
): Promise<void> {
|
||||
if (!/^\d{6}\w{4}$/.test(identifier) && !/^\d{4}\w{3}$/.test(identifier)) {
|
||||
return void getConfirm(
|
||||
controller,
|
||||
'不合法的存档编号+密码!请检查格式!',
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
}
|
||||
const [id, password] = parseIdPassword(identifier);
|
||||
const result = await waitbox(
|
||||
controller,
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH,
|
||||
syncLoad(id, password)
|
||||
);
|
||||
if (typeof result === 'number') {
|
||||
const map = {
|
||||
[FromServerResponse.ErrorCannotParse]: '出错啦!\n存档解析失败',
|
||||
[FromServerResponse.ErrorCannotSync]:
|
||||
'出错啦!\n无法从服务器同步存档。',
|
||||
[FromServerResponse.ErrorUnexpectedCode]:
|
||||
'出错啦!\n无法从服务器同步存档。'
|
||||
};
|
||||
return void getConfirm(
|
||||
controller,
|
||||
map[result],
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
}
|
||||
if (result instanceof Array) {
|
||||
const confirm = await getConfirm(
|
||||
controller,
|
||||
'所有本地存档都将被覆盖,确认?',
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH,
|
||||
{
|
||||
defaultYes: true
|
||||
}
|
||||
);
|
||||
if (confirm) {
|
||||
const max = 5 * (main.savePages || 30);
|
||||
for (let i = 1; i <= max; i++) {
|
||||
if (i <= result.length) {
|
||||
core.setLocalForage('save' + i, result[i - 1]);
|
||||
} else if (core.saves.ids[i]) {
|
||||
core.removeLocalForage('save' + i);
|
||||
}
|
||||
}
|
||||
return void getConfirm(
|
||||
controller,
|
||||
'同步成功!\n你的本地所有存档均已被覆盖。',
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const idx = core.saves.saveIndex;
|
||||
await new Promise<void>(res => {
|
||||
core.setLocalForage(`save${idx}`, result, res);
|
||||
});
|
||||
return void getConfirm(
|
||||
controller,
|
||||
`同步成功!\n单存档已覆盖至存档 ${idx}`,
|
||||
CENTER_LOC,
|
||||
POP_BOX_WIDTH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { onUnmounted } from 'vue';
|
||||
import { WeatherController } from '../weather';
|
||||
|
||||
export function useWeather(): [WeatherController] {
|
||||
const weather = new WeatherController();
|
||||
|
||||
onUnmounted(() => {
|
||||
weather.destroy();
|
||||
});
|
||||
|
||||
return [weather];
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { RenderItem } from '@motajs/render-core';
|
||||
import { IWeather, IWeatherController, IWeatherInstance } from './types';
|
||||
import { logger } from '@motajs/common';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { Ticker } from 'mutate-animate';
|
||||
|
||||
type WeatherConstructor = new () => IWeather;
|
||||
|
||||
export class WeatherController implements IWeatherController {
|
||||
/** 暴露到全局的控制器 */
|
||||
static extern: Map<string, IWeatherController> = new Map();
|
||||
/** 注册的天气 */
|
||||
static weathers: Map<string, WeatherConstructor> = new Map();
|
||||
|
||||
private static ticker: Ticker = new Ticker();
|
||||
|
||||
/** 暴露至全局的 id */
|
||||
private externId?: string;
|
||||
/** 天气元素纵深 */
|
||||
private zIndex: number = 100;
|
||||
|
||||
readonly active: Set<IWeatherInstance> = new Set();
|
||||
|
||||
container: RenderItem | null = null;
|
||||
|
||||
constructor() {
|
||||
WeatherController.ticker.add(this.tick);
|
||||
}
|
||||
|
||||
private tick = (time: number) => {
|
||||
this.active.forEach(v => v.weather.tick(time));
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置天气元素纵深,第一个天气会被设置为 `zIndex`,之后依次是 `zIndex+1` `zIndex+2` ...
|
||||
* @param zIndex 第一个天气的纵深
|
||||
*/
|
||||
setZIndex(zIndex: number) {
|
||||
this.zIndex = zIndex;
|
||||
let n = zIndex;
|
||||
this.active.forEach(v => {
|
||||
v.setZIndex(n);
|
||||
n++;
|
||||
});
|
||||
}
|
||||
|
||||
bind(container: RenderItem): void {
|
||||
if (this.container) {
|
||||
logger.warn(65);
|
||||
return;
|
||||
}
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
private getWeatherObject<R extends RenderItem, T extends IWeather<R>>(
|
||||
weather: string | T
|
||||
): T | null {
|
||||
if (typeof weather === 'string') {
|
||||
const Weather = WeatherController.weathers.get(weather);
|
||||
if (!Weather) {
|
||||
logger.warn(25, weather);
|
||||
return null;
|
||||
}
|
||||
return new Weather() as T;
|
||||
} else {
|
||||
return weather;
|
||||
}
|
||||
}
|
||||
|
||||
activate(weather: string, level?: number): IWeatherInstance | null;
|
||||
activate<R extends RenderItem, T extends IWeather<R>>(
|
||||
weather: T,
|
||||
level?: number
|
||||
): IWeatherInstance<R, T> | null;
|
||||
activate<R extends RenderItem, T extends IWeather<R>>(
|
||||
weather: string | T,
|
||||
level: number = 5
|
||||
): IWeatherInstance<R, T> | null {
|
||||
const obj = this.getWeatherObject<R, T>(weather);
|
||||
if (!obj || !this.container) return null;
|
||||
const element = obj.create(level);
|
||||
element.size(this.container.width, this.container.height);
|
||||
const instance = new WeatherInstance(obj, element);
|
||||
instance.setZIndex(this.zIndex + this.active.size);
|
||||
this.active.add(instance);
|
||||
this.container.appendChild(element);
|
||||
return instance;
|
||||
}
|
||||
|
||||
deactivate(instance: IWeatherInstance): void {
|
||||
this.container?.removeChild(instance.element);
|
||||
instance.weather.destroy();
|
||||
this.active.delete(instance);
|
||||
}
|
||||
|
||||
clearWeather(): void {
|
||||
this.active.forEach(v => {
|
||||
v.weather.destroy();
|
||||
});
|
||||
this.active.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将此控制器暴露至全局,允许使用 {@link WeatherController.get} 获取到实例
|
||||
* @param id 暴露给全局的 id
|
||||
*/
|
||||
extern(id: string) {
|
||||
WeatherController.extern.set(id, this);
|
||||
this.externId = id;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clearWeather();
|
||||
WeatherController.ticker.remove(this.tick);
|
||||
if (!isNil(this.externId)) {
|
||||
WeatherController.extern.delete(this.externId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取暴露至全局的控制器
|
||||
* @param id 控制器暴露至全局的 id
|
||||
*/
|
||||
static get(id: string): IWeatherController | null {
|
||||
return this.extern.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个天气
|
||||
* @param id 天气的名称
|
||||
* @param weather 天气的构造器
|
||||
*/
|
||||
static register(id: string, weather: WeatherConstructor) {
|
||||
this.weathers.set(id, weather);
|
||||
}
|
||||
}
|
||||
|
||||
export class WeatherInstance<
|
||||
R extends RenderItem = RenderItem,
|
||||
T extends IWeather<R> = IWeather<R>
|
||||
> implements IWeatherInstance<R, T>
|
||||
{
|
||||
constructor(
|
||||
readonly weather: T,
|
||||
readonly element: R
|
||||
) {}
|
||||
|
||||
setZIndex(zIndex: number): void {
|
||||
this.element.setZIndex(zIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WeatherController } from './controller';
|
||||
import {
|
||||
CloudWeather,
|
||||
FogWeather,
|
||||
RainWeather,
|
||||
SnowWeather,
|
||||
SunWeather
|
||||
} from './presets';
|
||||
|
||||
export function createWeather() {
|
||||
WeatherController.register('cloud', CloudWeather);
|
||||
WeatherController.register('rain', RainWeather);
|
||||
WeatherController.register('snow', SnowWeather);
|
||||
WeatherController.register('sun', SunWeather);
|
||||
WeatherController.register('fog', FogWeather);
|
||||
}
|
||||
|
||||
export * from './presets';
|
||||
export * from './controller';
|
||||
export * from './types';
|
||||
export * from './weather';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CloudLike } from './cloudLike';
|
||||
import { SizedCanvasImageSource } from '@motajs/render-core';
|
||||
|
||||
export class CloudWeather extends CloudLike {
|
||||
getImage(): SizedCanvasImageSource | null {
|
||||
return core.material.images.images['cloud.png'] ?? null;
|
||||
}
|
||||
|
||||
onDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MotaOffscreenCanvas2D, Sprite } from '@motajs/render-core';
|
||||
import { Weather } from '../weather';
|
||||
import { SizedCanvasImageSource } from '@motajs/render-core';
|
||||
|
||||
export abstract class CloudLike extends Weather<Sprite> {
|
||||
/** 不透明度 */
|
||||
private alpha: number = 0;
|
||||
/** 水平速度 */
|
||||
private vx: number = 0;
|
||||
/** 竖直速度 */
|
||||
private vy: number = 0;
|
||||
/** 水平位置 */
|
||||
private cx: number = 0;
|
||||
/** 竖直位置 */
|
||||
private cy: number = 0;
|
||||
/** 移动的最大速度 */
|
||||
private maxSpeed: number = 1;
|
||||
/** 上一次执行速度变换的时刻 */
|
||||
private lastTick = 0;
|
||||
/** 绘制天气使用的图片 */
|
||||
private image: SizedCanvasImageSource | null = null;
|
||||
|
||||
/**
|
||||
* 获取类多云天气所使用的图片
|
||||
*/
|
||||
abstract getImage(): SizedCanvasImageSource | null;
|
||||
|
||||
private drawImage(canvas: MotaOffscreenCanvas2D) {
|
||||
const ctx = canvas.ctx;
|
||||
if (!this.image) return;
|
||||
ctx.globalAlpha = this.alpha;
|
||||
const { width, height } = this.image;
|
||||
for (let x = -1; x < 2; x++) {
|
||||
for (let y = -1; y < 2; y++) {
|
||||
const dx = x * width + this.cx;
|
||||
const dy = y * height + this.cy;
|
||||
if (dx > canvas.width || dy > canvas.height) continue;
|
||||
if (dx + width < 0 || dy + height < 0) continue;
|
||||
ctx.drawImage(this.image, dx, dy, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick(time: number): void {
|
||||
if (!this.element || !this.image) return;
|
||||
this.element.update();
|
||||
const dt = time - this.lastTick;
|
||||
this.lastTick = time;
|
||||
if (dt > 100) return;
|
||||
const dvx = (Math.random() - 0.5) * this.level * 10;
|
||||
const dvy = (Math.random() - 0.5) * this.level * 10;
|
||||
const addx = (dvx * dt) / 1000;
|
||||
const addy = (dvy * dt) / 1000;
|
||||
if (Math.sign(addx) === Math.sign(this.vx)) {
|
||||
const ratio = Math.sqrt(
|
||||
(this.maxSpeed - Math.abs(this.vx)) / this.maxSpeed
|
||||
);
|
||||
const value = Math.abs(addx) * ratio;
|
||||
this.vx += value * Math.sign(addx);
|
||||
} else {
|
||||
this.vx += addx;
|
||||
}
|
||||
if (Math.sign(addy) === Math.sign(this.vy)) {
|
||||
const ratio = Math.sqrt(
|
||||
(this.maxSpeed - Math.abs(this.vy)) / this.maxSpeed
|
||||
);
|
||||
const value = Math.abs(addy) * ratio;
|
||||
this.vy += value * Math.sign(addy);
|
||||
} else {
|
||||
this.vy += addy;
|
||||
}
|
||||
this.cx += (this.vx * dt) / 1000;
|
||||
this.cy += (this.vy * dt) / 1000;
|
||||
this.cx %= this.image.width;
|
||||
this.cy %= this.image.height;
|
||||
}
|
||||
|
||||
createElement(level: number): Sprite {
|
||||
const element = new Sprite('static', true);
|
||||
element.setRenderFn(canvas => this.drawImage(canvas));
|
||||
this.maxSpeed = Math.sqrt(level) * 100;
|
||||
this.vx = ((Math.random() - 0.5) * this.maxSpeed) / 2;
|
||||
this.vy = ((Math.random() - 0.5) * this.maxSpeed) / 2;
|
||||
this.alpha = Math.sqrt(level) / 10;
|
||||
this.image = this.getImage();
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MotaOffscreenCanvas2D } from '@motajs/render-core';
|
||||
import { CloudLike } from './cloudLike';
|
||||
import { SizedCanvasImageSource } from '@motajs/render-core';
|
||||
|
||||
export class FogWeather extends CloudLike {
|
||||
/** 雾天气的图像比较小,因此将四个进行合并 */
|
||||
private static mergedFog: MotaOffscreenCanvas2D | null = null;
|
||||
|
||||
getImage(): SizedCanvasImageSource | null {
|
||||
if (FogWeather.mergedFog) {
|
||||
return FogWeather.mergedFog.canvas;
|
||||
} else {
|
||||
return FogWeather.mergeFog();
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(): void {}
|
||||
|
||||
/**
|
||||
* 将雾天气的图片 2x2 合并
|
||||
*/
|
||||
static mergeFog() {
|
||||
const image = core.material.images.images['fog.png'];
|
||||
if (!image) return null;
|
||||
const { width, height } = image;
|
||||
this.mergedFog = new MotaOffscreenCanvas2D();
|
||||
this.mergedFog.size(width * 2, height * 2);
|
||||
const ctx = this.mergedFog.ctx;
|
||||
ctx.drawImage(image, 0, 0, width, width);
|
||||
ctx.drawImage(image, width, 0, width, width);
|
||||
ctx.drawImage(image, 0, height, width, width);
|
||||
ctx.drawImage(image, width, height, width, width);
|
||||
return this.mergedFog.canvas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './cloud';
|
||||
export * from './cloudLike';
|
||||
export * from './fog';
|
||||
export * from './rain';
|
||||
export * from './snow';
|
||||
export * from './sun';
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
ShaderProgram,
|
||||
IShaderUniform,
|
||||
UniformType,
|
||||
MotaOffscreenCanvas2D,
|
||||
GL2
|
||||
} from '@motajs/render';
|
||||
import { Weather } from '../weather';
|
||||
|
||||
const rainVs = /* glsl */ `
|
||||
in vec2 a_rainVertex;
|
||||
in vec2 a_offset; // 雨滴的中心位置
|
||||
in vec4 a_data; // x: 雨滴宽度; y: 雨滴长度; z: 雨滴旋转角度,0表示向下,逆时针为正;
|
||||
// w: 属于哪一种雨,需要两种雨反复循环,一种无法实现循环,0表示第一种,1表示第二种
|
||||
|
||||
uniform float u_progress; // 下雨进度,从最上落到最下是0.5个进度,以保证不会出现奇怪的问题
|
||||
|
||||
out vec2 v_center;
|
||||
out vec2 v_data; // 雨滴宽度与高度
|
||||
out vec2 v_pos;
|
||||
|
||||
mat2 createScaleMatrix(float x, float y) {
|
||||
return mat2(
|
||||
x, 0,
|
||||
0, y
|
||||
);
|
||||
}
|
||||
|
||||
vec2 getOffsetByProgress(vec2 offset) {
|
||||
if (a_data.w == 0.0) {
|
||||
if (u_progress < 0.5) {
|
||||
return offset * u_progress * 2.0;
|
||||
} else {
|
||||
return offset * (u_progress - 1.0) * 2.0;
|
||||
}
|
||||
} else {
|
||||
return offset * u_progress * 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
float cosTheta = cos(a_data.z);
|
||||
float sinTheta = sin(a_data.z);
|
||||
mat2 rotate = mat2(
|
||||
cosTheta, -sinTheta,
|
||||
sinTheta, cosTheta
|
||||
);
|
||||
vec2 offset = getOffsetByProgress(vec2(-sinTheta * 2.0, -cosTheta * 2.0));
|
||||
mat2 scale = createScaleMatrix(a_data.x, a_data.y);
|
||||
vec2 off = a_offset + offset;
|
||||
vec2 pos = rotate * scale * a_rainVertex + off;
|
||||
v_center = off;
|
||||
v_pos = pos;
|
||||
gl_Position = vec4(pos, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const rainFs = /* glsl */ `
|
||||
in vec2 v_center;
|
||||
in vec2 v_data; // 雨滴的宽度与长度
|
||||
in vec2 v_pos;
|
||||
|
||||
uniform vec4 u_color; // 雨滴的颜色
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
float random(vec2 uv) {
|
||||
return fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float ran = random(v_pos);
|
||||
vec2 pos = vec2(v_pos.x + ran * 0.01, v_pos.y);
|
||||
vec2 texPos = (pos + 1.0) / 2.0;
|
||||
texPos.y = 1.0 - texPos.y;
|
||||
vec4 tex = texture(u_sampler, texPos);
|
||||
outColor = mix(u_color, tex, 0.75);
|
||||
}
|
||||
`;
|
||||
|
||||
/** 雨滴顶点坐标 */
|
||||
const vertex = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
|
||||
|
||||
interface RainCreateData {
|
||||
/** 进度变量 */
|
||||
readonly uProgress: IShaderUniform<UniformType.Uniform1f> | null;
|
||||
/** 下雨着色器程序 */
|
||||
readonly program: ShaderProgram;
|
||||
}
|
||||
|
||||
class RainShader extends GL2 {
|
||||
/** 下雨程序 */
|
||||
private rainProgram: ShaderProgram | null = null;
|
||||
/** 背景程序 */
|
||||
private backProgram: ShaderProgram | null = null;
|
||||
|
||||
create(level: number): RainCreateData {
|
||||
const gl = this.gl;
|
||||
const program = this.createProgram(ShaderProgram);
|
||||
program.fs(rainFs);
|
||||
program.vs(rainVs);
|
||||
program.requestCompile();
|
||||
this.useProgram(program);
|
||||
const pos = program.defineAttribArray('a_rainVertex');
|
||||
program.defineAttribArray('a_offset');
|
||||
program.defineAttribArray('a_data');
|
||||
program.defineUniform('u_color', this.UNIFORM_4f);
|
||||
const uProgress = program.defineUniform('u_progress', this.UNIFORM_1f);
|
||||
program.mode(this.DRAW_ARRAYS_INSTANCED);
|
||||
|
||||
if (pos) {
|
||||
pos.buffer(vertex, gl.STATIC_DRAW);
|
||||
pos.pointer(2, gl.FLOAT, false, 0, 0);
|
||||
pos.enable();
|
||||
}
|
||||
|
||||
program.paramArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 100 * level);
|
||||
|
||||
this.generateRainPath(level, program);
|
||||
this.rainProgram = program;
|
||||
this.backProgram = this.createProgram(ShaderProgram);
|
||||
this.backProgram.requestCompile();
|
||||
|
||||
return { uProgress, program };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成雨滴
|
||||
* @param num 雨滴数量
|
||||
*/
|
||||
generateRainPath(level: number, program: ShaderProgram) {
|
||||
const num = level * 100;
|
||||
const angle = (((Math.random() - 0.5) * Math.PI) / 30) * level;
|
||||
const deviation = (Math.PI / 180) * (12 - level);
|
||||
|
||||
const aOffset = program.getAttribArray('a_offset');
|
||||
const aData = program.getAttribArray('a_data');
|
||||
const color = program.getUniform<UniformType.Uniform4f>('u_color');
|
||||
const gl = this.gl;
|
||||
if (!aOffset || !aData || !color) return;
|
||||
|
||||
const tan = Math.tan(angle);
|
||||
|
||||
const offset = new Float32Array(num * 2);
|
||||
const data = new Float32Array(num * 4);
|
||||
const half = num / 2;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const ox = Math.random() * 3 - 1.5;
|
||||
const oy = Math.random() * 2 - 1;
|
||||
const rad = angle + (Math.random() - 0.5) * Math.PI * deviation;
|
||||
const length = Math.random() * 0.05 + 0.03;
|
||||
const width = Math.random() * 0.002 + 0.002;
|
||||
offset.set([ox, oy], i * 2);
|
||||
data.set([width, length, rad, 0], i * 4);
|
||||
}
|
||||
for (let i = half; i < num; i++) {
|
||||
const ox = Math.random() * 3 - 1.5 + tan * 2;
|
||||
const oy = Math.random() * 2 + 1;
|
||||
const rad = angle + (Math.random() - 0.5) * Math.PI * deviation;
|
||||
const length = Math.random() * 0.05 + 0.03;
|
||||
const width = Math.random() * 0.002 + 0.002;
|
||||
offset.set([ox, oy], i * 2);
|
||||
data.set([width, length, rad, 1], i * 4);
|
||||
}
|
||||
|
||||
aOffset.buffer(offset, gl.STATIC_DRAW);
|
||||
aData.buffer(data, gl.STATIC_DRAW);
|
||||
aOffset.pointer(2, gl.FLOAT, false, 0, 0);
|
||||
aOffset.divisor(1);
|
||||
aOffset.enable();
|
||||
aData.pointer(4, gl.FLOAT, false, 0, 0);
|
||||
aData.divisor(1);
|
||||
aData.enable();
|
||||
|
||||
color.set(1, 1, 1, 1);
|
||||
}
|
||||
|
||||
protected drawScene(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
gl: WebGL2RenderingContext
|
||||
): void {
|
||||
const program1 = this.backProgram;
|
||||
const program2 = this.rainProgram;
|
||||
if (!program1 || !program2) return;
|
||||
this.useProgram(program1);
|
||||
program1.texTexture('u_sampler', canvas.canvas);
|
||||
this.draw(gl, program1);
|
||||
this.useProgram(program2);
|
||||
program2.texTexture('u_sampler', canvas.canvas);
|
||||
this.draw(gl, program2);
|
||||
}
|
||||
}
|
||||
|
||||
export class RainWeather extends Weather<GL2> {
|
||||
/** 下雨流程的 uniform 变量 */
|
||||
private progress: IShaderUniform<UniformType.Uniform1f> | null = null;
|
||||
/** 下雨着色器程序 */
|
||||
private program: ShaderProgram | null = null;
|
||||
/** 一个周期的时长 */
|
||||
private duration: number = 1;
|
||||
|
||||
createElement(level: number): GL2 {
|
||||
const shader = new RainShader();
|
||||
shader.setHD(true);
|
||||
const { uProgress, program } = shader.create(level);
|
||||
this.progress = uProgress;
|
||||
this.program = program;
|
||||
this.duration = 5000 - 300 * this.level;
|
||||
return shader;
|
||||
}
|
||||
|
||||
tick(timestamp: number): void {
|
||||
if (!this.element || !this.program) return;
|
||||
this.element.update();
|
||||
const time = this.duration;
|
||||
const progress = (timestamp % time) / time;
|
||||
this.progress?.set(progress);
|
||||
}
|
||||
|
||||
onDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
ShaderProgram,
|
||||
IShaderUniform,
|
||||
UniformType,
|
||||
MotaOffscreenCanvas2D,
|
||||
GL2
|
||||
} from '@motajs/render';
|
||||
import { Weather } from '../weather';
|
||||
|
||||
const snowVs = /* glsl */ `
|
||||
in vec2 a_snowVertex;
|
||||
in vec2 a_offset; // 雪花的中心位置
|
||||
in vec4 a_data; // x: 雪花直径; y: 留空; z: 雨滴旋转角度,0表示向下,逆时针为正;
|
||||
// w: 属于哪一种雪,需要两种雪反复循环,一种无法实现循环,0表示第一种,1表示第二种
|
||||
|
||||
uniform float u_progress; // 下雪进度,从最上落到最下是0.5个进度,以保证不会出现奇怪的问题
|
||||
|
||||
out vec2 v_center;
|
||||
out vec2 v_data; // 雪花宽度与高度
|
||||
out vec2 v_pos;
|
||||
|
||||
mat2 createScaleMatrix(float x, float y) {
|
||||
return mat2(
|
||||
x, 0,
|
||||
0, y
|
||||
);
|
||||
}
|
||||
|
||||
vec2 getOffsetByProgress(vec2 offset) {
|
||||
if (a_data.w == 0.0) {
|
||||
if (u_progress < 0.5) {
|
||||
return offset * u_progress * 2.0;
|
||||
} else {
|
||||
return offset * (u_progress - 1.0) * 2.0;
|
||||
}
|
||||
} else {
|
||||
return offset * u_progress * 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
float cosTheta = cos(a_data.z);
|
||||
float sinTheta = sin(a_data.z);
|
||||
mat2 rotate = mat2(
|
||||
cosTheta, -sinTheta,
|
||||
sinTheta, cosTheta
|
||||
);
|
||||
vec2 offset = getOffsetByProgress(vec2(-sinTheta * 2.0, -cosTheta * 2.0));
|
||||
mat2 scale = createScaleMatrix(a_data.x, a_data.x);
|
||||
vec2 off = a_offset + offset;
|
||||
vec2 pos = rotate * scale * a_snowVertex + off;
|
||||
v_center = off;
|
||||
v_pos = pos;
|
||||
gl_Position = vec4(pos, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const snowFs = /* glsl */ `
|
||||
in vec2 v_center;
|
||||
in vec2 v_data; // 雪花的宽度与长度
|
||||
in vec2 v_pos;
|
||||
|
||||
uniform vec4 u_color; // 雪花的颜色
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
vec2 pos = vec2(v_pos.x, v_pos.y);
|
||||
vec2 texPos = (pos + 1.0) / 2.0;
|
||||
texPos.y = 1.0 - texPos.y;
|
||||
vec4 tex = texture(u_sampler, texPos);
|
||||
outColor = mix(u_color, tex, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
const CIRCLE_POINTS = 8;
|
||||
|
||||
function generateCircle(points: number) {
|
||||
const array: number[] = [0, 0];
|
||||
for (let i = 0; i < points; i++) {
|
||||
const rad = Math.PI * 2 - (Math.PI * 2 * i) / points;
|
||||
array.push(Math.cos(rad), Math.sin(rad));
|
||||
}
|
||||
array.push(Math.cos(0), Math.sin(0));
|
||||
return array;
|
||||
}
|
||||
|
||||
/** 雪花顶点坐标 */
|
||||
const vertex = new Float32Array(generateCircle(CIRCLE_POINTS));
|
||||
|
||||
interface SnowCreateData {
|
||||
/** 进度变量 */
|
||||
readonly uProgress: IShaderUniform<UniformType.Uniform1f> | null;
|
||||
/** 下雪着色器程序 */
|
||||
readonly program: ShaderProgram;
|
||||
}
|
||||
|
||||
class SnowShader extends GL2 {
|
||||
/** 下雪程序 */
|
||||
private snowProgram: ShaderProgram | null = null;
|
||||
/** 背景程序 */
|
||||
private backProgram: ShaderProgram | null = null;
|
||||
|
||||
create(level: number): SnowCreateData {
|
||||
const gl = this.gl;
|
||||
const program = this.createProgram(ShaderProgram);
|
||||
program.fs(snowFs);
|
||||
program.vs(snowVs);
|
||||
program.requestCompile();
|
||||
this.useProgram(program);
|
||||
const pos = program.defineAttribArray('a_snowVertex');
|
||||
program.defineAttribArray('a_offset');
|
||||
program.defineAttribArray('a_data');
|
||||
program.defineUniform('u_color', this.UNIFORM_4f);
|
||||
const uProgress = program.defineUniform('u_progress', this.UNIFORM_1f);
|
||||
program.mode(this.DRAW_ARRAYS_INSTANCED);
|
||||
|
||||
if (pos) {
|
||||
pos.buffer(vertex, gl.STATIC_DRAW);
|
||||
pos.pointer(2, gl.FLOAT, false, 0, 0);
|
||||
pos.enable();
|
||||
}
|
||||
|
||||
program.paramArraysInstanced(
|
||||
gl.TRIANGLE_FAN,
|
||||
0,
|
||||
CIRCLE_POINTS * 2 + 4,
|
||||
200 * level
|
||||
);
|
||||
|
||||
this.generateSnowPath(level, program);
|
||||
this.snowProgram = program;
|
||||
this.backProgram = this.createProgram(ShaderProgram);
|
||||
this.backProgram.requestCompile();
|
||||
|
||||
return { uProgress, program };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成雪花
|
||||
* @param level 天气等级
|
||||
*/
|
||||
generateSnowPath(level: number, program: ShaderProgram) {
|
||||
const num = level * 200;
|
||||
const angle = (((Math.random() - 0.5) * Math.PI) / 30) * level;
|
||||
const deviation = (Math.PI / 180) * (12 - level);
|
||||
|
||||
const aOffset = program.getAttribArray('a_offset');
|
||||
const aData = program.getAttribArray('a_data');
|
||||
const color = program.getUniform<UniformType.Uniform4f>('u_color');
|
||||
const gl = this.gl;
|
||||
if (!aOffset || !aData || !color) return;
|
||||
|
||||
const tan = Math.tan(angle);
|
||||
|
||||
const offset = new Float32Array(num * 2);
|
||||
const data = new Float32Array(num * 4);
|
||||
const half = num / 2;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const ox = Math.random() * 3 - 1.5;
|
||||
const oy = Math.random() * 2 - 1;
|
||||
const rad = angle + (Math.random() - 0.5) * Math.PI * deviation;
|
||||
const diameter = Math.random() * 0.002 + 0.005;
|
||||
offset.set([ox, oy], i * 2);
|
||||
data.set([diameter, 0, rad, 0], i * 4);
|
||||
}
|
||||
for (let i = half; i < num; i++) {
|
||||
const ox = Math.random() * 3 - 1.5 + tan * 2;
|
||||
const oy = Math.random() * 2 + 1;
|
||||
const rad = angle + (Math.random() - 0.5) * Math.PI * deviation;
|
||||
const diameter = Math.random() * 0.002 + 0.005;
|
||||
offset.set([ox, oy], i * 2);
|
||||
data.set([diameter, 0, rad, 1], i * 4);
|
||||
}
|
||||
|
||||
aOffset.buffer(offset, gl.STATIC_DRAW);
|
||||
aData.buffer(data, gl.STATIC_DRAW);
|
||||
aOffset.pointer(2, gl.FLOAT, false, 0, 0);
|
||||
aOffset.divisor(1);
|
||||
aOffset.enable();
|
||||
aData.pointer(4, gl.FLOAT, false, 0, 0);
|
||||
aData.divisor(1);
|
||||
aData.enable();
|
||||
|
||||
color.set(1, 1, 1, 0.8);
|
||||
}
|
||||
|
||||
protected drawScene(
|
||||
canvas: MotaOffscreenCanvas2D,
|
||||
gl: WebGL2RenderingContext
|
||||
): void {
|
||||
const program1 = this.backProgram;
|
||||
const program2 = this.snowProgram;
|
||||
if (!program1 || !program2) return;
|
||||
this.useProgram(program1);
|
||||
program1.texTexture('u_sampler', canvas.canvas);
|
||||
this.draw(gl, program1);
|
||||
this.useProgram(program2);
|
||||
this.draw(gl, program2);
|
||||
}
|
||||
}
|
||||
|
||||
export class SnowWeather extends Weather<GL2> {
|
||||
/** 下雪流程的 uniform 变量 */
|
||||
private progress: IShaderUniform<UniformType.Uniform1f> | null = null;
|
||||
/** 下雪着色器程序 */
|
||||
private program: ShaderProgram | null = null;
|
||||
/** 一个周期的时长 */
|
||||
private duration: number = 1;
|
||||
|
||||
createElement(level: number): GL2 {
|
||||
const shader = new SnowShader();
|
||||
shader.setHD(true);
|
||||
const { uProgress, program } = shader.create(level);
|
||||
this.progress = uProgress;
|
||||
this.program = program;
|
||||
this.duration = 15000 - 800 * this.level;
|
||||
return shader;
|
||||
}
|
||||
|
||||
tick(timestamp: number): void {
|
||||
if (!this.element || !this.program) return;
|
||||
this.element.update();
|
||||
const time = this.duration;
|
||||
const progress = (timestamp % time) / time;
|
||||
this.progress?.set(progress);
|
||||
}
|
||||
|
||||
onDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MotaOffscreenCanvas2D, Sprite } from '@motajs/render-core';
|
||||
import { Weather } from '../weather';
|
||||
import { clamp } from 'lodash-es';
|
||||
|
||||
export class SunWeather extends Weather<Sprite> {
|
||||
/** 阳光图片 */
|
||||
private image: HTMLImageElement | null = null;
|
||||
/** 阳光图片的不透明度 */
|
||||
private alpha: number = 0;
|
||||
/** 阳光的最大不透明度 */
|
||||
private maxAlpha: number = 0;
|
||||
/** 阳光的最小不透明度 */
|
||||
private minAlpha: number = 0;
|
||||
/** 不透明度变化率 */
|
||||
private va: number = 0;
|
||||
/** 上一帧的时刻 */
|
||||
private lastTick: number = 0;
|
||||
|
||||
drawSun(canvas: MotaOffscreenCanvas2D) {
|
||||
if (!this.image) return;
|
||||
const ctx = canvas.ctx;
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
tick(timestamp: number): void {
|
||||
this.element?.update();
|
||||
const dt = timestamp - this.lastTick;
|
||||
this.lastTick = timestamp;
|
||||
if (dt > 100) return;
|
||||
const aa = (Math.random() - 0.5) * this.level;
|
||||
this.va += (aa * dt) / 1000;
|
||||
this.va = clamp(this.va, 0.1);
|
||||
this.alpha += (this.va * dt) / 1000;
|
||||
if (this.alpha < this.minAlpha) {
|
||||
this.va = Math.abs(this.va);
|
||||
this.alpha = this.minAlpha;
|
||||
} else if (this.alpha > this.maxAlpha) {
|
||||
this.va = -Math.abs(this.va);
|
||||
this.alpha = this.maxAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
createElement(level: number): Sprite {
|
||||
const element = new Sprite('static', true);
|
||||
element.setRenderFn(canvas => this.drawSun(canvas));
|
||||
this.maxAlpha = level / 10;
|
||||
this.minAlpha = level / 20;
|
||||
this.alpha = (this.maxAlpha + this.minAlpha) / 2;
|
||||
this.image = core.material.images.images['sun.png'];
|
||||
return element;
|
||||
}
|
||||
|
||||
onDestroy(): void {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { RenderItem } from '@motajs/render-core';
|
||||
|
||||
export interface IWeather<T extends RenderItem = RenderItem> {
|
||||
/** 天气的等级,-1 表示未创建 */
|
||||
readonly level: number;
|
||||
|
||||
/**
|
||||
* 创建天气
|
||||
* @param level 天气等级
|
||||
* @returns 天气的渲染元素
|
||||
*/
|
||||
create(level: number): T;
|
||||
|
||||
/**
|
||||
* 摧毁这个天气
|
||||
*/
|
||||
destroy(): void;
|
||||
|
||||
/**
|
||||
* 每帧执行一次的函数
|
||||
* @param timestamp 当前时间戳
|
||||
*/
|
||||
tick(timestamp: number): void;
|
||||
}
|
||||
|
||||
export interface IWeatherInstance<
|
||||
R extends RenderItem = RenderItem,
|
||||
T extends IWeather<R> = IWeather<R>
|
||||
> {
|
||||
/** 天气对象 */
|
||||
readonly weather: T;
|
||||
/** 天气的渲染元素 */
|
||||
readonly element: R;
|
||||
|
||||
/**
|
||||
* 设置这个天气的纵深
|
||||
* @param zIndex 纵深
|
||||
*/
|
||||
setZIndex(zIndex: number): void;
|
||||
}
|
||||
|
||||
export interface IWeatherController {
|
||||
/** 天气控制器绑定到的渲染元素 */
|
||||
readonly container: RenderItem | null;
|
||||
/** 所有已激活的天气 */
|
||||
readonly active: Set<IWeatherInstance>;
|
||||
|
||||
/**
|
||||
* 绑定控制器到渲染元素
|
||||
* @param container 绑定的渲染元素,需要能够添加子元素,一般绑定到 `container`
|
||||
*/
|
||||
bind(container: RenderItem): void;
|
||||
|
||||
/**
|
||||
* 使用天气名称添加天气
|
||||
* @param weather 天气的名称
|
||||
* @param level 天气的等级
|
||||
* @returns 天气实例
|
||||
*/
|
||||
activate(weather: string, level?: number): IWeatherInstance | null;
|
||||
/**
|
||||
* 使用天气对象添加天气
|
||||
* @param weather 天气对象
|
||||
* @param level 天气的等级
|
||||
* @returns 天气实例
|
||||
*/
|
||||
activate<R extends RenderItem, T extends IWeather<R>>(
|
||||
weather: T,
|
||||
level?: number
|
||||
): IWeatherInstance<R, T> | null;
|
||||
|
||||
/**
|
||||
* 取消天气
|
||||
* @param instance 天气实例
|
||||
*/
|
||||
deactivate(instance: IWeatherInstance): void;
|
||||
|
||||
/**
|
||||
* 清空天气
|
||||
*/
|
||||
clearWeather(): void;
|
||||
|
||||
/**
|
||||
* 摧毁这个控制器
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { RenderItem } from '@motajs/render-core';
|
||||
import { IWeather } from './types';
|
||||
|
||||
export abstract class Weather<T extends RenderItem> implements IWeather<T> {
|
||||
level: number = -1;
|
||||
|
||||
protected element: T | null = null;
|
||||
|
||||
create(level: number): T {
|
||||
this.level = level;
|
||||
const element = this.createElement(level);
|
||||
this.element = element;
|
||||
return element;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.element?.destroy();
|
||||
this.onDestroy();
|
||||
}
|
||||
|
||||
abstract tick(timestamp: number): void;
|
||||
|
||||
/**
|
||||
* 创建天气的渲染元素,并进行初始化
|
||||
* @param level 天气的等级
|
||||
*/
|
||||
abstract createElement(level: number): T;
|
||||
|
||||
/**
|
||||
* 当天气被摧毁时调用,进行清理工作,会自动摧毁渲染元素,不需要手动处理
|
||||
*/
|
||||
abstract onDestroy(): void;
|
||||
}
|
||||
Reference in new issue
Block a user