mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-02-28 09:27:07 +08:00
feat: 弹幕编辑器
This commit is contained in:
parent
36f0495b8e
commit
ed3ef3c16a
@ -40,7 +40,7 @@ main.floors.MT22=
|
||||
"第二章的加点已开启,可以在技能树的前置技能下方选择",
|
||||
"如果你玩过上个版本,直接跳到了本章,记得查看背包里面的各种道具,尤其是百科全书,同时注意左边是你来的方向,那里还有些怪物",
|
||||
"从现在开始,跳跃技能不再消耗生命值,别忘了你还有跳跃技能",
|
||||
"为了确保平衡与可玩性,从现在开始,无上之盾(第一章终极技能)效果变为1/10,同时每个怪物的最大吸血量限制为攻防和"
|
||||
"为了确保平衡与可玩性,从现在开始,无上之盾(第一章终极技能)效果变为1/10,同时每个怪物的最大吸血量限制为怪物生命值的四分之一"
|
||||
],
|
||||
"7,9": [
|
||||
"百科全书中已解锁第二章需要特别说明的怪物属性,你可以在百科全书中查看",
|
||||
|
@ -33,6 +33,8 @@ interface RollupInfo {
|
||||
const rollupMap = new Map<string, RollupInfo>();
|
||||
let bundleIndex = 0;
|
||||
let ws: WebSocket;
|
||||
let h: Server;
|
||||
let wt: chokidar.FSWatcher;
|
||||
|
||||
class RefValue<T> 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<WebSocketServer>(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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<void>(res => {
|
||||
requestAnimationFrame(() => {
|
||||
const style = getComputedStyle(content);
|
||||
total = parseFloat(style[canvasAttr]);
|
||||
res('');
|
||||
total =
|
||||
props.type === 'horizontal'
|
||||
? content.scrollWidth
|
||||
: content.scrollHeight;
|
||||
res();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -11,6 +11,17 @@ export const enum LogLevel {
|
||||
ERROR
|
||||
}
|
||||
|
||||
interface LoggerCatchInfo {
|
||||
code?: number;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LoggerCatchReturns<T> {
|
||||
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<T>(fn: () => T): LoggerCatchReturns<T> {
|
||||
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;
|
||||
}
|
||||
|
@ -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<Record<CanParseCss, AllowedCSS>> = {
|
||||
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<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 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++;
|
||||
static lastEditoredDanmaku?: Danmaku;
|
||||
|
||||
id: number = -1;
|
||||
text: string = '';
|
||||
@ -82,10 +110,10 @@ export class Danmaku extends EventEmitter<DanmakuEvent> {
|
||||
* 发送弹幕
|
||||
* @returns 弹幕发送的 Axios Post 信息,为 Promise
|
||||
*/
|
||||
async post() {
|
||||
async post(): Promise<AxiosResponse<PostDanmakuResponse>> {
|
||||
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<DanmakuEvent> {
|
||||
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<DanmakuEvent> {
|
||||
3,
|
||||
`Unexpected error when posting danmaku. Error info: ${e}`
|
||||
);
|
||||
return Promise.resolve();
|
||||
return Promise.reject();
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,7 +196,7 @@ export class Danmaku extends EventEmitter<DanmakuEvent> {
|
||||
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<DanmakuEvent> {
|
||||
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<DanmakuEvent> {
|
||||
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<DanmakuEvent> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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();
|
||||
})
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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
|
||||
),
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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<Record<CanParseCss, string>> {
|
||||
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<Record<CanParseCss, string>> = {};
|
||||
|
||||
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<Record<CanParseCss, string>>) {
|
||||
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;
|
||||
}
|
||||
|
624
src/ui/danmakuEditor.vue
Normal file
624
src/ui/danmakuEditor.vue
Normal file
@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div id="danmaku-editor" @click.stop>
|
||||
<div id="danmaku-input">
|
||||
<span
|
||||
class="danmaku-tool"
|
||||
:open="cssOpened"
|
||||
@click="openTool('css')"
|
||||
>css</span
|
||||
>
|
||||
<font-colors-outlined
|
||||
class="danmaku-tool"
|
||||
:open="fillOpened"
|
||||
@click="openTool('fillColor')"
|
||||
/>
|
||||
<highlight-outlined
|
||||
class="danmaku-tool"
|
||||
:open="strokeOpened"
|
||||
@click="openTool('strokeColor')"
|
||||
/>
|
||||
<meh-outlined
|
||||
class="danmaku-tool"
|
||||
:open="iconOpened"
|
||||
@click="openTool('icon')"
|
||||
/>
|
||||
<div id="danmaku-input-div">
|
||||
<a-input
|
||||
id="danmaku-input-input"
|
||||
:max-length="200"
|
||||
v-model:value="inputValue"
|
||||
placeholder="请在此输入弹幕"
|
||||
autocomplete="off"
|
||||
@change="input(inputValue)"
|
||||
@pressEnter="inputEnter()"
|
||||
/>
|
||||
</div>
|
||||
<send-outlined
|
||||
class="danmaku-tool danmaku-post"
|
||||
:posting="posting"
|
||||
@click="send()"
|
||||
/>
|
||||
</div>
|
||||
<Transition name="danmaku">
|
||||
<div v-if="cssOpened" id="danmaku-css">
|
||||
<span id="danmaku-css-hint">编辑弹幕的 CSS 样式</span>
|
||||
<a-input
|
||||
id="danmaku-css-input"
|
||||
:max-length="300"
|
||||
v-model:value="cssInfo"
|
||||
placeholder="请在此输入样式"
|
||||
autocomplete="off"
|
||||
@blur="inputCSS(cssInfo)"
|
||||
@pressEnter="inputCSS(cssInfo)"
|
||||
/>
|
||||
<span v-if="cssError" id="danmaku-css-error">{{
|
||||
cssError
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else-if="iconOpened" id="danmaku-icon">
|
||||
<span id="danmaku-icon-hint">常用图标</span>
|
||||
<Scroll
|
||||
class="danmaku-icon-scroll"
|
||||
:no-scroll="true"
|
||||
type="horizontal"
|
||||
>
|
||||
<div id="danmaku-icon-div">
|
||||
<span
|
||||
class="danmaku-icon-one"
|
||||
v-for="icon of frequentlyIcon"
|
||||
@click="addIcon(icon as AllIds)"
|
||||
>
|
||||
<BoxAnimate
|
||||
:id="(icon as AllIds)"
|
||||
:noborder="true"
|
||||
:no-animate="true"
|
||||
:height="getIconHeight(icon as AllIds)"
|
||||
></BoxAnimate>
|
||||
</span>
|
||||
</div>
|
||||
</Scroll>
|
||||
<span
|
||||
id="danmaku-icon-all"
|
||||
class="button-text"
|
||||
:active="iconAll"
|
||||
@click="iconAll = !iconAll"
|
||||
>
|
||||
所有图标 <up-outlined />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="fillOpened || strokeOpened" id="danmaku-color">
|
||||
<span id="danmaku-color-hint">设置颜色</span>
|
||||
<Scroll
|
||||
class="danmaku-color-scroll"
|
||||
:no-scroll="true"
|
||||
type="horizontal"
|
||||
>
|
||||
<div id="danmaku-color-container">
|
||||
<span
|
||||
v-for="color of frequentlyColor"
|
||||
:style="{ backgroundColor: color }"
|
||||
:selected="color === nowColor"
|
||||
@click="inputColor(color)"
|
||||
class="danmaku-color-one"
|
||||
></span>
|
||||
</div>
|
||||
</Scroll>
|
||||
<a-input
|
||||
id="danmaku-color-input"
|
||||
:max-length="100"
|
||||
v-model:value="nowColor"
|
||||
placeholder="输入颜色"
|
||||
autocomplete="off"
|
||||
@blur="inputColor(nowColor)"
|
||||
@pressEnter="inputColor(nowColor)"
|
||||
></a-input>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="danmaku-icon">
|
||||
<div v-if="iconAll" id="danmaku-icon-all-div">
|
||||
<span
|
||||
>本列表不包含额外素材,如果需要额外素材请手动填写素材id</span
|
||||
>
|
||||
<Scroll class="danmaku-all-scroll">
|
||||
<div id="danmaku-all-container">
|
||||
<span
|
||||
v-for="icon of getAllIcons()"
|
||||
@click="addIcon(icon)"
|
||||
>
|
||||
<BoxAnimate
|
||||
:id="icon"
|
||||
:height="getIconHeight(icon)"
|
||||
:no-animate="true"
|
||||
:noborder="true"
|
||||
></BoxAnimate>
|
||||
</span>
|
||||
</div>
|
||||
</Scroll>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Ref, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
FontColorsOutlined,
|
||||
HighlightOutlined,
|
||||
MehOutlined,
|
||||
SendOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Danmaku } from '@/core/main/custom/danmaku';
|
||||
import { GameUi } from '@/core/main/custom/ui';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { fixedUi } from '@/core/main/init/ui';
|
||||
import { tip } from '@/plugin/utils';
|
||||
import { gameKey } from '@/core/main/init/hotkey';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { stringifyCSS, parseCss, getIconHeight } from '@/plugin/utils';
|
||||
import { logger, LogLevel } from '@/core/common/logger';
|
||||
import Scroll from '@/components/scroll.vue';
|
||||
import BoxAnimate from '@/components/boxAnimate.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
num: number;
|
||||
ui: GameUi;
|
||||
}>();
|
||||
|
||||
const frequentlyIcon: (AllIds | 'hero' | `X${number}`)[] = [
|
||||
'hero',
|
||||
'yellowKey',
|
||||
'blueKey',
|
||||
'redKey',
|
||||
'A492',
|
||||
'A494',
|
||||
'A497',
|
||||
'redPotion',
|
||||
'redGem',
|
||||
'blueGem',
|
||||
'I559',
|
||||
'X10194',
|
||||
'downPortal',
|
||||
'leftPortal',
|
||||
'upPortal',
|
||||
'rightPortal',
|
||||
'upFloor',
|
||||
'downFloor',
|
||||
'greenSlime',
|
||||
'yellowKnight',
|
||||
'bat',
|
||||
'slimelord'
|
||||
];
|
||||
const frequentlyColor: string[] = [
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#ffff00',
|
||||
'#00ffff',
|
||||
'#ff00ff',
|
||||
'#c0c0c0',
|
||||
'#808080',
|
||||
'#800000',
|
||||
'#800080',
|
||||
'#008000',
|
||||
'#808000',
|
||||
'#000080',
|
||||
'#008080'
|
||||
];
|
||||
|
||||
let mainDiv: HTMLDivElement;
|
||||
|
||||
let danmaku = Danmaku.lastEditoredDanmaku ?? new Danmaku();
|
||||
|
||||
const cssOpened = ref(false);
|
||||
const iconOpened = ref(false);
|
||||
const fillOpened = ref(false);
|
||||
const strokeOpened = ref(false);
|
||||
const posting = ref(false);
|
||||
const iconAll = ref(false);
|
||||
const nowColor = ref('#ffffff');
|
||||
|
||||
const inputValue = ref(danmaku.text);
|
||||
const cssInfo = ref(stringifyCSS(danmaku.style));
|
||||
const cssError = ref('');
|
||||
|
||||
const map: Record<string, Ref<boolean>> = {
|
||||
css: cssOpened,
|
||||
icon: iconOpened,
|
||||
fillColor: fillOpened,
|
||||
strokeColor: strokeOpened
|
||||
};
|
||||
|
||||
function openTool(tool: string) {
|
||||
iconAll.value = false;
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (key === tool) {
|
||||
value.value = !value.value;
|
||||
} else {
|
||||
value.value = false;
|
||||
}
|
||||
}
|
||||
if (tool === 'fillColor') {
|
||||
nowColor.value = danmaku.textColor;
|
||||
} else if (tool === 'strokeColor') {
|
||||
nowColor.value = danmaku.strokeColor;
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
if (posting.value) return;
|
||||
if (danmaku.text === '') {
|
||||
tip('warning', '请填写弹幕!');
|
||||
return;
|
||||
}
|
||||
if (!core.isPlaying()) {
|
||||
tip('warning', '请进入游戏后再发送弹幕');
|
||||
return;
|
||||
}
|
||||
const { x, y } = core.status.hero.loc;
|
||||
const floor = core.status.floorId;
|
||||
if (isNil(x) || isNil(y) || isNil(floor)) {
|
||||
tip('warning', '当前无法发送弹幕');
|
||||
return;
|
||||
}
|
||||
|
||||
danmaku.x = x;
|
||||
danmaku.y = y;
|
||||
danmaku.floor = floor;
|
||||
|
||||
danmaku
|
||||
.post()
|
||||
.then(value => {
|
||||
if (value.data.code === 0) {
|
||||
danmaku.show();
|
||||
danmaku = new Danmaku();
|
||||
inputValue.value = '';
|
||||
cssInfo.value = '';
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
posting.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
mainDiv.classList.remove('danmaku-startup');
|
||||
mainDiv.classList.add('danmaku-close');
|
||||
sleep(400).then(() => {
|
||||
fixedUi.close(props.num);
|
||||
});
|
||||
}
|
||||
|
||||
function input(value: string) {
|
||||
danmaku.text = value;
|
||||
}
|
||||
|
||||
function inputEnter() {
|
||||
input(inputValue.value);
|
||||
inputCSS(cssInfo.value);
|
||||
send();
|
||||
}
|
||||
|
||||
function inputCSS(text: string) {
|
||||
const { info, ret } = logger.catch(() => {
|
||||
return parseCss(text);
|
||||
});
|
||||
|
||||
if (info.some(v => v.level > LogLevel.LOG)) {
|
||||
cssError.value = '语法错误';
|
||||
return;
|
||||
}
|
||||
|
||||
const allow = Danmaku.checkCSSAllow(ret);
|
||||
if (allow.length > 0) {
|
||||
cssError.value = allow[0];
|
||||
return;
|
||||
} else {
|
||||
cssError.value = '';
|
||||
}
|
||||
|
||||
danmaku.css(ret);
|
||||
}
|
||||
|
||||
function inputColor(color: string) {
|
||||
nowColor.value = color;
|
||||
if (fillOpened.value) {
|
||||
danmaku.textColor = color;
|
||||
} else {
|
||||
danmaku.strokeColor = color;
|
||||
}
|
||||
}
|
||||
|
||||
function addIcon(icon: AllIds | 'hero') {
|
||||
const iconText = `[i:${icon}]`;
|
||||
if (iconText.length + danmaku.text.length > 200) {
|
||||
tip('warn', '弹幕长度超限!');
|
||||
return;
|
||||
}
|
||||
danmaku.text += iconText;
|
||||
inputValue.value = danmaku.text;
|
||||
}
|
||||
|
||||
function getAllIcons() {
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(core.maps.blocksInfo)
|
||||
.filter(v => v.cls !== 'tileset')
|
||||
.map(v => {
|
||||
return v.id;
|
||||
})
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function clickOuter() {
|
||||
if (!iconAll.value) close();
|
||||
else iconAll.value = false;
|
||||
}
|
||||
|
||||
let lockedBefore = false;
|
||||
onMounted(() => {
|
||||
mainDiv = document.getElementById('danmaku-editor') as HTMLDivElement;
|
||||
mainDiv.classList.add('danmaku-startup');
|
||||
gameKey.disable();
|
||||
|
||||
core.lockControl();
|
||||
mainDiv.addEventListener('focus', () => {
|
||||
lockedBefore = core.status.lockControl;
|
||||
core.lockControl();
|
||||
gameKey.disable();
|
||||
});
|
||||
mainDiv.addEventListener('blur', () => {
|
||||
gameKey.enable();
|
||||
if (!lockedBefore) core.unlockControl();
|
||||
});
|
||||
|
||||
document.addEventListener('click', clickOuter);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (danmaku.text !== '' || Object.keys(danmaku.style).length > 0) {
|
||||
Danmaku.lastEditoredDanmaku = danmaku;
|
||||
} else {
|
||||
delete Danmaku.lastEditoredDanmaku;
|
||||
}
|
||||
if (!lockedBefore) core.unlockControl();
|
||||
gameKey.enable();
|
||||
document.removeEventListener('click', clickOuter);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
#danmaku-editor {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: end;
|
||||
font-size: 200%;
|
||||
background-color: #000b;
|
||||
}
|
||||
|
||||
.danmaku-startup {
|
||||
animation: editor-startup 0.4s ease-out 0s 1 normal forwards running;
|
||||
}
|
||||
|
||||
.danmaku-close {
|
||||
animation: editor-close 0.4s ease-in 0s 1 normal forwards running;
|
||||
}
|
||||
|
||||
@keyframes editor-startup {
|
||||
0% {
|
||||
transform: translateY(70px);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes editor-close {
|
||||
0% {
|
||||
transform: none;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(110px);
|
||||
}
|
||||
}
|
||||
|
||||
#danmaku-input {
|
||||
height: 40px;
|
||||
width: 60%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#danmaku-input-div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
#danmaku-input-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-tool {
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: color 0.2s linear;
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
.danmaku-tool[open='true'],
|
||||
.danmaku-tool:hover {
|
||||
color: aqua;
|
||||
}
|
||||
|
||||
.danmaku-post {
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.danmaku-post[posting='true'] {
|
||||
color: gray;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
#danmaku-css {
|
||||
width: 60%;
|
||||
font-size: 60%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
font-family: 'Fira Code';
|
||||
|
||||
#danmaku-css-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 80%;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
#danmaku-css-error {
|
||||
color: lightcoral;
|
||||
}
|
||||
}
|
||||
|
||||
#danmaku-icon {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 80%;
|
||||
white-space: nowrap;
|
||||
justify-content: space-between;
|
||||
|
||||
.danmaku-icon-scroll {
|
||||
width: calc(90% - 200px);
|
||||
}
|
||||
|
||||
#danmaku-icon-div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.danmaku-icon-one {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#danmaku-icon-all-div {
|
||||
position: fixed;
|
||||
bottom: 110px;
|
||||
height: 50vh;
|
||||
background-color: #000b;
|
||||
border-radius: 20px;
|
||||
padding: 1%;
|
||||
font-size: 75%;
|
||||
width: 60%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #dddd;
|
||||
|
||||
#danmaku-all-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.danmaku-all-scroll {
|
||||
height: calc(100% - 70px);
|
||||
}
|
||||
}
|
||||
|
||||
#danmaku-color {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
justify-content: space-between;
|
||||
font-family: 'Fira Code';
|
||||
font-size: 75%;
|
||||
|
||||
#danmaku-color-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.danmaku-color-one {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
margin-right: 7px;
|
||||
border: 2px solid transparent;
|
||||
transition: border 0.1s linear;
|
||||
}
|
||||
|
||||
.danmaku-color-one[selected='true'],
|
||||
.danmaku-color-one:hover {
|
||||
border: 2px solid gold;
|
||||
}
|
||||
|
||||
.danmaku-color-scroll {
|
||||
width: calc(100% - 400px);
|
||||
}
|
||||
|
||||
#danmaku-color-input {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-enter-active,
|
||||
.danmaku-leave-active {
|
||||
transition: all 0.4s ease-out;
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
}
|
||||
|
||||
.danmaku-enter-from,
|
||||
.danmaku-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
.danmaku-icon-enter-active,
|
||||
.danmaku-icon-leave-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.danmaku-icon-enter-from,
|
||||
.danmaku-icon-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#danmaku-input {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
#danmaku-css {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
#danmaku-icon {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user