Compare commits

..

4 Commits

Author SHA1 Message Date
b018a5fd9a fix: 缩放后画面消失 2024-12-21 23:51:39 +08:00
1203b5618d fix: 打字机 2024-12-21 23:45:58 +08:00
1883e381fe feat: Graphic Props Base Comment 2024-12-21 23:33:31 +08:00
fc511dbf29 feat: Text Content 2024-12-21 23:27:52 +08:00
12 changed files with 889 additions and 479 deletions

View File

@ -28,7 +28,7 @@
"lodash-es": "^4.17.21",
"lz-string": "^1.5.0",
"mutate-animate": "^1.4.2",
"vue": "^3.4.38"
"vue": "^3.5.13"
},
"devDependencies": {
"@babel/cli": "^7.24.8",

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,21 @@
import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d';
import { defineComponent, onUpdated, shallowRef, watch } from 'vue';
import {
defineComponent,
onMounted,
onUpdated,
ref,
shallowRef,
SlotsType,
VNode,
watch
} from 'vue';
import { Transform } from '../transform';
import { isSetEqual } from '../utils';
import { logger } from '@/core/common/logger';
import { Sprite } from '../sprite';
import { onTick } from '../renderer';
import { isNil } from 'lodash-es';
import { SetupComponentOptions } from './types';
export const enum WordBreak {
/** 不换行 */
@ -18,12 +32,22 @@ export const enum TextAlign {
End
}
let testCanvas: MotaOffscreenCanvas2D;
Mota.require('var', 'loading').once('coreInit', () => {
testCanvas = new MotaOffscreenCanvas2D(false);
testCanvas.withGameScale(false);
testCanvas.setHD(false);
testCanvas.size(32, 32);
testCanvas.freeze();
});
export interface TextContentProps {
text: string;
x?: number;
y?: number;
width?: number;
height?: number;
/** 字体 */
font?: string;
/** 是否持续上一次的文本,开启后,如果修改后的文本以修改前的文本为开头,那么会继续播放而不会从头播放 */
keepLast?: boolean;
@ -40,9 +64,24 @@ export interface TextContentProps {
/** 行尾忽略字符,即不会出现在行尾的字符 */
ignoreLineEnd?: Iterable<string>;
/** 会被分词规则识别的分词字符 */
breakChars: Iterable<string>;
breakChars?: Iterable<string>;
/** 填充样式 */
fillStyle?: CanvasStyle;
/** 描边样式 */
strokeStyle?: CanvasStyle;
/** 线宽 */
strokeWidth?: number;
/** 是否填充 */
fill?: boolean;
/** 是否描边 */
stroke?: boolean;
}
export type TextContentEmits = {
typeEnd: () => void;
typeStart: () => void;
};
interface TextContentData {
text: string;
width: number;
@ -57,132 +96,396 @@ interface TextContentData {
breakChars: Set<string>;
}
class TextContentCachePool {
private pool: MotaOffscreenCanvas2D[] = [];
/**
*
* @param num
*/
requestCanvas(num: number): MotaOffscreenCanvas2D[] {
if (this.pool.length < num) {
const diff = num - this.pool.length;
for (let i = 0; i < diff; i++) {
this.pool.push(new MotaOffscreenCanvas2D(false));
}
}
return this.pool.splice(0, num);
}
/**
* 退
* @param canvas 退
*/
returnCanvas(canvas: MotaOffscreenCanvas2D[]) {
this.pool.push(...canvas);
}
interface TextContentRenderable {
x: number;
y: number;
/** 行高为0时表示两行间为默认行距 */
height: number;
/** 这一行文字的高度,即 measureText 算出的高度 */
textHeight: number;
/** 这一行的文字 */
text: string;
/** 当前渲染到了本行的哪个文字 */
pointer: number;
/** 本行文字在全部文字中的起点 */
from: number;
/** 本行文字在全部文字中的终点 */
to: number;
}
const pool = new TextContentCachePool();
const textContentOptions = {
props: [
'breakChars',
'font',
'height',
'ignoreLineEnd',
'ignoreLineStart',
'interval',
'keepLast',
'lineHeight',
'text',
'textAlign',
'width',
'wordBreak',
'x',
'y'
],
emits: ['typeEnd', 'typeStart']
} satisfies SetupComponentOptions<TextContentProps, TextContentEmits>;
export const TextContent = defineComponent<TextContentProps>((props, ctx) => {
const ensureProps = () => {
props.x ??= 0;
props.y ??= 0;
props.width ??= 200;
props.height ??= 200;
props.font ??= core.status.globalAttribute.font;
props.ignoreLineEnd ??= new Set();
props.ignoreLineStart ??= new Set();
props.keepLast ??= false;
props.interval ??= 0;
props.lineHeight ??= 0;
props.wordBreak ??= WordBreak.Space;
props.breakChars ??= new Set();
};
const makeSplitData = (): TextContentData => {
ensureProps();
return {
export const TextContent = defineComponent<TextContentProps, TextContentEmits>(
(props, { emit }) => {
if (props.width && props.width <= 0) {
logger.warn(41, String(props.width));
}
const renderData: Required<TextContentProps> = {
text: props.text,
width: props.width!,
font: props.font!,
wordBreak: props.wordBreak!,
ignoreLineStart: new Set(props.ignoreLineStart),
ignoreLineEnd: new Set(props.ignoreLineEnd),
breakChars: new Set(props.breakChars)
textAlign: props.textAlign ?? TextAlign.Left,
x: props.x ?? 0,
y: props.y ?? 0,
width: !props.width || props.width <= 0 ? 200 : props.width,
height: props.height ?? 200,
font:
props.font ??
core.status.globalAttribute?.font ??
'16px Verdana',
ignoreLineEnd: props.ignoreLineEnd ?? new Set(),
ignoreLineStart: props.ignoreLineStart ?? new Set(),
keepLast: props.keepLast ?? false,
interval: props.interval ?? 0,
lineHeight: props.lineHeight ?? 0,
wordBreak: props.wordBreak ?? WordBreak.Space,
breakChars: props.breakChars ?? new Set(),
fillStyle: props.fillStyle ?? '#fff',
strokeStyle: props.strokeStyle ?? 'transparent',
fill: props.fill ?? true,
stroke: props.stroke ?? false,
strokeWidth: props.strokeWidth ?? 2
};
};
/**
*
*/
const needResplit = (value: TextContentData, old: TextContentData) => {
return (
value.text !== old.text ||
value.font !== old.font ||
value.width !== old.width ||
value.wordBreak !== old.wordBreak ||
!isSetEqual(value.breakChars, old.breakChars) ||
!isSetEqual(value.ignoreLineEnd, old.ignoreLineEnd) ||
!isSetEqual(value.ignoreLineStart, old.ignoreLineStart)
);
};
const render = (canvas: MotaOffscreenCanvas2D, transform: Transform) => {};
const data = shallowRef<TextContentData>(makeSplitData());
onUpdated(() => {
data.value = makeSplitData();
});
let shouldKeep = false;
const lineData = shallowRef([0]);
watch(data, (value, old) => {
if (needResplit(value, old)) {
lineData.value = splitLines(value);
}
if (props.keepLast && value.text.startsWith(old.text)) {
shouldKeep = true;
}
});
watch(lineData, (value, old) => {
if (shouldKeep) {
shouldKeep = false;
const isSub = value.every((v, i) => v === old[i]);
if (isSub) {
const ensureProps = () => {
for (const [key, value] of Object.entries(props)) {
if (key in renderData && !isNil(value)) {
if (key === 'width') {
if (value && value <= 0) {
logger.warn(41, String(props.width));
renderData.width = 200;
} else {
renderData.width = value;
}
} else {
// @ts-ignore
renderData[key] = value;
}
}
}
}
});
};
return () => {
return (
<sprite
x={props.x}
y={props.y}
width={props.width}
height={props.height}
render={render}
></sprite>
);
};
});
const makeSplitData = (): TextContentData => {
ensureProps();
return {
text: renderData.text,
width: renderData.width!,
font: renderData.font!,
wordBreak: renderData.wordBreak!,
ignoreLineStart: new Set(renderData.ignoreLineStart),
ignoreLineEnd: new Set(renderData.ignoreLineEnd),
breakChars: new Set(renderData.breakChars)
};
};
export const Textbox = defineComponent((props, ctx) => {
return () => {};
});
/**
*
*/
const needResplit = (value: TextContentData, old: TextContentData) => {
return (
value.text !== old.text ||
value.font !== old.font ||
value.width !== old.width ||
value.wordBreak !== old.wordBreak ||
!isSetEqual(value.breakChars, old.breakChars) ||
!isSetEqual(value.ignoreLineEnd, old.ignoreLineEnd) ||
!isSetEqual(value.ignoreLineStart, old.ignoreLineStart)
);
};
let testCanvas: MotaOffscreenCanvas2D;
Mota.require('var', 'loading').once('coreInit', () => {
testCanvas = new MotaOffscreenCanvas2D(false);
testCanvas.withGameScale(false);
testCanvas.setHD(false);
testCanvas.size(32, 32);
testCanvas.freeze();
});
/** 每行的渲染信息 */
const renderable: TextContentRenderable[] = [];
/** 需要更新的行数 */
const dirtyIndex: number[] = [];
const spriteElement = ref<Sprite>();
/** dirtyIndex 更新指针 */
let linePointer = 0;
let startTime = 0;
/** 从哪个字符开始渲染 */
let fromChar = 0;
/** 是否需要更新渲染 */
let needUpdate = false;
const tick = () => {
if (!needUpdate) return;
spriteElement.value?.update();
const time = Date.now();
const char =
Math.floor((time - startTime) / renderData.interval!) +
fromChar;
if (!isFinite(char)) {
renderable.forEach(v => (v.pointer = v.text.length));
needUpdate = false;
return;
}
while (linePointer < dirtyIndex.length) {
const line = dirtyIndex[linePointer];
const data = renderable[line];
const pointer = char - data.from;
if (char >= data.to) {
data.pointer = data.text.length;
linePointer++;
} else {
data.pointer = pointer;
break;
}
}
if (linePointer >= dirtyIndex.length) {
needUpdate = false;
renderable.forEach(v => (v.pointer = v.text.length));
emit('typeEnd');
}
};
onTick(tick);
onMounted(() => {
data.value = makeSplitData();
lineData.value = splitLines(data.value);
});
const renderContent = (
canvas: MotaOffscreenCanvas2D,
transform: Transform
) => {
const ctx = canvas.ctx;
ctx.font = renderData.font;
ctx.fillStyle = renderData.fillStyle;
ctx.strokeStyle = renderData.strokeStyle;
ctx.lineWidth = renderData.strokeWidth;
renderable.forEach(v => {
if (v.pointer === 0) return;
const text = v.text.slice(0, v.pointer);
if (renderData.stroke) ctx.strokeText(text, v.x, v.y);
if (renderData.fill) ctx.fillText(text, v.x, v.y);
});
};
/**
* renderable
* @param text
* @param lines
* @param index
* @param from
*/
const makeRenderable = (
text: string,
lines: number[],
index: number,
from: number
) => {
renderable.splice(index);
dirtyIndex.splice(0);
dirtyIndex.push(index);
// 初始化渲染
linePointer = 0;
startTime = Date.now();
fromChar = from;
needUpdate = true;
let startY = renderable.reduce(
(prev, curr) => prev + curr.textHeight + curr.height,
0
);
// 第一个比较特殊,需要特判
const start = lines[index - 1] ?? 0;
const end = lines[index];
const startPointer = from > start && from < end ? from - start : 0;
const height = testHeight(text, renderData.font!);
startY += height;
renderable.push({
text: text.slice(start, end),
x: 0,
y: startY,
height: renderData.lineHeight!,
textHeight: height,
pointer: startPointer,
from: start,
to: end
});
for (let i = index + 1; i < lines.length; i++) {
dirtyIndex.push(i);
const start = lines[i - 1] ?? 0;
const end = lines[i];
const height = testHeight(text, renderData.font!);
startY += height;
renderable.push({
text: text.slice(start, end),
x: 0,
y: startY,
height: renderData.lineHeight!,
textHeight: height,
pointer: 0,
from: start,
to: end
});
}
emit('typeStart');
};
/**
*
*/
const rawRender = (text: string, lines: number[]) => {
makeRenderable(text, lines, 0, 0);
spriteElement.value?.update();
};
/**
*
* @param from
* @param lines
* @param index
*/
const continueRender = (
text: string,
from: number,
lines: number[],
index: number
) => {
makeRenderable(text, lines, index, from);
spriteElement.value?.update();
};
const data = shallowRef<TextContentData>(makeSplitData());
onUpdated(() => {
data.value = makeSplitData();
});
let shouldKeep = false;
const lineData = shallowRef([0]);
watch(data, (value, old) => {
if (needResplit(value, old)) {
lineData.value = splitLines(value);
}
if (renderData.keepLast && value.text.startsWith(old.text)) {
shouldKeep = true;
}
});
// 判断是否需要接续渲染
watch(lineData, (value, old) => {
if (shouldKeep) {
shouldKeep = false;
const isSub = old.slice(0, -1).every((v, i) => v === value[i]);
// 有点地狱的条件分歧,大体就是分为两种情况,一种是两个末尾一致,如果一致那直接从下一行接着画就完事了
// 但是如果不一致,那么从旧的最后一个开始往后画
if (isSub) {
const last = value[old.length - 1];
const oldLast = value.at(-1);
if (!last) {
rawRender(data.value.text, value);
return;
}
if (last === oldLast) {
const index = old.length - 1;
continueRender(data.value.text, last, value, index);
} else {
if (!oldLast) {
rawRender(data.value.text, value);
} else {
const index = old.length - 1;
continueRender(
data.value.text,
oldLast,
value,
index
);
}
}
} else {
rawRender(data.value.text, value);
}
} else {
rawRender(data.value.text, value);
}
});
return () => {
return (
<sprite
ref={spriteElement}
hd
antiAliasing={false}
x={renderData.x}
y={renderData.y}
width={renderData.width}
height={renderData.height}
render={renderContent}
></sprite>
);
};
},
textContentOptions
);
export interface TextboxProps extends TextContentProps {
/** 背景颜色 */
backColor?: CanvasStyle;
/** 背景 winskin */
winskin?: string;
}
interface TextboxSlots extends SlotsType {
default: () => VNode;
}
const textboxOptions = {
props: (textContentOptions.props as (keyof TextboxProps)[]).concat([
'backColor',
'winskin'
])
} satisfies SetupComponentOptions<TextboxProps, {}, string, TextboxSlots>;
export const Textbox = defineComponent<TextboxProps, {}, string, TextboxSlots>(
(props, { slots }) => {
return () => {
return (
<container>
{slots.default ? (
slots.default()
) : props.winskin ? (
// todo
<winskin></winskin>
) : (
// todo
<g-rect
x={0}
y={0}
width={props.width ?? 200}
height={props.height ?? 200}
fill
fillStyle={props.backColor}
></g-rect>
)}
<TextContent {...props}></TextContent>
</container>
);
};
},
textboxOptions
);
const fontSizeGuessScale = new Map<string, number>([
['px', 1],
@ -207,7 +510,8 @@ const fontSizeGuessScale = new Map<string, number>([
*/
function splitLines(data: TextContentData) {
const words = breakWords(data);
if (words.length === 1) return [words[0]];
if (words.length === 0) return [];
else if (words.length === 1) return [words[0]];
// 对文字二分,然后计算长度
const text = data.text;
@ -215,9 +519,9 @@ function splitLines(data: TextContentData) {
const fontSize = data.font.match(/\s*[\d\.-]+[a-zA-Z%]*\s*/)?.[0].trim();
const unit = fontSize?.match(/[a-zA-Z%]+/)?.[0];
const guessScale = fontSizeGuessScale.get(unit ?? '') ?? 0.2;
const guessSize = parseInt(fontSize ?? '0') * guessScale;
const guessSize = parseInt(fontSize ?? '1') * guessScale;
const averageLength = text.length / words.length;
const guess = data.width / guessSize / averageLength;
const guess = Math.max(data.width / guessSize / averageLength, 1);
const ctx = testCanvas.ctx;
ctx.font = data.font;
@ -228,7 +532,6 @@ function splitLines(data: TextContentData) {
let guessCount = 1;
let splitProgress = false;
console.time();
while (1) {
if (!splitProgress) {
const chars = text.slice(words[start], words[end]);
@ -246,9 +549,10 @@ function splitLines(data: TextContentData) {
if (diff === 1) {
res.push(words[start]);
if (end === words.length) break;
if (end >= words.length) break;
resolved = start;
end = Math.ceil(start + guess);
if (end > words.length) end = words.length;
guessCount = 1;
splitProgress = false;
} else {
@ -264,7 +568,6 @@ function splitLines(data: TextContentData) {
}
}
}
console.timeEnd();
return res;
}
@ -302,6 +605,7 @@ function isCJK(char: number) {
* @returns
*/
function breakWords(data: TextContentData) {
if (data.text.length <= 1) return [data.text.length];
let allBreak = false;
const breakChars = breakSet.union(data.breakChars);
switch (data.wordBreak) {
@ -332,7 +636,7 @@ function breakWords(data: TextContentData) {
}
if (ignoreLineStart.has(char) && !ignoreLineStart.has(next)) {
res.push(pointer);
res.push(pointer + 1);
continue;
}
@ -342,10 +646,16 @@ function breakWords(data: TextContentData) {
char === '\n' ||
isCJK(char.charCodeAt(0))
) {
res.push(pointer);
res.push(pointer + 1);
continue;
}
}
res.push(text.length);
return res;
}
function testHeight(text: string, font: string) {
const ctx = testCanvas.ctx;
ctx.font = font;
return ctx.measureText(text).fontBoundingBoxAscent;
}

View File

@ -0,0 +1,12 @@
import { ComponentOptions, EmitsOptions, SlotsType } from 'vue';
export type SetupComponentOptions<
Props extends Record<string, any>,
E extends EmitsOptions = {},
EE extends string = string,
S extends SlotsType = {}
> = Pick<ComponentOptions, 'name' | 'inheritAttrs'> & {
props?: (keyof Props)[];
emits?: E | EE[];
slots?: S;
};

View File

@ -11,8 +11,8 @@ import { LayerGroupHalo } from '@/plugin/fx/halo';
import { FloorViewport } from './preset/viewport';
import { PopText } from '@/plugin/fx/pop';
import { FloorChange } from '@/plugin/fallback';
import { render } from './renderer';
import { defineComponent, ref } from 'vue';
import { createApp } from './renderer';
import { defineComponent } from 'vue';
let main: MotaRenderer;
@ -61,7 +61,8 @@ Mota.require('var', 'loading').once('coreInit', () => {
});
main.hide();
render(<Com></Com>, main);
createApp(Com).mount(main);
// render(<Com></Com>, main);
console.log(main);
});

44
src/core/render/pool.ts Normal file
View File

@ -0,0 +1,44 @@
import { logger } from '../common/logger';
import { MotaOffscreenCanvas2D } from '../fx/canvas2d';
export class CanvasPool {
private pool: MotaOffscreenCanvas2D[] = [];
private requested: Set<MotaOffscreenCanvas2D> = new Set();
/**
*
* @param num
*/
requestCanvas(num: number): MotaOffscreenCanvas2D[] {
if (this.pool.length < num) {
const diff = num - this.pool.length;
for (let i = 0; i < diff; i++) {
this.pool.push(new MotaOffscreenCanvas2D(false));
}
}
const toProvide = this.pool.splice(0, num);
toProvide.forEach(v => this.requested.add(v));
return toProvide;
}
/**
* 退
* @param canvas 退
*/
returnCanvas(canvas: MotaOffscreenCanvas2D[]) {
canvas.forEach(v => {
if (!this.requested.has(v)) {
logger.warn(40);
return;
}
this.requested.delete(v);
this.pool.push(v);
v.clear();
});
}
destroy() {
this.pool.forEach(v => v.delete());
this.requested.forEach(v => v.delete());
}
}

View File

@ -84,11 +84,27 @@ export class MotaRenderer extends Container {
this.target.delete();
}
/**
*
*/
refreshAll() {
const stack: RenderItem[] = [this];
while (stack.length > 0) {
const item = stack.pop();
if (!item) break;
if (item.children.size === 0) {
item.update();
} else {
item.children.forEach(v => stack.push(v));
}
}
}
static get(id: string) {
return this.list.get(id);
}
}
window.addEventListener('resize', () => {
MotaRenderer.list.forEach(v => v.update(v));
MotaRenderer.list.forEach(v => v.requestAfterFrame(() => v.refreshAll()));
});

View File

@ -92,6 +92,7 @@ const standardElementNoCache = (
// Default elements
tagMap.register('container', standardElement(Container));
tagMap.register('template', standardElement(Container));
tagMap.register('mota-renderer', (_0, _1, props) => {
return new MotaRenderer(props?.id);
});

View File

@ -6,6 +6,7 @@ import {
ILayerRenderExtends
} from '../preset/layer';
import type { EnemyCollection } from '@/game/enemy/damage';
import { ILineProperty } from '../preset/graphics';
export interface CustomProps {
_item: (props: BaseProps) => RenderItem;
@ -90,19 +91,34 @@ export interface DamageProps extends BaseProps {
strokeWidth?: number;
}
export interface RectProps extends BaseProps {}
export interface GraphicPropsBase extends BaseProps, Partial<ILineProperty> {
/** 是否填充,若填写 {@link stroke},那么表现为先填充后描边 */
fill?: boolean;
/** 是否描边,若填写 {@link fill},那么表现为先填充后描边 */
stroke?: boolean;
/** 是否先描边后填充,优先级最高,若设置,则 {@link fill} 与 {@link stroke} 无效。 */
strokeAndFill?: boolean;
/** 填充原则,比如 `nonzero` 表示非零环绕原则,默认为奇偶环绕原则 `evenodd` */
fillRule?: CanvasFillRule;
/** 填充样式 */
fillStyle?: CanvasStyle;
/** 描边样式 */
strokeStyle?: CanvasStyle;
}
export interface CirclesProps extends BaseProps {}
export interface RectProps extends GraphicPropsBase {}
export interface EllipseProps extends BaseProps {}
export interface CirclesProps extends GraphicPropsBase {}
export interface LineProps extends BaseProps {}
export interface EllipseProps extends GraphicPropsBase {}
export interface BezierProps extends BaseProps {}
export interface LineProps extends GraphicPropsBase {}
export interface QuadraticProps extends BaseProps {}
export interface BezierProps extends GraphicPropsBase {}
export interface PathProps extends BaseProps {}
export interface QuadraticProps extends GraphicPropsBase {}
export interface PathProps extends GraphicPropsBase {}
export interface IconProps extends BaseProps {}

View File

@ -52,7 +52,7 @@ export class Sprite<
): void {
switch (key) {
case 'render':
if (this.assertType(nextValue, 'function', key)) return;
if (!this.assertType(nextValue, 'function', key)) return;
this.setRenderFn(nextValue);
break;
}

View File

@ -32,5 +32,6 @@ export function multiplyTiming(timing1: TimingFn, timing2: TimingFn): TimingFn {
*
*/
export function isSetEqual<T>(set1: Set<T>, set2: Set<T>) {
return set1.size === set2.size && set1.isSubsetOf(set2);
if (set1 === set2) return true;
else return set1.size === set2.size && set1.isSubsetOf(set2);
}

View File

@ -67,6 +67,8 @@
"37": "Cannot execute 'requestSort' on plain render item, please ensure you have overrided 'requestSort' method in your own element.",
"38": "Using plain text in jsx is strongly not recommended, since you can hardly modify its attributes. Consider using Text element instead.",
"39": "Plain text is not supported outside Text element.",
"40": "Cannot return canvas that is not provided by this pool.",
"41": "Width of text content components must be positive. receive: $1",
"1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency.",
"1101": "Cannot add new effect to point effect instance, for there's no more reserve space for it. Please increase the max count of the instance."
}