diff --git a/.gitignore b/.gitignore index 5a3c58b..06bda30 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ dam3.png dam4.png meeting.md -special.csv +*.csv script/special.ts +script/people.ts docs/ diff --git a/src/components/boxAnimate.vue b/src/components/boxAnimate.vue index 26a68cc..fe2b5f9 100644 --- a/src/components/boxAnimate.vue +++ b/src/components/boxAnimate.vue @@ -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(); }); diff --git a/src/core/common/logger.ts b/src/core/common/logger.ts index 652b27a..771aa5e 100644 --- a/src/core/common/logger.ts +++ b/src/core/common/logger.ts @@ -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); diff --git a/src/core/index.ts b/src/core/index.ts index ee9b341..8866b43 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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); diff --git a/src/core/interface.ts b/src/core/interface.ts index e96a5db..bde8d78 100644 --- a/src/core/interface.ts +++ b/src/core/interface.ts @@ -12,3 +12,8 @@ export interface Undoable { */ redo(): T | undefined; } + +export interface ResponseBase { + code: number; + message: string; +} diff --git a/src/core/main/custom/danmaku.ts b/src/core/main/custom/danmaku.ts new file mode 100644 index 0000000..f58d21b --- /dev/null +++ b/src/core/main/custom/danmaku.ts @@ -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>; + +interface DanmakuResponse extends ResponseBase { + total: number; + list: DanmakuInfo[]; +} + +interface DanmakuInfo { + id: number; + comment: string; + tags: string; + love: number; +} + +interface DanmakuPostInfo extends Partial { + 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 { + static num: number = 0; + static backend: string = `/backend/tower/barrage.php`; + static all: Set = new Set(); + static allInPos: Partial>> = {}; + + static showList: Danmaku[] = shallowReactive([]); + static showMap: Map = new Map(); + static specList: Record = {}; + + 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( + 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(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(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(); +}); diff --git a/src/core/main/init/ui.ts b/src/core/main/init/ui.ts index f3f9b49..b568ede 100644 --- a/src/core/main/init/ui.ts +++ b/src/core/main/init/ui.ts @@ -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(); diff --git a/src/core/main/setting.ts b/src/core/main/setting.ts index 93b9962..84ff36e 100644 --- a/src/core/main/setting.ts +++ b/src/core/main/setting.ts @@ -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(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后生效'); diff --git a/src/game/system.ts b/src/game/system.ts index 223e720..dd8fd9e 100644 --- a/src/game/system.ts +++ b/src/game/system.ts @@ -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; diff --git a/src/plugin/use.ts b/src/plugin/use.ts index b148aa7..721cd05 100644 --- a/src/plugin/use.ts +++ b/src/plugin/use.ts @@ -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; diff --git a/src/styles.less b/src/styles.less index 44c4f52..2c3fdcb 100644 --- a/src/styles.less +++ b/src/styles.less @@ -119,3 +119,9 @@ div.toolbar-editor-item { align-items: center; width: 100%; } + +.danmaku { + margin-left: 10px; + display: flex; + align-items: center; +} diff --git a/src/ui/danmaku.vue b/src/ui/danmaku.vue new file mode 100644 index 0000000..002a5ac --- /dev/null +++ b/src/ui/danmaku.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/src/ui/index.ts b/src/ui/index.ts index f34d217..6bdd1c5 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/user.ts b/user.ts new file mode 100644 index 0000000..be2826b --- /dev/null +++ b/user.ts @@ -0,0 +1,2 @@ +export const id = 2691; +export const password = '26e631510147c1d0b71a368a3729df5a'; diff --git a/vite.config.ts b/vite.config.ts index 81a4668..5b70f3a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -75,7 +75,8 @@ export default defineConfig({ rewrite(path) { return path.replace(/^\/forceTem/, ''); }, - } + }, + '/danmaku': 'https://h5mota.com/backend/tower/barrage.php' }, watch: { ignored: ['**/public/**']