diff --git a/public/project/floors/MT22.js b/public/project/floors/MT22.js index 6628806..ecc141c 100644 --- a/public/project/floors/MT22.js +++ b/public/project/floors/MT22.js @@ -40,7 +40,7 @@ main.floors.MT22= "第二章的加点已开启,可以在技能树的前置技能下方选择", "如果你玩过上个版本,直接跳到了本章,记得查看背包里面的各种道具,尤其是百科全书,同时注意左边是你来的方向,那里还有些怪物", "从现在开始,跳跃技能不再消耗生命值,别忘了你还有跳跃技能", - "为了确保平衡与可玩性,从现在开始,无上之盾(第一章终极技能)效果变为1/10,同时每个怪物的最大吸血量限制为攻防和" + "为了确保平衡与可玩性,从现在开始,无上之盾(第一章终极技能)效果变为1/10,同时每个怪物的最大吸血量限制为怪物生命值的四分之一" ], "7,9": [ "百科全书中已解锁第二章需要特别说明的怪物属性,你可以在百科全书中查看", diff --git a/script/dev.ts b/script/dev.ts index d54232b..a83a86a 100644 --- a/script/dev.ts +++ b/script/dev.ts @@ -33,6 +33,8 @@ interface RollupInfo { const rollupMap = new Map(); let bundleIndex = 0; let ws: WebSocket; +let h: Server; +let wt: chokidar.FSWatcher; class RefValue extends EventEmitter { private _value: T; @@ -448,6 +450,7 @@ ${names} } async function startHttpServer(port: number = 3000) { + if (h) return h; const server = http(); const tryNext = () => { @@ -516,6 +519,7 @@ function setupHttp(server: Server) { } function watchProject() { + if (wt) return; const watcher = chokidar.watch('public/', { persistent: true, ignored: [ @@ -530,6 +534,7 @@ function watchProject() { /_.*/ ] }); + wt = watcher; watcher.removeAllListeners(); watcher.on('change', async path => { // 楼层热重载 @@ -574,6 +579,7 @@ function setupSocket(socket: WebSocket) { } async function startWsServer(http: Server) { + if (ws) return; return new Promise(res => { const server = new WebSocketServer({ server: http @@ -603,6 +609,7 @@ async function ensureConfig() { // 2. 启动样板http服务 await ensureConfig(); const server = await startHttpServer(3000); + h = server; // 3. 启动样板ws热重载服务 await startWsServer(server); diff --git a/src/components/boxAnimate.vue b/src/components/boxAnimate.vue index fe2b5f9..9ea5a88 100644 --- a/src/components/boxAnimate.vue +++ b/src/components/boxAnimate.vue @@ -18,6 +18,7 @@ const props = defineProps<{ noborder?: boolean; width?: number; height?: number; + noAnimate?: boolean; }>(); let c: HTMLCanvasElement; @@ -26,7 +27,6 @@ let ctx: CanvasRenderingContext2D; let drawFn: () => void; function draw() { - if (id === 'none') return; if (has(drawFn)) removeAnimate(drawFn); const cls = core.getClsFromId(props.id as AllIds); @@ -58,12 +58,16 @@ function draw() { } else { drawFn = () => { core.clearMap(ctx); - const frame = core.status.globalAnimateStatus % frames; + const frame = props.noAnimate + ? 0 + : core.status.globalAnimateStatus % frames; core.drawIcon(ctx, props.id as AllIds, 0, 0, w, h, frame); }; drawFn(); - addAnimate(drawFn); + if (!props.noAnimate) { + addAnimate(drawFn); + } } } diff --git a/src/components/scroll.vue b/src/components/scroll.vue index d33a0fd..96b3547 100644 --- a/src/components/scroll.vue +++ b/src/components/scroll.vue @@ -108,11 +108,13 @@ async function calHeight(first: boolean = false) { canvas.height = width * scale; if (props.noScroll) canvas.style.height = `0px`; } - await new Promise(res => { + await new Promise(res => { requestAnimationFrame(() => { - const style = getComputedStyle(content); - total = parseFloat(style[canvasAttr]); - res(''); + total = + props.type === 'horizontal' + ? content.scrollWidth + : content.scrollHeight; + res(); }); }); } diff --git a/src/core/common/logger.ts b/src/core/common/logger.ts index 771aa5e..05c248d 100644 --- a/src/core/common/logger.ts +++ b/src/core/common/logger.ts @@ -11,6 +11,17 @@ export const enum LogLevel { ERROR } +interface LoggerCatchInfo { + code?: number; + level: LogLevel; + message: string; +} + +interface LoggerCatchReturns { + ret: T; + info: LoggerCatchInfo[]; +} + let logTip: HTMLSpanElement; if (!main.replayChecking) { const tip = document.createElement('span'); @@ -38,6 +49,9 @@ export class Logger { level: LogLevel = LogLevel.LOG; enabled: boolean = true; + private catching: boolean = false; + private catchedInfo: LoggerCatchInfo[] = []; + constructor(logLevel: LogLevel) { this.level = logLevel; } @@ -56,6 +70,13 @@ export class Logger { * @param text 错误信息 */ error(code: number, text: string) { + if (this.catching) { + this.catchedInfo.push({ + level: LogLevel.ERROR, + message: text, + code + }); + } if (this.level <= LogLevel.ERROR && this.enabled) { console.error(`[ERROR Code ${code}] ${text}`); if (!main.replayChecking) { @@ -73,6 +94,13 @@ export class Logger { * @param text 警告信息 */ severe(code: number, text: string) { + if (this.catching) { + this.catchedInfo.push({ + level: LogLevel.ERROR, + message: text, + code + }); + } if (this.level <= LogLevel.SEVERE_WARNING && this.enabled) { console.warn(`[SEVERE WARNING Code ${code}] ${text}`); if (!main.replayChecking) { @@ -90,6 +118,13 @@ export class Logger { * @param text 警告信息 */ warn(code: number, text: string) { + if (this.catching) { + this.catchedInfo.push({ + level: LogLevel.ERROR, + message: text, + code + }); + } if (this.level <= LogLevel.WARNING && this.enabled) { console.warn(`[WARNING Code ${code}] ${text}`); if (!main.replayChecking) { @@ -106,11 +141,29 @@ export class Logger { * @param text 日志信息 */ log(text: string) { + if (this.catching) { + this.catchedInfo.push({ + level: LogLevel.ERROR, + message: text + }); + } if (this.level <= LogLevel.LOG && this.enabled) { console.log(`[LOG] ${text}`); } } + catch(fn: () => T): LoggerCatchReturns { + const before = this.enabled; + this.catchedInfo = []; + this.disable(); + this.catching = true; + const ret = fn(); + this.catching = false; + if (before) this.enable(); + + return { ret, info: this.catchedInfo }; + } + disable() { this.enabled = false; } diff --git a/src/core/main/custom/danmaku.ts b/src/core/main/custom/danmaku.ts index e13b0ed..3cb67c3 100644 --- a/src/core/main/custom/danmaku.ts +++ b/src/core/main/custom/danmaku.ts @@ -3,8 +3,8 @@ 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'; +import axios, { AxiosResponse, toFormData } from 'axios'; +import { Component, VNode, h, shallowReactive } from 'vue'; /* @__PURE__ */ import { id, password } from '../../../../user'; import { mainSetting } from '../setting'; @@ -49,17 +49,45 @@ interface DanmakuEvent extends EmitableEvent { type SpecContentFn = (content: string, type: string) => VNode; +interface AllowedCSS { + property: string; + check: (value: string, prop: string) => true | string; +} + +const allowedCSS: Partial> = { + color: { + property: 'color', + check: () => true + }, + backgroundColor: { + property: 'backgroundColor', + check: () => true + }, + fontSize: { + property: 'fontSize', + check: value => { + if (!/^\d+%$/.test(value)) { + return '字体大小只能设置为百分格式'; + } + if (parseInt(value) > 200) { + return '字体最大只能为200%'; + } + return true; + } + } +}; + 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 allInPos: Partial>> = + {}; static showList: Danmaku[] = shallowReactive([]); static showMap: Map = new Map(); static specList: Record = {}; - num: number = Danmaku.num++; + static lastEditoredDanmaku?: Danmaku; id: number = -1; text: string = ''; @@ -82,10 +110,10 @@ export class Danmaku extends EventEmitter { * 发送弹幕 * @returns 弹幕发送的 Axios Post 信息,为 Promise */ - async post() { + async post(): Promise> { if (this.posted || this.posting) { logger.warn(5, `Repeat post danmaku.`); - return Promise.resolve(); + return Promise.reject(); } const data: DanmakuPostInfo = { @@ -108,6 +136,14 @@ export class Danmaku extends EventEmitter { this.id = res.data.id; this.posting = false; + if (res.data.code === 0) { + this.posted = true; + tip('success', '发送成功'); + this.addToList(); + } else { + tip('error', res.data.message); + } + return res; } catch (e) { this.posted = false; @@ -116,7 +152,7 @@ export class Danmaku extends EventEmitter { 3, `Unexpected error when posting danmaku. Error info: ${e}` ); - return Promise.resolve(); + return Promise.reject(); } } @@ -160,7 +196,7 @@ export class Danmaku extends EventEmitter { 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; + return { ...css, ...this.style }; } /** @@ -195,9 +231,17 @@ export class Danmaku extends EventEmitter { 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 }; + const allow = Danmaku.checkCSSAllow(res); + if (allow.length === 0) { + if (overwrite) this.style = res; + else { + this.style = { ...this.style, ...res }; + } + } else { + logger.error( + 8, + `Post not allowed css danmaku. Allow info: ${allow.join(',')}` + ); } } @@ -208,7 +252,8 @@ export class Danmaku extends EventEmitter { Danmaku.all.add(this); if (!this.floor) return; Danmaku.allInPos[this.floor] ??= {}; - Danmaku.allInPos[this.floor]![`${this.x},${this.y}`] = this; + Danmaku.allInPos[this.floor]![`${this.x},${this.y}`] ??= []; + Danmaku.allInPos[this.floor]![`${this.x},${this.y}`].push(this); } /** @@ -373,6 +418,27 @@ export class Danmaku extends EventEmitter { return h('span'); } + /** + * 检查CSS内容是否符合发弹幕要求 + * @param css 要检查的CSS内容 + */ + static checkCSSAllow(css: CSSObj) { + const problem: string[] = []; + for (const [key, value] of Object.entries(css)) { + if (!allowedCSS[key as CanParseCss]) { + problem.push(`不允许的CSS:${key}`); + continue; + } else { + const res = allowedCSS[key as CanParseCss]!.check(value, key); + if (res !== true) { + problem.push(res); + } + } + } + + return problem; + } + /** * 拉取本塔所有弹幕 */ @@ -431,6 +497,7 @@ Danmaku.registerSpecContent('i', content => { return h(BoxAnimate as Component, { id: content, noborder: true, + noAnimate: true, width: 32, height: iconInfo.height }); @@ -450,7 +517,11 @@ Mota.require('var', 'hook').on('moveOneStep', (x, y, floor) => { if (f) { const danmaku = f[`${x},${y}`]; if (danmaku) { - danmaku.show(); + danmaku.forEach(v => { + setTimeout(() => { + v.show(); + }, Math.random() * 1000); + }); } } }); diff --git a/src/core/main/init/hotkey.ts b/src/core/main/init/hotkey.ts index 0d462ed..d0fa46c 100644 --- a/src/core/main/init/hotkey.ts +++ b/src/core/main/init/hotkey.ts @@ -1,6 +1,6 @@ import { KeyCode } from '@/plugin/keyCodes'; import { Hotkey, HotkeyJSON } from '../custom/hotkey'; -import { generateBinary, keycode } from '@/plugin/utils'; +import { generateBinary, keycode, openDanmakuPoster } from '@/plugin/utils'; import { hovered } from './fixed'; import { hasMarkedEnemy, markEnemy, unmarkEnemy } from '@/plugin/mark'; import { mainUi } from './ui'; @@ -136,6 +136,12 @@ gameKey name: '鼠标位置怪物临界', defaults: KeyCode.KeyC }) + .register({ + id: 'danmaku', + name: '发送弹幕', + defaults: KeyCode.KeyA, + ctrl: true + }) .register({ id: 'quickEquip_1', name: '切换/保存套装_1', @@ -486,6 +492,9 @@ gameKey if (enemy) mainUi.open('fixedDetail', { panel: 'critical' }); } }) + .realize('danmaku', () => { + openDanmakuPoster(); + }) .realize('restart', () => { core.confirmRestart(); }) diff --git a/src/core/main/init/ui.ts b/src/core/main/init/ui.ts index b568ede..594a59e 100644 --- a/src/core/main/init/ui.ts +++ b/src/core/main/init/ui.ts @@ -34,13 +34,11 @@ fixedUi.register( new GameUi('start', UI.Start), new GameUi('toolbar', UI.Toolbar), new GameUi('load', UI.Load), - new GameUi('danmaku', UI.Danmaku) + new GameUi('danmaku', UI.Danmaku), + new GameUi('danmakuEditor', UI.DanmakuEditor) ); fixedUi.showAll(); -let loaded = false; -let mounted = false; - const hook = Mota.require('var', 'hook'); hook.once('mounted', () => { const ui = document.getElementById('ui-main')!; @@ -73,6 +71,4 @@ hook.once('mounted', () => { fixedUi.on('end', () => { fixed.style.display = 'none'; }); - - mounted = true; }); diff --git a/src/core/main/setting.ts b/src/core/main/setting.ts index 5d3cee0..250a3bd 100644 --- a/src/core/main/setting.ts +++ b/src/core/main/setting.ts @@ -523,7 +523,7 @@ loading.once('coreInit', () => { 'ui.danmaku': storage.getValue('ui.danmaku', true), 'ui.danmakuSpeed': storage.getValue( 'ui.danmakuSpeed', - Math.floor(window.innerWidth / 25) * 5 + Math.floor(window.innerWidth / 30) * 5 ), }); }); diff --git a/src/core/plugin.ts b/src/core/plugin.ts index 26cedbd..b556440 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -27,6 +27,7 @@ import * as frag from '@/plugin/fx/frag'; import * as use from '@/plugin/use'; import * as gameCanvas from '@/plugin/fx/gameCanvas'; import * as smooth from '@/plugin/fx/smoothView'; +import * as animateController from '@/plugin/animateController'; Mota.Plugin.register('shadow_r', shadow, shadow.init); Mota.Plugin.register('gameShadow_r', gameShadow, gameShadow.init); @@ -38,3 +39,8 @@ Mota.Plugin.register('frag_r', frag, frag.init); Mota.Plugin.register('use_r', use); Mota.Plugin.register('gameCanvas_r', gameCanvas); Mota.Plugin.register('smooth_r', smooth, smooth.init); +Mota.Plugin.register( + 'animateController_r', + animateController, + animateController.default +); diff --git a/src/plugin/utils.ts b/src/plugin/utils.ts index 7a44f7e..11e1305 100644 --- a/src/plugin/utils.ts +++ b/src/plugin/utils.ts @@ -8,8 +8,9 @@ import axios from 'axios'; import { decompressFromBase64 } from 'lz-string'; import { parseColor } from './webgl/utils'; import { Keyboard, KeyboardEmits } from '@/core/main/custom/keyboard'; -import { mainUi } from '@/core/main/init/ui'; +import { fixedUi, mainUi } from '@/core/main/init/ui'; import { isAssist } from '@/core/main/custom/hotkey'; +import { logger } from '@/core/common/logger'; type CanParseCss = keyof { [P in keyof CSSStyleDeclaration as CSSStyleDeclaration[P] extends string @@ -82,20 +83,110 @@ export function keycode(key: number) { * @param css 要解析的css字符串 */ export function parseCss(css: string): Partial> { - const str = css.replace(/[\n\s\t]*/g, '').replace(/;*/g, ';'); - const styles = str.split(';'); + if (css.length === 0) return {}; + + let pointer = -1; + let inProp = true; + let prop = ''; + let value = ''; + let upper = false; const res: Partial> = {}; - for (const one of styles) { - const [key, data] = one.split(':'); - const cssKey = key.replace(/\-([a-z])/g, (str, $1) => - $1.toUpperCase() - ) as CanParseCss; - res[cssKey] = data; + while (++pointer < css.length) { + const char = css[pointer]; + + if ((char === ' ' || char === '\n' || char === '\r') && inProp) { + continue; + } + + if (char === '-' && inProp) { + if (prop.length !== 0) { + upper = true; + } + continue; + } + + if (char === ':') { + if (!inProp) { + logger.error( + 3, + `Syntax error in parsing CSS: Unexpected ':'. Col: ${pointer}. CSS string: '${css}'` + ); + return res; + } + inProp = false; + continue; + } + + if (char === ';') { + if (prop.length === 0) continue; + if (inProp) { + logger.error( + 4, + `Syntax error in parsing CSS: Unexpected ';'. Col: ${pointer}. CSS string: '${css}'` + ); + return res; + } + res[prop as CanParseCss] = value.trim(); + inProp = true; + prop = ''; + value = ''; + continue; + } + + if (upper) { + if (!inProp) { + logger.error( + 5, + `Syntax error in parsing CSS: Missing property name after '-'. Col: ${pointer}. CSS string: '${css}'` + ); + } + prop += char.toUpperCase(); + upper = false; + } else { + if (inProp) prop += char; + else value += char; + } } + if (inProp && prop.length > 0) { + logger.error( + 6, + `Syntax error in parsing CSS: Unexpected end of css, expecting ':'. Col: ${pointer}. CSS string: '${css}'` + ); + return res; + } + if (!inProp && value.trim().length === 0) { + logger.error( + 7, + `Syntax error in parsing CSS: Unexpected end of css, expecting property value. Col: ${pointer}. CSS string: '${css}'` + ); + return res; + } + if (prop.length > 0) res[prop as CanParseCss] = value.trim(); + return res; } +export function stringifyCSS(css: Partial>) { + let str = ''; + + for (const [key, value] of Object.entries(css)) { + let pointer = -1; + let prop = ''; + while (++pointer < key.length) { + const char = key[pointer]; + if (char.toLowerCase() === char) { + prop += char; + } else { + prop += `-${char.toLowerCase()}`; + } + } + str += `${prop}:${value};`; + } + + return str; +} + /** * 使用打字机效果显示一段文字 * @param str 要打出的字符串 @@ -401,3 +492,22 @@ let num = 0; export function requireUniqueSymbol() { return num++; } + +export function openDanmakuPoster() { + if (!fixedUi.hasName('danmakuEditor')) { + fixedUi.open('danmakuEditor'); + } +} + +export function getIconHeight(icon: AllIds | 'hero') { + if (icon === 'hero') { + if (core.isPlaying()) { + return ( + core.material.images.images[core.status.hero.image].height / 4 + ); + } else { + return 48; + } + } + return core.getBlockInfo(icon)?.height ?? 32; +} diff --git a/src/ui/danmakuEditor.vue b/src/ui/danmakuEditor.vue new file mode 100644 index 0000000..646971f --- /dev/null +++ b/src/ui/danmakuEditor.vue @@ -0,0 +1,624 @@ + + + + + diff --git a/src/ui/index.ts b/src/ui/index.ts index 6bdd1c5..509837f 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -24,3 +24,4 @@ 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'; +export { default as DanmakuEditor } from './danmakuEditor.vue';