feat: 弹幕系统

This commit is contained in:
unanmed 2024-04-22 23:08:46 +08:00
parent 5b74a22494
commit 27230b3d47
15 changed files with 696 additions and 8 deletions

3
.gitignore vendored
View File

@ -43,6 +43,7 @@ dam3.png
dam4.png
meeting.md
special.csv
*.csv
script/special.ts
script/people.ts
docs/

View File

@ -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();
});

View File

@ -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);

View File

@ -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);

View File

@ -12,3 +12,8 @@ export interface Undoable<T> {
*/
redo(): T | undefined;
}
export interface ResponseBase {
code: number;
message: string;
}

View 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();
});

View File

@ -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();

View File

@ -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后生效');

View File

@ -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;

View File

@ -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;

View File

@ -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
View 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>

View File

@ -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
View File

@ -0,0 +1,2 @@
export const id = 2691;
export const password = '26e631510147c1d0b71a368a3729df5a';

View File

@ -75,7 +75,8 @@ export default defineConfig({
rewrite(path) {
return path.replace(/^\/forceTem/, '');
},
}
},
'/danmaku': 'https://h5mota.com/backend/tower/barrage.php'
},
watch: {
ignored: ['**/public/**']