import { DStage } from '../models/index.js';
import { EEvent, FHelp } from '../utils/index.js';
import { UBaseController } from './base.js';
/**
* 由元素及其优先级构成的菜单元素项目
* @summary 菜单元素项目
* @typedef {Object} UStageController~TMenuItem
* @property {HTMLElement} element 菜单元素
* @property {number} priority 元素优先级
*/
/**
* @class
* @summary stage 控制器类
* @classdesc 用于控制 stage 相关的的控制器, 例如控制元素淡入淡出等等
* @extends UBaseController
* @memberof module:controller
* @alias UStageController
*/
export class UStageController extends UBaseController {
/**
* live2d 舞台数据集合, 用于存储 canvas, tips 等文档元素
* @summary stage 数据
* @protected
* @type {DStage}
*/
_data;
/**
* 在菜单元素中显示的菜单集
* @protected
* @summary 菜单集
* @type {UStageController~TMenuItem[]}
* @default []
*/
_menuItems;
/**
* 创建 live2d stage 控制器
* @summary stage 控制器构造
* @constructor
* @param {ULive2dController} live2d live2d 上下文
* @param {string | null} [selector=null] 父元素选择器
* @param {DStage | null} [data=null] 舞台元素数据
* @listens EEvent#modelLoad 模型加载完成的事件
*/
constructor(live2d, selector = null, data = null) {
super(live2d);
data = FHelp.mergeAll(
data ?? {},
{ parent: this.getParentFromSelector(selector) }
);
this._data = new DStage(data);
this._menuItems = [];
// 监听事件
this.event.on(EEvent.modelLoad, this._onModelLoad, this);
this.event.off(EEvent.init, this.init, this);
this.init();
}
/**
* getter: 菜单元素数组
* @summary 菜单数组
* @type {UStageController~TMenuItem[]}
* @readonly
*/
get menuItems() {
return this._menuItems;
}
/**
* getter: 包装器元素
* @summary 包装器元素
* @return {HTMLElement}
* @readonly
*/
get wrapper() {
return this._data.wrapper;
}
/**
* getter: 画布元素
* @summary 画布元素
* @type {HTMLCanvasElement}
* @readonly
*/
get canvas() {
return this._data.canvas;
}
/**
* getter: 消息提示元素
* @summary 提示元素
* @type {HTMLElement}
* @readonly
*/
get tips() {
return this._data.tips;
}
/**
* getter: 菜单元素
* @summary 菜单元素
* @type {HTMLElement}
* @readonly
*/
get menus() {
return this._data.menus;
}
/**
* getter: 其它元素
* @summary 其它元素
* @type {HTMLElement}
* @readonly
*/
get other() {
return this._data.other;
}
/**
* getter: wrapper 的父元素
* @summary 父元素
* @type {HTMLElement}
* @readonly
*/
get parent() {
return this._data.parent;
}
/**
* 初始化 stage 控制器, 并为元素设置层级结构以及类名与样式
* @summary 初始化 stage 控制器
* @override
*/
init() {
// 设置元素层级
// 元素层级
// |parent
// |--|wrapper
// |--|--|canvas
// |--|--|tips
// |--|--|menus
// |--|--|other
this.wrapper.appendChild(this.canvas);
this.wrapper.appendChild(this.menus);
this.wrapper.appendChild(this.tips);
this.wrapper.appendChild(this.other);
this.parent.appendChild(this.wrapper);
const { fixed, transitionTime } = this.live2dData;
// 添加类
this.wrapper.classList.add(fixed ? 'live2d-fixed' : 'live2d-relative', 'live2d-wrapper', 'live2d-transition-all', 'live2d-opacity-0');
// 画布
this.canvas.classList.add('live2d-canvas', 'live2d-transition-all', 'live2d-opacity-1');
// 消息提示
this.tips.classList.add('live2d-tips', 'live2d-shake', 'live2d-transition-all', 'live2d-opacity-0');
// 菜单
this.menus.classList.add('live2d-menus', 'live2d-transition-all', 'live2d-opacity-0');
// 其它
this.other.classList.add('live2d-other', 'live2d-transition-all', 'live2d-opacity-1');
// 模型的过度时间
this.canvas.style.setProperty('--live2d-duration', `${ transitionTime }ms`);
// 绑定事件
const ref = this.ref['_showAndHiddenMenus'] = this.showAndHiddenMenus.bind(this);
this.wrapper.addEventListener('mouseover', ref);
this.wrapper.addEventListener('mouseleave', ref);
document.addEventListener('touchstart', ref);
}
/**
* 销毁控制器, 移除菜单元素, 以及移除绑定的事件
* @summary 销毁 stage 控制器
* @override
*/
destroy() {
super.destroy();
this.event.removeListener(EEvent.modelLoad, this._onModelLoad, this);
for (const item of this._menuItems) {
this.removeMenu(item.element);
}
const ref = this.ref['_showAndHiddenMenus'];
this.wrapper.removeEventListener('mouseover', ref);
this.wrapper.removeEventListener('mouseleave', ref);
document.removeEventListener('touchstart', ref);
this.wrapper.remove();
this._data = null;
}
/**
* 对指定元素应用者淡入动画
* @summary 元素淡入
* @param {HTMLElement | null} [element=null] 需要执行淡入的元素, 默认是包装器元素
* @return {Promise<void>}
* @async
*/
async fadeIn(element = null) {
await this._fade(element, 'fadeIn', 'fadeOut').catch(FHelp.F);
}
/**
* 对指定元素应用者淡出动画
* @summary 元素淡出
* @param {HTMLElement | null} [element=null] 需要执行淡出的元素, 默认是包装器元素
* @return {Promise<void>}
* @async
*/
async fadeOut(element = null) {
await this._fade(element, 'fadeOut', 'fadeIn').catch(FHelp.F);
}
/**
* 对指定元素应用淡入或者淡出动画
* @summary 元素淡入淡出
* @param {HTMLElement | null} element 需要执行动画的元素, 默认是包装器元素
* @param {'fadeIn' | 'fadeOut'} proceed 需要进行的动画名称
* @param {'fadeIn' | 'fadeOut'} exit 需要退出的动画名称
* @return {Promise<void>}
* @fires EEvent#fadeStart 淡入淡出开始时间
* @fires EEvent#fadeEnd 淡入淡出结束事件
* @fires EEvent#fadeCancel 淡入淡出取消事件
* @protected
* @async
*/
async _fade(element, proceed, exit) {
const state = {};
element ??= this.wrapper;
// 取消之前的淡入淡出
element[exit]?.();
element[proceed]?.();
element[proceed] = (end = false) => {
for (const key in state) {
state[key]?.();
}
element[proceed] = null;
this.event.emit(end ? EEvent.fadeEnd : EEvent.fadeCancel);
};
this.event.emit(EEvent.fadeStart);
let time = this.getTransitionDuration(element);
// 添加过度类
!element.classList.contains('live2d-transition-all') && element.classList.add('live2d-transition-all');
// 执行分支
if (proceed.search(/fadeIn/) !== -1) {
element.classList.remove('live2d-hidden');
// 响应时间
await setTime(20, 'wait');
element.classList.remove('live2d-opacity-0');
element.classList.add('live2d-opacity-1');
await setTime(time - 20, 'cancel');
}
else {
element.classList.remove('live2d-opacity-1');
element.classList.add('live2d-opacity-0');
await setTime(time, 'cancel');
element.classList.add('live2d-hidden');
}
// 清除回调, 通知 fadeEnd
element[proceed]?.(true);
/**
* 设置定时事时间
* @param {number} time
* @param {string} key
* @return {Promise<void>}
* @async
*/
async function setTime(time, key) {
await new Promise((resolve, reject) => {
const handler = setTimeout(() => {
state[key] = null;
resolve();
}, time);
state[key] = () => {
clearTimeout(handler);
reject();
};
});
}
}
/**
* 将菜单元素及优先级作为一个对象添加到 menuItems
* @summary 添加菜单元素
* @param {HTMLElement} element 文档元素
* @param {number} [priority=2] 优先级
* @return {UStageController} 自身引用
*/
addMenu(element, priority = 2) {
if (FHelp.is(HTMLElement, element)) {
this._menuItems.push({ element, priority });
// 按优先级排序 - 从大到小
this._menuItems.sort((a, b) => b.priority - a.priority);
// 更新节点
this.menus.innerHTML = '';
this.menus.append(...this._menuItems.map(item => item.element));
}
return this;
}
/**
* 在 menuItems 中移除指定的菜单元素
* @summary 移除菜单元素
* @param {HTMLElement} element 文档元素
* @return {UStageController} 自身引用
*/
removeMenu(element) {
const index = this._menuItems.findIndex(item => element === item.element);
if (index >= 0) {
this._menuItems.splice(index, 1);
// 移除节点
element.remove();
}
return this;
}
/**
* 当鼠标进入舞台时显示菜单, 离开时隐藏
*
* 当触摸到舞台时显示菜单, 否则隐藏菜单
* @summary 显示和隐藏菜单
* @param {MouseEvent | TouchEvent} event 鼠标事件 | 触摸事件
*/
showAndHiddenMenus(event) {
if (event.type === 'mouseover' || (event.type === 'touchstart' && this.wrapper.contains(event.touches[0].target))) {
this.fadeIn(this.menus).catch(FHelp.F);
}
else {
this.fadeOut(this.menus).catch(FHelp.F);
}
}
/**
* 从选择器中获取父元素
*
* `css` 选择器规则优先, 其次是 `xpath` 规则, 当两个都找不到时, 则使用 body 为父元素
* @summary 获取父元素
* @param {string | null} [selector=null] 选择器
* @return {HTMLElement} 获取到的节点元素
*/
getParentFromSelector(selector = null) {
let parent;
try {
parent = document.querySelector(selector);
}
catch (_) {
try {
parent = document.evaluate(selector, document).iterateNext();
}
catch (_) {}
}
return parent ?? document.body;
}
/**
* 获取指定元素的 transition-duration 值
* @summary 获取过度时间
* @param {HTMLElement} element 元素
* @return {number} 持续时间
*/
getTransitionDuration(element) {
if (!element) return 0;
let str = getComputedStyle(element).getPropertyValue('transition-duration');
/s/.test(str) || (str += 's');
return FHelp.defaultTo(0, parseFloat(str)) * (/ms/.test(str) ? 1 : 1000);
}
/**
* 判断 wrapper 元素是在窗口的左边还是右边
* @summary 判断 wrapper 的左右位置
* @return {boolean} true 和 false
*/
isRight() {
const wrapper = this.wrapper;
const allWidth = Math.min(window.screen.width, window.visualViewport.width, window.innerWidth);
const width = (allWidth - wrapper.clientWidth) / 2;
const left = wrapper.offsetLeft;
return left > width;
}
/**
* 模型加载完成后触发的事件, 负责设置包装器的宽高, 以及调整模型大小
* @protected
* @summary 模型加载完成后的回调事件
* @param {TRect} style 模型宽高
* @return {void}
*/
_onModelLoad(style) {
// 重设画布宽和高
// transition 会导致 wrapper 的宽高不固定, 从而影响到 canvas 宽高的设置
this.wrapper.classList.remove('live2d-transition-all');
this.canvas.classList.remove('live2d-transition-all');
this.canvas.style.width = this.wrapper.style.width = `${ style.width }px`;
this.canvas.style.height = this.wrapper.style.height = `${ style.height }px`;
// 设置背景色
this.wrapper.style.backgroundColor = this.live2d.model.backgroundColor;
this.isRight() ? this.wrapper.classList.add('live2d-right') : this.wrapper.classList.remove('live2d-right');
// 当包装器元素的宽度与高度被设置后,调整一次模型的大小
this.app.resize();
// 添加过渡类
this.wrapper.classList.add('live2d-transition-all');
this.canvas.classList.add('live2d-transition-all');
// 舞台淡入
this.fadeIn().finally(() => {});
}
}