From ee5b9627430ec8d33903cc10946d230854f20af0 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 28 Feb 2025 17:10:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AD=97=E4=BD=93=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/render/index.ts | 1 + src/core/render/preset/misc.ts | 11 +- src/core/render/renderer/props.ts | 3 +- src/core/render/style/font.ts | 155 +++++++++++++++++++ src/core/render/style/index.ts | 1 + src/data/logger.json | 3 +- src/module/render/components/index.ts | 6 + src/module/render/components/page.tsx | 40 +++-- src/module/render/components/textbox.tsx | 16 +- src/module/render/components/textboxTyper.ts | 38 +++-- src/module/render/components/tip.tsx | 4 +- src/module/render/ui/main.tsx | 7 +- src/module/render/ui/statusBar.tsx | 15 +- src/module/render/ui/toolbar.tsx | 8 +- 14 files changed, 241 insertions(+), 67 deletions(-) create mode 100644 src/core/render/style/font.ts create mode 100644 src/core/render/style/index.ts diff --git a/src/core/render/index.ts b/src/core/render/index.ts index b675579..9801241 100644 --- a/src/core/render/index.ts +++ b/src/core/render/index.ts @@ -1,5 +1,6 @@ export * from './preset'; export * from './renderer'; +export * from './style'; export * from './adapter'; export * from './cache'; export * from './camera'; diff --git a/src/core/render/preset/misc.ts b/src/core/render/preset/misc.ts index acf340c..cba8b74 100644 --- a/src/core/render/preset/misc.ts +++ b/src/core/render/preset/misc.ts @@ -6,6 +6,7 @@ import { texture } from '../cache'; import { isNil } from 'lodash-es'; import { logger } from '@/core/common/logger'; import { IAnimateFrame, renderEmits } from '../frame'; +import { Font } from '../style/font'; /** 文字的安全填充,会填充在文字的上侧和下侧,防止削顶和削底 */ const SAFE_PAD = 1; @@ -21,7 +22,7 @@ export class Text extends RenderItem { fillStyle?: CanvasStyle = '#fff'; strokeStyle?: CanvasStyle; - font: string = '16px Verdana'; + font: Font = new Font(); strokeWidth: number = 1; private length: number = 0; @@ -50,7 +51,7 @@ export class Text extends RenderItem { ctx.textBaseline = 'bottom'; ctx.fillStyle = this.fillStyle ?? 'transparent'; ctx.strokeStyle = this.strokeStyle ?? 'transparent'; - ctx.font = this.font; + ctx.font = this.font.string(); ctx.lineWidth = this.strokeWidth; if (this.strokeStyle) { @@ -67,7 +68,7 @@ export class Text extends RenderItem { measure() { const ctx = Text.measureCanvas.ctx; ctx.textBaseline = 'bottom'; - ctx.font = this.font; + ctx.font = this.font.string(); const res = ctx.measureText(this.text); return res; } @@ -87,7 +88,7 @@ export class Text extends RenderItem { * 设置使用的字体 * @param font 字体 */ - setFont(font: string) { + setFont(font: Font) { this.font = font; this.calBox(); this.update(this); @@ -145,7 +146,7 @@ export class Text extends RenderItem { this.setStyle(this.fillStyle, nextValue); return true; case 'font': - if (!this.assertType(nextValue, 'string', key)) return false; + if (!this.assertType(nextValue, Font, key)) return false; this.setFont(nextValue); return true; case 'strokeWidth': diff --git a/src/core/render/renderer/props.ts b/src/core/render/renderer/props.ts index 91d9852..48ad06c 100644 --- a/src/core/render/renderer/props.ts +++ b/src/core/render/renderer/props.ts @@ -18,6 +18,7 @@ import { } from '../preset/graphics'; import { ElementAnchor, ElementLocator, ElementScale } from '../utils'; import { CustomContainerRenderFn } from '../container'; +import { Font } from '../style/font'; export interface CustomProps { _item: (props: BaseProps) => RenderItem; @@ -100,7 +101,7 @@ export interface TextProps extends BaseProps { text?: string; fillStyle?: CanvasStyle; strokeStyle?: CanvasStyle; - font?: string; + font?: Font; strokeWidth?: number; } diff --git a/src/core/render/style/font.ts b/src/core/render/style/font.ts new file mode 100644 index 0000000..5478532 --- /dev/null +++ b/src/core/render/style/font.ts @@ -0,0 +1,155 @@ +import { logger } from '@/core/common/logger'; + +export interface IFontConfig { + /** 字体类型 */ + readonly family: string; + /** 字体大小的值 */ + readonly size: number; + /** 字体大小单位,推荐使用 px */ + readonly sizeUnit: string; + /** 字体粗细,范围 0-1000 */ + readonly weight: number; + /** 是否斜体 */ + readonly italic: boolean; +} + +export const enum FontWeight { + Light = 300, + Normal = 400, + Bold = 700 +} + +type _FontStretch = + | 'ultra-condensed' + | 'extra-condensed' + | 'condensed' + | 'semi-condensed' + | 'normal' + | 'semi-expanded' + | 'expanded' + | 'extra-expanded' + | 'ultra-expanded'; + +type _FontVariant = 'normal' | 'small-caps'; + +export class Font implements IFontConfig { + private readonly fallbacks: Font[] = []; + + private fontString: string = ''; + + constructor( + public readonly family: string = 'Verdana', + public readonly size: number = 16, + public readonly sizeUnit: string = 'px', + public readonly weight: number = 400, + public readonly italic: boolean = false + ) { + this.fontString = this.getFont(); + } + + /** + * 添加补充字体,若当前字体不可用,那么会使用补充字体,补充字体也可以添加补充字体,但是请避免递归添加 + * @param fallback 补充字体 + */ + addFallback(...fallback: Font[]) { + this.fallbacks.push(...fallback); + this.fontString = this.getFont(); + } + + private build() { + return `${ + this.italic ? 'italic ' : '' + } ${this.weight} ${this.size}${this.sizeUnit} ${this.family}`; + } + + private getFallbackFont(used: Set) { + let font = ''; + this.fallbacks.forEach(v => { + if (used.has(v)) { + logger.warn(62, this.build()); + return; + } + used.add(v); + font += `, ${v.getFallbackFont(used)}`; + }); + return font; + } + + private getFont() { + if (this.fallbacks.length === 0) { + return this.build(); + } else { + const usedFont = new Set(); + return this.build() + this.getFallbackFont(usedFont); + } + } + + /** + * 获取字体的 CSS 字符串 + */ + string() { + return this.fontString; + } + + private static parseOne(str: string) { + if (!str) return new Font(); + let italic = false; + let weight = 400; + let size = 16; + let unit = 'px'; + let family = 'Verdana'; + const tokens = str.split(/\s+/); + tokens.forEach(v => { + // font-italic + if (v === 'italic') { + italic = true; + return; + } + + // font-weight + const num = Number(v); + if (!isNaN(num)) { + weight = num; + return; + } + + // font-size + const parse = parseFloat(v); + if (!isNaN(parse)) { + size = parse; + unit = v.slice(parse.toString().length); + return; + } + }); + family = tokens.at(-1) ?? 'Verdana'; + return new Font(family, size, unit, weight, italic); + } + + /** + * 从 CSS 字体字符串解析出 Font 实例,不支持的属性将被忽略 + */ + static parse(str: string) { + const fonts = str.split(','); + const main = this.parseOne(fonts[0]); + for (let i = 1; i < fonts.length; i++) { + main.addFallback(this.parseOne(fonts[i])); + } + } + + /** + * 复制一个字体,同时修改字体的一部分属性 + * @param font 要复制的字体 + */ + static clone( + font: Font, + { + family = font.family, + size = font.size, + sizeUnit = font.sizeUnit, + weight = font.weight, + italic = font.italic + }: Partial + ) { + return new Font(family, size, sizeUnit, weight, italic); + } +} diff --git a/src/core/render/style/index.ts b/src/core/render/style/index.ts new file mode 100644 index 0000000..f36d3d5 --- /dev/null +++ b/src/core/render/style/index.ts @@ -0,0 +1 @@ +export * from './font'; diff --git a/src/data/logger.json b/src/data/logger.json index 3ea6a95..27a3288 100644 --- a/src/data/logger.json +++ b/src/data/logger.json @@ -92,7 +92,8 @@ "58": "Fail to set ellipse round rect, since length of 'ellipse' property should only be 2, 4, 6 or 8. delivered: $1", "59": "Unknown icon '$1' in parsing text content.", "60": "Repeated Tip id: '$1'.", - "61": "Unexpected recursive call of $1.update in render function. Please ensure you must do this, if you do, ignore this warn.", + "61": "Unexpected recursive call of $1.update in render function. Please ensure you have to do this, if you do, ignore this warn.", + "62": "Recursive fallback fonts in '$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." } diff --git a/src/module/render/components/index.ts b/src/module/render/components/index.ts index cee1da2..7b47d4d 100644 --- a/src/module/render/components/index.ts +++ b/src/module/render/components/index.ts @@ -1,3 +1,9 @@ +export * from './choices'; +export * from './icons'; +export * from './misc'; +export * from './page'; +export * from './scroll'; export * from './textbox'; export * from './textboxTyper'; +export * from './tip'; export * from './types'; diff --git a/src/module/render/components/page.tsx b/src/module/render/components/page.tsx index 2435b3d..b6f40a8 100644 --- a/src/module/render/components/page.tsx +++ b/src/module/render/components/page.tsx @@ -10,7 +10,7 @@ import { } from 'vue'; import { SetupComponentOptions } from './types'; import { clamp } from 'lodash-es'; -import { DefaultProps, ElementLocator } from '@/core/render'; +import { DefaultProps, ElementLocator, Font } from '@/core/render'; /** 圆角矩形页码距离容器的边框大小,与 pageSize 相乘 */ const RECT_PAD = 0.1; @@ -20,8 +20,10 @@ export interface PageProps extends DefaultProps { pages: number; /** 页码组件的定位 */ loc: ElementLocator; - /** 页码的字体大小,默认为 14 */ - pageSize?: number; + /** 页码的字体 */ + font?: Font; + /** 只有一页的时候,是否隐藏页码 */ + hideIfSingle?: boolean; } export interface PageExpose { @@ -37,7 +39,7 @@ type PageSlots = SlotsType<{ }>; const pageProps = { - props: ['pages', 'loc', 'pageSize'] + props: ['pages', 'loc', 'font', 'hideIfSingle'] } satisfies SetupComponentOptions; /** @@ -80,14 +82,15 @@ export const Page = defineComponent( const leftArrow = ref(); const rightArrow = ref(); + const font = computed(() => props.font ?? new Font()); const isFirst = computed(() => nowPage.value === 1); const isLast = computed(() => nowPage.value === props.pages); - const pageSize = computed(() => props.pageSize ?? 14); const width = computed(() => props.loc[2] ?? 200); const height = computed(() => props.loc[3] ?? 200); - const round = computed(() => pageSize.value / 4); - const pageFont = computed(() => `${pageSize.value}px normal`); - const nowPageFont = computed(() => `bold ${pageSize.value}px normal`); + const round = computed(() => font.value.size / 4); + const nowPageFont = computed(() => + Font.clone(font.value, { weight: 700 }) + ); // 左右箭头的颜色 const leftColor = computed(() => (isFirst.value ? '#666' : '#ddd')); @@ -100,11 +103,11 @@ export const Page = defineComponent( nextTick(() => { updating = false; }); - const pageH = pageSize.value + 8; + const pageH = font.value.size + 8; contentLoc.value = [0, 0, width.value, height.value - pageH]; pageLoc.value = [0, height.value - pageH, width.value, pageH]; const center = width.value / 2; - const size = pageSize.value * 1.5; + const size = font.value.size * 1.5; nowPageLoc.value = [center, 0, size, size, 0.5, 0]; leftPageLoc.value = [center - size * 1.5, 0, size, size, 0.5, 0]; leftLoc.value = [center - size * 3, 0, size, size, 0.5, 0]; @@ -113,8 +116,8 @@ export const Page = defineComponent( }; const updateArrowPath = () => { - const rectSize = pageSize.value * 1.5; - const size = pageSize.value; + const rectSize = font.value.size * 1.5; + const size = font.value.size; const pad = rectSize - size; const left = new Path2D(); left.moveTo(size, pad); @@ -129,13 +132,13 @@ export const Page = defineComponent( }; const updateRectAndText = () => { - const size = pageSize.value * 1.5; + const size = font.value.size * 1.5; const pad = RECT_PAD * size; rectLoc.value = [pad, pad, size - pad * 2, size - pad * 2]; textLoc.value = [size / 2, size / 2, void 0, void 0, 0.5, 0.5]; }; - watch(pageSize, () => { + watch(font, () => { updatePagePos(); updateArrowPath(); updateRectAndText(); @@ -178,7 +181,10 @@ export const Page = defineComponent( {slots.default?.(nowPage.value)} - + )} diff --git a/src/module/render/components/textbox.tsx b/src/module/render/components/textbox.tsx index ce5cb5b..8ffac63 100644 --- a/src/module/render/components/textbox.tsx +++ b/src/module/render/components/textbox.tsx @@ -27,7 +27,7 @@ import { WordBreak, TextAlign } from './textboxTyper'; -import { ElementLocator } from '@/core/render'; +import { ElementLocator, Font } from '@/core/render'; export interface TextContentProps extends DefaultProps, @@ -70,10 +70,7 @@ export interface TextContentExpose { const textContentOptions = { props: [ 'breakChars', - 'fontFamily', - 'fontSize', - 'fontWeight', - 'fontItalic', + 'font', 'ignoreLineEnd', 'ignoreLineStart', 'interval', @@ -225,7 +222,7 @@ export interface TextboxProps extends TextContentProps, DefaultProps { /** 标题 */ title?: string; /** 标题字体 */ - titleFont?: string; + titleFont?: Font; /** 标题填充样式 */ titleFill?: CanvasStyle; /** 标题描边样式 */ @@ -296,10 +293,7 @@ export const Textbox = defineComponent< const setContentData = () => { contentData.breakChars = props.breakChars ?? ''; - contentData.fontFamily = props.fontFamily ?? 'Verdana'; - contentData.fontSize = props.fontSize ?? 16; - contentData.fontWeight = props.fontWeight ?? 500; - contentData.fontItalic = props.fontItalic ?? false; + contentData.font = props.font ?? new Font(); contentData.ignoreLineEnd = props.ignoreLineEnd ?? ''; contentData.ignoreLineStart = props.ignoreLineStart ?? ''; contentData.interval = props.interval ?? 0; @@ -323,7 +317,7 @@ export const Textbox = defineComponent< data.padding = props.padding ?? 8; data.titleFill = props.titleFill ?? 'gold'; data.titleStroke = props.titleStroke ?? 'transparent'; - data.titleFont = props.titleFont ?? '18px Verdana'; + data.titleFont = props.titleFont ?? new Font('Verdana', 18); data.titlePadding = props.titlePadding ?? 8; data.width = props.width ?? props.loc?.[2] ?? 200; data.height = props.height ?? props.loc?.[3] ?? 200; diff --git a/src/module/render/components/textboxTyper.ts b/src/module/render/components/textboxTyper.ts index 7a8888a..ac0fc9b 100644 --- a/src/module/render/components/textboxTyper.ts +++ b/src/module/render/components/textboxTyper.ts @@ -2,6 +2,7 @@ import { logger } from '@/core/common/logger'; import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; import { AutotileRenderable, + Font, onTick, RenderableData, texture @@ -28,14 +29,8 @@ export const enum TextAlign { } export interface ITextContentConfig { - /** 字体类型 */ - fontFamily: string; - /** 字体大小 */ - fontSize: number; - /** 字体线宽 */ - fontWeight: number; - /** 是否斜体 */ - fontItalic: boolean; + /** 字体 */ + font: Font; /** 是否持续上一次的文本,开启后,如果修改后的文本以修改前的文本为开头,那么会继续播放而不会从头播放 */ keepLast: boolean; /** 打字机时间间隔,即两个字出现之间相隔多长时间 */ @@ -62,6 +57,17 @@ export interface ITextContentConfig { width: number; } +interface TyperConfig extends ITextContentConfig { + /** 字体类型 */ + fontFamily: string; + /** 字体大小 */ + fontSize: number; + /** 字体线宽 */ + fontWeight: number; + /** 是否斜体 */ + fontItalic: boolean; +} + export interface ITextContentRenderData { text: string; title?: string; @@ -162,7 +168,7 @@ type TyperFunction = (data: TyperRenderable[], typing: boolean) => void; export class TextContentTyper extends EventEmitter { /** 文字配置信息 */ - readonly config: Required; + readonly config: Required; /** 文字解析器 */ readonly parser: TextContentParser; @@ -205,12 +211,14 @@ export class TextContentTyper extends EventEmitter { constructor(config: Partial) { super(); + const font = config.font ?? new Font(); this.config = { - fontFamily: config.fontFamily ?? 'Verdana', - fontSize: config.fontSize ?? 16, - fontWeight: config.fontWeight ?? 500, - fontItalic: config.fontItalic ?? false, + font, + fontFamily: font.family, + fontSize: font.size, + fontWeight: font.weight, + fontItalic: font.italic, keepLast: config.keepLast ?? false, interval: config.interval ?? 0, lineHeight: config.lineHeight ?? 0, @@ -762,7 +770,7 @@ export class TextContentParser { private parseFontWeight() { if (this.resolved.length > 0) this.addTextRenderable(); - this.status.fontWeight = this.status.fontWeight > 500 ? 500 : 700; + this.status.fontWeight = this.status.fontWeight > 400 ? 400 : 700; this.font = this.buildFont(); } @@ -1300,7 +1308,7 @@ function isCJK(char: number) { export function buildFont( family: string, size: number, - weight: number = 500, + weight: number = 400, italic: boolean = false ) { return `${italic ? 'italic ' : ''}${weight} ${size}px "${family}"`; diff --git a/src/module/render/components/tip.tsx b/src/module/render/components/tip.tsx index 87b149e..201b236 100644 --- a/src/module/render/components/tip.tsx +++ b/src/module/render/components/tip.tsx @@ -1,4 +1,4 @@ -import { DefaultProps, ElementLocator, texture } from '@/core/render'; +import { DefaultProps, ElementLocator, Font, texture } from '@/core/render'; import { computed, defineComponent, onUnmounted, ref } from 'vue'; import { SetupComponentOptions } from './types'; import { transitioned } from '../use'; @@ -36,7 +36,7 @@ export const Tip = defineComponent((props, { expose }) => { const text = ref(''); const textWidth = ref(0); - const font = '16px normal'; + const font = new Font('normal'); const alpha = transitioned(0, 500, hyper('sin', 'in-out'))!; const pad = computed(() => props.pad ?? [4, 4]); diff --git a/src/module/render/ui/main.tsx b/src/module/render/ui/main.tsx index d25e89c..c37aa6b 100644 --- a/src/module/render/ui/main.tsx +++ b/src/module/render/ui/main.tsx @@ -8,7 +8,8 @@ import { HeroRenderer, LayerDoorAnimate, Props, - LayerGroup + LayerGroup, + Font } from '@/core/render'; import { WeatherController } from '@/module/weather'; import { FloorChange } from '@/plugin/fallback'; @@ -62,8 +63,8 @@ const MainScene = defineComponent(() => { zIndex: 30, fillStyle: '#fff', titleFill: 'gold', - fontFamily: 'normal', - titleFont: '700 20px normal', + font: new Font('normal'), + titleFont: new Font('normal', 20, 'px', 700), winskin: 'winskin2.png', interval: 100, lineHeight: 4, diff --git a/src/module/render/ui/statusBar.tsx b/src/module/render/ui/statusBar.tsx index d90c20a..5a3a806 100644 --- a/src/module/render/ui/statusBar.tsx +++ b/src/module/render/ui/statusBar.tsx @@ -1,7 +1,7 @@ import { GameUI } from '@/core/system'; import { computed, defineComponent, ref, watch } from 'vue'; import { SetupComponentOptions, TextContent } from '../components'; -import { DefaultProps, ElementLocator, Sprite } from '@/core/render'; +import { DefaultProps, ElementLocator, Sprite, Font } from '@/core/render'; import { transitionedColor } from '../use'; import { linear } from 'mutate-animate'; import { Scroll } from '../components/scroll'; @@ -62,9 +62,9 @@ export const LeftStatusBar = defineComponent>( return num.toString().padStart(2, '0'); }; - const font1 = '18px normal'; - const font2 = 'bold 18px normal'; - const font3 = 'bold 14px normal'; + const font1 = new Font('normal', 18); + const font2 = new Font('normal', 18, 'px', 700); + const font3 = new Font('normal', 14, 'px', 700); const iconLoc = (n: number): ElementLocator => { return [16, 76 + 44 * n, 32, 32]; @@ -201,8 +201,8 @@ export interface IRightHeroStatus { export const RightStatusBar = defineComponent>( p => { - const font1 = '18px normal'; - const font2 = '16px normal'; + const font1 = new Font('normal', 18); + const font2 = new Font('normal', 16); const minimap = ref(); const inNumpad = ref(false); @@ -340,8 +340,7 @@ export const RightStatusBar = defineComponent>( diff --git a/src/module/render/ui/toolbar.tsx b/src/module/render/ui/toolbar.tsx index a48e7ab..27e8934 100644 --- a/src/module/render/ui/toolbar.tsx +++ b/src/module/render/ui/toolbar.tsx @@ -1,4 +1,4 @@ -import { DefaultProps, ElementLocator } from '@/core/render'; +import { DefaultProps, ElementLocator, Font } from '@/core/render'; import { computed, defineComponent, ref } from 'vue'; import { SetupComponentOptions } from '../components'; import { @@ -82,7 +82,7 @@ export const PlayingToolbar = defineComponent< const loadIcon = core.statusBar.icons.load; const setIcon = core.statusBar.icons.settings; - const iconFont = '12px Verdana'; + const iconFont = new Font('Verdana', 12); const book = () => core.openBook(true); const tool = () => core.openEquipbox(true); @@ -160,8 +160,8 @@ export const ReplayingToolbar = defineComponent(props => { const bookIcon = core.statusBar.icons.book; const saveIcon = core.statusBar.icons.save; - const font1 = '16px normal'; - const font2 = '12px Verdana'; + const font1 = new Font('normal', 16); + const font2 = new Font('Verdana', 12); const speedText = computed(() => `${status.speed}速`); const progress = computed(() => status.played / status.total);