mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-01-31 23:29:27 +08:00
feat: 弹幕系统
This commit is contained in:
parent
5b74a22494
commit
27230b3d47
3
.gitignore
vendored
3
.gitignore
vendored
@ -43,6 +43,7 @@ dam3.png
|
||||
dam4.png
|
||||
meeting.md
|
||||
|
||||
special.csv
|
||||
*.csv
|
||||
script/special.ts
|
||||
script/people.ts
|
||||
docs/
|
||||
|
@ -46,6 +46,7 @@ function draw() {
|
||||
c.width = scale * w;
|
||||
c.height = scale * h;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
if (props.id === 'none') return;
|
||||
|
||||
@ -73,6 +74,7 @@ onUnmounted(() => {
|
||||
onMounted(() => {
|
||||
c = document.getElementById(`box-animate-${id}`) as HTMLCanvasElement;
|
||||
ctx = c.getContext('2d')!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
draw();
|
||||
});
|
||||
|
||||
|
@ -36,6 +36,7 @@ const hideTipText = debounce(() => {
|
||||
|
||||
export class Logger {
|
||||
level: LogLevel = LogLevel.LOG;
|
||||
enabled: boolean = true;
|
||||
|
||||
constructor(logLevel: LogLevel) {
|
||||
this.level = logLevel;
|
||||
@ -55,7 +56,7 @@ export class Logger {
|
||||
* @param text 错误信息
|
||||
*/
|
||||
error(code: number, text: string) {
|
||||
if (this.level <= LogLevel.ERROR) {
|
||||
if (this.level <= LogLevel.ERROR && this.enabled) {
|
||||
console.error(`[ERROR Code ${code}] ${text}`);
|
||||
if (!main.replayChecking) {
|
||||
logTip.style.color = 'lightcoral';
|
||||
@ -72,7 +73,7 @@ export class Logger {
|
||||
* @param text 警告信息
|
||||
*/
|
||||
severe(code: number, text: string) {
|
||||
if (this.level <= LogLevel.SEVERE_WARNING) {
|
||||
if (this.level <= LogLevel.SEVERE_WARNING && this.enabled) {
|
||||
console.warn(`[SEVERE WARNING Code ${code}] ${text}`);
|
||||
if (!main.replayChecking) {
|
||||
logTip.style.color = 'goldenrod';
|
||||
@ -89,7 +90,7 @@ export class Logger {
|
||||
* @param text 警告信息
|
||||
*/
|
||||
warn(code: number, text: string) {
|
||||
if (this.level <= LogLevel.WARNING) {
|
||||
if (this.level <= LogLevel.WARNING && this.enabled) {
|
||||
console.warn(`[WARNING Code ${code}] ${text}`);
|
||||
if (!main.replayChecking) {
|
||||
logTip.style.color = 'gold';
|
||||
@ -105,10 +106,18 @@ export class Logger {
|
||||
* @param text 日志信息
|
||||
*/
|
||||
log(text: string) {
|
||||
if (this.level <= LogLevel.LOG) {
|
||||
if (this.level <= LogLevel.LOG && this.enabled) {
|
||||
console.log(`[LOG] ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger(LogLevel.LOG);
|
||||
|
@ -58,6 +58,7 @@ import KeyboardPanel from '@/panel/keyboard.vue';
|
||||
import { MCGenerator } from './main/layout';
|
||||
import { ResourceController } from './loader/controller';
|
||||
import { logger } from './common/logger';
|
||||
import { Danmaku } from './main/custom/danmaku';
|
||||
|
||||
// ----- 类注册
|
||||
Mota.register('class', 'AudioPlayer', AudioPlayer);
|
||||
@ -75,6 +76,7 @@ Mota.register('class', 'SoundEffect', SoundEffect);
|
||||
Mota.register('class', 'UiController', UiController);
|
||||
Mota.register('class', 'MComponent', MComponent);
|
||||
Mota.register('class', 'ResourceController', ResourceController);
|
||||
Mota.register('class', 'Danmaku', Danmaku);
|
||||
// ----- 函数注册
|
||||
Mota.register('fn', 'm', m);
|
||||
Mota.register('fn', 'unwrapBinary', unwarpBinary);
|
||||
|
@ -12,3 +12,8 @@ export interface Undoable<T> {
|
||||
*/
|
||||
redo(): T | undefined;
|
||||
}
|
||||
|
||||
export interface ResponseBase {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
430
src/core/main/custom/danmaku.ts
Normal file
430
src/core/main/custom/danmaku.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import BoxAnimate from '@/components/boxAnimate.vue';
|
||||
import { EmitableEvent, EventEmitter } from '@/core/common/eventEmitter';
|
||||
import { logger } from '@/core/common/logger';
|
||||
import { ResponseBase } from '@/core/interface';
|
||||
import { deleteWith, ensureArray, parseCss, tip } from '@/plugin/utils';
|
||||
import axios, { toFormData } from 'axios';
|
||||
import { Component, VNode, h, ref, shallowReactive } from 'vue';
|
||||
/* @__PURE__ */ import { id, password } from '../../../../user';
|
||||
|
||||
type CSSObj = Partial<Record<CanParseCss, string>>;
|
||||
|
||||
interface DanmakuResponse extends ResponseBase {
|
||||
total: number;
|
||||
list: DanmakuInfo[];
|
||||
}
|
||||
|
||||
interface DanmakuInfo {
|
||||
id: number;
|
||||
comment: string;
|
||||
tags: string;
|
||||
love: number;
|
||||
}
|
||||
|
||||
interface DanmakuPostInfo extends Partial<DanmakuContentInfo> {
|
||||
type: 1 | 2 | 3;
|
||||
towername: 'HumanBreak';
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface DanmakuContentInfo {
|
||||
comment: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
interface PostDanmakuResponse extends ResponseBase {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface PostLikeResponse extends ResponseBase {
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
interface DanmakuEvent extends EmitableEvent {
|
||||
showStart: (danmaku: Danmaku) => void;
|
||||
showEnd: (danmaku: Danmaku) => void;
|
||||
like: (liked: boolean, danmaku: Danmaku) => void;
|
||||
}
|
||||
|
||||
type SpecContentFn = (content: string, type: string) => VNode;
|
||||
|
||||
export class Danmaku extends EventEmitter<DanmakuEvent> {
|
||||
static num: number = 0;
|
||||
static backend: string = `/backend/tower/barrage.php`;
|
||||
static all: Set<Danmaku> = new Set();
|
||||
static allInPos: Partial<Record<FloorIds, Record<LocString, Danmaku>>> = {};
|
||||
|
||||
static showList: Danmaku[] = shallowReactive([]);
|
||||
static showMap: Map<number, Danmaku> = new Map();
|
||||
static specList: Record<string, SpecContentFn> = {};
|
||||
|
||||
num: number = Danmaku.num++;
|
||||
|
||||
id: number = -1;
|
||||
text: string = '';
|
||||
x: number = 0;
|
||||
y: number = 0;
|
||||
floor?: FloorIds;
|
||||
showing: boolean = false;
|
||||
likedNum: number = 0;
|
||||
liked: boolean = false;
|
||||
|
||||
style: CSSObj = {};
|
||||
textColor: string = 'white';
|
||||
strokeColor: string = 'black';
|
||||
|
||||
private posted: boolean = false;
|
||||
private vNode?: VNode;
|
||||
private posting: boolean = false;
|
||||
|
||||
/**
|
||||
* 发送弹幕
|
||||
* @returns 弹幕发送的 Axios Post 信息,为 Promise
|
||||
*/
|
||||
async post() {
|
||||
if (this.posted || this.posting) {
|
||||
logger.warn(5, `Repeat post danmaku.`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const data: DanmakuPostInfo = {
|
||||
type: 2,
|
||||
towername: 'HumanBreak',
|
||||
...this.encode()
|
||||
};
|
||||
|
||||
this.posting = true;
|
||||
const form = toFormData(data);
|
||||
/* @__PURE__ */ form.append('userid', id);
|
||||
/* @__PURE__ */ form.append('password', password);
|
||||
const res = await axios.post<PostDanmakuResponse>(
|
||||
Danmaku.backend,
|
||||
form
|
||||
);
|
||||
|
||||
this.id = res.data.id;
|
||||
this.posting = false;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将弹幕整合为可以发送的格式
|
||||
*/
|
||||
encode(): DanmakuContentInfo {
|
||||
const css = this.getEncodedCSS();
|
||||
return {
|
||||
comment: this.text,
|
||||
tags: JSON.stringify([
|
||||
`!css:${JSON.stringify(css)}`,
|
||||
`!pos:${this.x},${this.y},${this.floor}`
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析弹幕信息
|
||||
* @param info 要被解析的弹幕信息
|
||||
*/
|
||||
decode(info: DanmakuContentInfo) {
|
||||
this.text = info.comment;
|
||||
|
||||
ensureArray(JSON.parse(info.tags) as string[]).forEach(v => {
|
||||
if (v.startsWith('!css:')) {
|
||||
this.style = JSON.parse(v.slice(5));
|
||||
} else if (v.startsWith('!pos:')) {
|
||||
const [x, y, f] = v.slice(5).split(',');
|
||||
this.x = parseInt(x);
|
||||
this.y = parseInt(y);
|
||||
this.floor = f as FloorIds;
|
||||
} else {
|
||||
logger.warn(3, `Unknown danmaku tag: ${v}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getEncodedCSS() {
|
||||
const css = JSON.parse(JSON.stringify(this.style)) as CSSObj;
|
||||
if (!css.color) css.color = this.textColor;
|
||||
if (!css.textShadow)
|
||||
css.textShadow = `1px 1px 1px ${this.strokeColor}, 1px -1px 1px ${this.strokeColor}, -1px 1px 1px ${this.strokeColor}, -1px -1px 1px ${this.strokeColor}`;
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文字的颜色
|
||||
* @param fill 填充颜色
|
||||
* @param stroke 描边颜色
|
||||
*/
|
||||
color(fill?: string, stroke?: string) {
|
||||
fill && (this.textColor = fill);
|
||||
stroke && (this.strokeColor = stroke);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个图标
|
||||
* @param icon 要显示的图标id
|
||||
*/
|
||||
addIcon(icon: AllIds) {
|
||||
this.text += `[i:${icon}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置这个弹幕整体的css信息
|
||||
* @param str css字符串
|
||||
* @param overwrite 是否完全覆写原来的css
|
||||
*/
|
||||
css(str: string, overwrite?: boolean): void;
|
||||
/**
|
||||
* 设置这个弹幕整体的css信息
|
||||
* @param str css对象,参考 CSSStyleDeclaration
|
||||
* @param overwrite 是否完全覆写原来的css
|
||||
*/
|
||||
css(obj: CSSObj, overwrite?: boolean): void;
|
||||
css(obj: string | CSSObj, overwrite: boolean = false) {
|
||||
const res = typeof obj === 'string' ? parseCss(obj) : obj;
|
||||
if (overwrite) this.style = res;
|
||||
else {
|
||||
this.style = { ...this.style, ...res };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将这个弹幕添加至弹幕列表
|
||||
*/
|
||||
addToList() {
|
||||
Danmaku.all.add(this);
|
||||
if (!this.floor) return;
|
||||
Danmaku.allInPos[this.floor] ??= {};
|
||||
Danmaku.allInPos[this.floor]![`${this.x},${this.y}`] = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析这个弹幕为 VNode
|
||||
*/
|
||||
parse() {
|
||||
let pointer = -1;
|
||||
let ignore = false;
|
||||
|
||||
let str = '';
|
||||
|
||||
let spec = false;
|
||||
let specType = '';
|
||||
let specTypeEnd = false;
|
||||
let specContent = '';
|
||||
|
||||
const children: VNode[] = [];
|
||||
|
||||
while (++pointer < this.text.length) {
|
||||
const char = this.text[pointer];
|
||||
|
||||
if (char === '\\' && !ignore) {
|
||||
ignore = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignore) {
|
||||
str += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '[') {
|
||||
spec = true;
|
||||
children.push(h('span', str));
|
||||
str = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ']') {
|
||||
if (!spec) {
|
||||
logger.warn(4, `Ignored a mismatched ']' in danmaku.`);
|
||||
str += char;
|
||||
} else {
|
||||
spec = false;
|
||||
specTypeEnd = false;
|
||||
children.push(this.createSpecVNode(specType, specContent));
|
||||
specType = '';
|
||||
specContent = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (spec) {
|
||||
if (!specTypeEnd) {
|
||||
if (char !== ':') {
|
||||
specType += char;
|
||||
} else {
|
||||
specTypeEnd = true;
|
||||
}
|
||||
} else {
|
||||
specContent += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
str += char;
|
||||
}
|
||||
|
||||
if (str.length > 0) {
|
||||
children.push(h('span', str));
|
||||
}
|
||||
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'danmaku', style: this.getEncodedCSS() },
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本弹幕的VNode
|
||||
*/
|
||||
getVNode(nocache: boolean = false) {
|
||||
if (nocache) return (this.vNode = this.parse());
|
||||
return this.vNode ?? (this.vNode = this.parse());
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示这个弹幕
|
||||
*/
|
||||
show() {
|
||||
this.showing = true;
|
||||
Danmaku.showList.push(this);
|
||||
Danmaku.showMap.set(this.id, this);
|
||||
this.emit('showStart', this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示结束这个弹幕
|
||||
*/
|
||||
showEnd() {
|
||||
this.showing = false;
|
||||
deleteWith(Danmaku.showList, this);
|
||||
Danmaku.showMap.delete(this.id);
|
||||
this.emit('showEnd', this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞或取消点赞
|
||||
*/
|
||||
async triggerLike() {
|
||||
const post: DanmakuPostInfo = {
|
||||
type: 3,
|
||||
towername: 'HumanBreak',
|
||||
id: this.id
|
||||
};
|
||||
|
||||
const res = await axios.post<PostLikeResponse>(Danmaku.backend, post);
|
||||
if (res.data.code !== 0) {
|
||||
logger.severe(
|
||||
2,
|
||||
`Uncaught error in posting like info for danmaku. Danmaku id: ${this.id}.`
|
||||
);
|
||||
tip('error', `Error ${res.data.code}. ${res.data.message}`);
|
||||
} else {
|
||||
tip('success', res.data.message);
|
||||
|
||||
if (res.data.liked) {
|
||||
this.liked = true;
|
||||
this.likedNum++;
|
||||
} else {
|
||||
this.liked = false;
|
||||
this.likedNum--;
|
||||
}
|
||||
this.emit('like', this.liked, this);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁这个弹幕
|
||||
*/
|
||||
destroy() {
|
||||
this.showEnd();
|
||||
Danmaku.all.delete(this);
|
||||
if (this.floor) {
|
||||
const floor = Danmaku.allInPos[this.floor];
|
||||
if (floor) {
|
||||
delete floor[`${this.x},${this.y}`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createSpecVNode(type: string, content: string): VNode {
|
||||
if (Danmaku.specList[type]) {
|
||||
return Danmaku.specList[type](content, type);
|
||||
} else {
|
||||
logger.severe(1, `Unknown special danmaku element: ${type}.`);
|
||||
}
|
||||
|
||||
return h('span');
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取本塔所有弹幕
|
||||
*/
|
||||
static async fetch() {
|
||||
const form = toFormData({
|
||||
type: 1,
|
||||
towername: 'HumanBreak'
|
||||
});
|
||||
/* @__PURE__ */ form.append('userid', id);
|
||||
/* @__PURE__ */ form.append('password', password);
|
||||
const data = await axios.post<DanmakuResponse>(Danmaku.backend, form);
|
||||
|
||||
data.data.list.forEach(v => {
|
||||
const dan = new Danmaku();
|
||||
dan.id = v.id;
|
||||
dan.likedNum = v.love;
|
||||
dan.decode(v);
|
||||
dan.posted = true;
|
||||
dan.addToList();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示一个弹幕
|
||||
* @param dan 要显示的弹幕
|
||||
*/
|
||||
static show(dan: Danmaku) {
|
||||
dan.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个特殊显示内容
|
||||
* @param type 特殊内容类型
|
||||
* @param fn 特殊内容显示函数,返回VNode
|
||||
*/
|
||||
static registerSpecContent(type: string, fn: SpecContentFn) {
|
||||
if (this.specList[type]) {
|
||||
logger.warn(6, `Registered special danmaku element: ${type}`);
|
||||
}
|
||||
this.specList[type] = fn;
|
||||
}
|
||||
}
|
||||
|
||||
// 图标类型
|
||||
Danmaku.registerSpecContent('i', content => {
|
||||
const iconInfo = core.getBlockInfo(content as AllIds);
|
||||
if (!iconInfo) {
|
||||
return h(BoxAnimate as Component, {
|
||||
id: 'greenSlime',
|
||||
noborder: true,
|
||||
width: 32,
|
||||
height: 32
|
||||
});
|
||||
}
|
||||
|
||||
return h(BoxAnimate as Component, {
|
||||
id: content,
|
||||
noborder: true,
|
||||
width: 32,
|
||||
height: iconInfo.height
|
||||
});
|
||||
});
|
||||
|
||||
/* @__PURE__ */ Danmaku.backend = `/danmaku`;
|
||||
Mota.require('var', 'hook').once('reset', () => {
|
||||
Danmaku.fetch();
|
||||
});
|
@ -33,7 +33,8 @@ fixedUi.register(
|
||||
new GameUi('completeAchi', UI.CompleteAchi),
|
||||
new GameUi('start', UI.Start),
|
||||
new GameUi('toolbar', UI.Toolbar),
|
||||
new GameUi('load', UI.Load)
|
||||
new GameUi('load', UI.Load),
|
||||
new GameUi('danmaku', UI.Danmaku)
|
||||
);
|
||||
fixedUi.showAll();
|
||||
|
||||
|
@ -9,6 +9,7 @@ import settingsText from '@/data/settings.json';
|
||||
import { isMobile } from '@/plugin/use';
|
||||
import { fontSize } from '@/plugin/ui/statusBar';
|
||||
import { CustomToolbar } from './custom/toolbar';
|
||||
import { fixedUi } from './init/ui';
|
||||
|
||||
export interface SettingComponentProps {
|
||||
item: MotaSettingItem;
|
||||
@ -410,6 +411,12 @@ function handleUiSetting<T extends number | boolean>(key: string, n: T, o: T) {
|
||||
v.setSize(v.width * scale, v.height * scale);
|
||||
});
|
||||
CustomToolbar.refreshAll(true);
|
||||
} else if (key === 'danmaku') {
|
||||
if (n) {
|
||||
fixedUi.open('danmaku');
|
||||
} else {
|
||||
fixedUi.closeByName('danmaku');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -478,6 +485,8 @@ mainSetting
|
||||
.setDisplayFunc('toolbarScale', value => `${value}%`)
|
||||
.register('bookScale', '怪物手册缩放', 100, COM.Number, [10, 500, 10])
|
||||
.setDisplayFunc('bookScale', value => `${value}%`)
|
||||
.register('danmaku', '显示弹幕', true, COM.Boolean)
|
||||
.register('danmakuSpeed', '弹幕速度', 60, COM.Number, [10, 200, 5])
|
||||
);
|
||||
|
||||
const loading = Mota.require('var', 'loading');
|
||||
@ -511,6 +520,8 @@ loading.once('coreInit', () => {
|
||||
isMobile ? 50 : Math.floor((window.innerWidth / 1700) * 10) * 10
|
||||
),
|
||||
'ui.bookScale': storage.getValue('ui.bookScale', isMobile ? 100 : 80),
|
||||
'ui.danmaku': storage.getValue('ui.danmaku', true),
|
||||
'ui.danmakuSpeed': storage.getValue('ui.danmakuSpeed', 60),
|
||||
});
|
||||
});
|
||||
|
||||
@ -545,6 +556,8 @@ mainSetting
|
||||
.setDescription('ui.mapScale', `楼传小地图的缩放,百分比格式`)
|
||||
.setDescription('ui.toolbarScale', `自定义工具栏的缩放比例`)
|
||||
.setDescription('ui.bookScale', `怪物手册界面中每个怪物框体的高度缩放,最小值限定为 20% 屏幕高度`)
|
||||
.setDescription('ui.danmaku', '是否显示弹幕')
|
||||
.setDescription('ui.danmakuSpeed', '弹幕速度,刷新或开关弹幕显示后起效')
|
||||
.setDescription('screen.fontSizeStatus', `修改状态栏的字体大小`)
|
||||
.setDescription('screen.blur', '打开任意ui界面时是否有背景虚化效果,移动端打开后可能会有掉帧或者发热现象。关闭ui后生效');
|
||||
|
||||
|
@ -23,6 +23,7 @@ import type * as battle from './enemy/battle';
|
||||
import type * as hero from './hero';
|
||||
import type * as damage from './enemy/damage';
|
||||
import type { Logger } from '@/core/common/logger';
|
||||
import type { Danmaku } from '@/core/main/custom/danmaku';
|
||||
|
||||
interface ClassInterface {
|
||||
// 渲染进程与游戏进程通用
|
||||
@ -48,6 +49,7 @@ interface ClassInterface {
|
||||
Range: typeof Range;
|
||||
EnemyCollection: typeof EnemyCollection;
|
||||
DamageEnemy: typeof DamageEnemy;
|
||||
Danmaku: typeof Danmaku;
|
||||
}
|
||||
|
||||
type _IBattle = typeof battle;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { tip } from './utils';
|
||||
|
||||
export default function init() {
|
||||
return { useDrag, useWheel, useUp, isMobile };
|
||||
}
|
||||
@ -29,7 +31,8 @@ checkMobile();
|
||||
|
||||
function checkMobile() {
|
||||
if (isMobile && !alerted) {
|
||||
alert(
|
||||
tip(
|
||||
'info',
|
||||
'手机端建议使用新版APP或者自带的浏览器进行游玩,并在进入游戏后开启游戏内的全屏设置游玩'
|
||||
);
|
||||
alerted = true;
|
||||
|
@ -119,3 +119,9 @@ div.toolbar-editor-item {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.danmaku {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
210
src/ui/danmaku.vue
Normal file
210
src/ui/danmaku.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div id="danmaku-div">
|
||||
<div
|
||||
:id="`danmaku-${one.id}`"
|
||||
class="danmaku-one"
|
||||
v-for="one of Danmaku.showList"
|
||||
:key="one.id"
|
||||
@mouseenter="mousein(one.id)"
|
||||
@mouseleave="mouseleave(one.id)"
|
||||
@touchstart="touchStart(one.id)"
|
||||
>
|
||||
<span class="danmaku-info">
|
||||
<like-filled
|
||||
class="danmaku-like-icon"
|
||||
:liked="one.liked"
|
||||
@click="one.triggerLike()"
|
||||
/>
|
||||
<span class="danmaku-like-num">{{ one.likedNum }}</span>
|
||||
</span>
|
||||
<component :is="one.getVNode()"></component>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onUnmounted, reactive, watch } from 'vue';
|
||||
import { Danmaku } from '../core/main/custom/danmaku';
|
||||
import { LikeFilled } from '@ant-design/icons-vue';
|
||||
import { Ticker } from 'mutate-animate';
|
||||
import { mainSetting } from '@/core/main/setting';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
interface ElementMap {
|
||||
pos: number;
|
||||
ele: HTMLDivElement;
|
||||
danmaku: Danmaku;
|
||||
style: CSSStyleDeclaration;
|
||||
width: number;
|
||||
hover: boolean;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const map = Danmaku.showMap;
|
||||
const eleMap: Map<number, ElementMap> = new Map();
|
||||
const liked = reactive<Record<number, boolean>>({});
|
||||
const ticker = new Ticker();
|
||||
|
||||
const speed = mainSetting.getValue('ui.danmakuSpeed', 60);
|
||||
|
||||
const likeFn = (l: boolean, d: Danmaku) => {
|
||||
liked[d.id] = l;
|
||||
};
|
||||
|
||||
watch(Danmaku.showList, list => {
|
||||
list.forEach(v => {
|
||||
liked[v.id] = v.liked;
|
||||
v.on('like', likeFn);
|
||||
if (!eleMap.has(v.id)) {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
addElement(v.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function addElement(id: number) {
|
||||
const danmaku = map.get(id);
|
||||
if (!danmaku) return;
|
||||
const div = document.getElementById(`danmaku-${id}`);
|
||||
if (!div) return;
|
||||
|
||||
const style = getComputedStyle(div);
|
||||
|
||||
const ele: ElementMap = {
|
||||
danmaku,
|
||||
pos: window.innerWidth + 100,
|
||||
ele: div as HTMLDivElement,
|
||||
style,
|
||||
width: parseInt(style.width),
|
||||
hover: false,
|
||||
top: -1
|
||||
};
|
||||
|
||||
eleMap.set(id, ele);
|
||||
calTop(id);
|
||||
}
|
||||
|
||||
let lastTime = 0;
|
||||
ticker.add(time => {
|
||||
const dt = (time - lastTime) / 1000;
|
||||
const toDelete: number[] = [];
|
||||
|
||||
eleMap.forEach((value, id) => {
|
||||
const { danmaku, ele, style, width, hover } = value;
|
||||
if (!hover) {
|
||||
const dx = dt * speed;
|
||||
value.pos -= dx;
|
||||
ele.style.left = `${value.pos.toFixed(2)}px`;
|
||||
}
|
||||
|
||||
if (value.pos + width < 0) {
|
||||
toDelete.push(id);
|
||||
}
|
||||
});
|
||||
lastTime = time;
|
||||
|
||||
toDelete.forEach(v => {
|
||||
eleMap.delete(v);
|
||||
const danmaku = map.get(v);
|
||||
if (danmaku) {
|
||||
danmaku.showEnd();
|
||||
}
|
||||
map.delete(v);
|
||||
});
|
||||
});
|
||||
|
||||
function mousein(id: number) {
|
||||
const danmaku = eleMap.get(id)!;
|
||||
danmaku.hover = true;
|
||||
}
|
||||
|
||||
function mouseleave(id: number) {
|
||||
const danmaku = eleMap.get(id)!;
|
||||
danmaku.hover = false;
|
||||
}
|
||||
|
||||
const touchDebounce = debounce(mouseleave, 3000);
|
||||
function touchStart(id: number) {
|
||||
mousein(id);
|
||||
touchDebounce(id);
|
||||
}
|
||||
|
||||
function calTop(id: number) {
|
||||
const danmaku = eleMap.get(id)!;
|
||||
const fontSize = mainSetting.getValue('screen.fontSize', 16) * 1.25 + 10;
|
||||
|
||||
const used: Set<number> = new Set();
|
||||
eleMap.forEach(v => {
|
||||
const { pos, width } = v;
|
||||
if (
|
||||
pos <= window.innerWidth + 125 &&
|
||||
pos + width >= window.innerWidth + 75
|
||||
) {
|
||||
used.add(v.top);
|
||||
}
|
||||
});
|
||||
let i = -1;
|
||||
while (++i < 20) {
|
||||
if (!used.has(i)) {
|
||||
danmaku.top = i;
|
||||
danmaku.ele.style.top = `${fontSize * i + 20}px`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
ticker.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
#danmaku-div {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: visible;
|
||||
font-size: 150%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.danmaku-one {
|
||||
position: fixed;
|
||||
left: 100vw;
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
text-wrap: nowrap;
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.danmaku-one:hover {
|
||||
background-color: #0006;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.danmaku-info {
|
||||
text-shadow: 1px 1px 1px black, 1px -1px 1px black, -1px 1px 1px black,
|
||||
-1px -1px 1px black;
|
||||
}
|
||||
|
||||
.danmaku-like-num {
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.danmaku-like-icon {
|
||||
transition: color 0.1s linear;
|
||||
}
|
||||
|
||||
.danmaku-like-icon[liked='true'],
|
||||
.danmaku-like-icon:hover {
|
||||
color: aqua;
|
||||
}
|
||||
</style>
|
@ -23,3 +23,4 @@ export { default as Hotkey } from './hotkey.vue';
|
||||
export { default as Toolbar } from './toolbar.vue';
|
||||
export { default as ToolEditor } from './toolEditor.vue';
|
||||
export { default as Load } from './load.vue';
|
||||
export { default as Danmaku } from './danmaku.vue';
|
||||
|
2
user.ts
Normal file
2
user.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const id = 2691;
|
||||
export const password = '26e631510147c1d0b71a368a3729df5a';
|
@ -75,7 +75,8 @@ export default defineConfig({
|
||||
rewrite(path) {
|
||||
return path.replace(/^\/forceTem/, '');
|
||||
},
|
||||
}
|
||||
},
|
||||
'/danmaku': 'https://h5mota.com/backend/tower/barrage.php'
|
||||
},
|
||||
watch: {
|
||||
ignored: ['**/public/**']
|
||||
|
Loading…
Reference in New Issue
Block a user