refactor: 字体类

This commit is contained in:
unanmed 2025-02-28 17:10:12 +08:00
parent 4c898a7836
commit ee5b962743
14 changed files with 241 additions and 67 deletions

View File

@ -1,5 +1,6 @@
export * from './preset';
export * from './renderer';
export * from './style';
export * from './adapter';
export * from './cache';
export * from './camera';

View File

@ -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<ETextEvent> {
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<ETextEvent> {
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<ETextEvent> {
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<ETextEvent> {
* 使
* @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<ETextEvent> {
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':

View File

@ -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;
}

View File

@ -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<Font>) {
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<Font>();
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<IFontConfig>
) {
return new Font(family, size, sizeUnit, weight, italic);
}
}

View File

@ -0,0 +1 @@
export * from './font';

View File

@ -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."
}

View File

@ -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';

View File

@ -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<PageProps, {}, string, PageSlots>;
/**
@ -80,14 +82,15 @@ export const Page = defineComponent<PageProps, {}, string, PageSlots>(
const leftArrow = ref<Path2D>();
const rightArrow = ref<Path2D>();
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<PageProps, {}, string, PageSlots>(
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<PageProps, {}, string, PageSlots>(
};
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<PageProps, {}, string, PageSlots>(
};
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<PageProps, {}, string, PageSlots>(
<container loc={contentLoc.value}>
{slots.default?.(nowPage.value)}
</container>
<container loc={pageLoc.value}>
<container
loc={pageLoc.value}
hidden={props.hideIfSingle && props.pages === 1}
>
<container
loc={leftLoc.value}
onClick={lastPage}
@ -214,7 +220,7 @@ export const Page = defineComponent<PageProps, {}, string, PageSlots>(
<text
loc={textLoc.value}
text={(nowPage.value - 1).toString()}
font={pageFont.value}
font={font.value}
></text>
</container>
)}
@ -251,7 +257,7 @@ export const Page = defineComponent<PageProps, {}, string, PageSlots>(
<text
loc={textLoc.value}
text={(nowPage.value + 1).toString()}
font={pageFont.value}
font={font.value}
></text>
</container>
)}

View File

@ -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;

View File

@ -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<TextContentTyperEvent> {
/** 文字配置信息 */
readonly config: Required<ITextContentConfig>;
readonly config: Required<TyperConfig>;
/** 文字解析器 */
readonly parser: TextContentParser;
@ -205,12 +211,14 @@ export class TextContentTyper extends EventEmitter<TextContentTyperEvent> {
constructor(config: Partial<ITextContentConfig>) {
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}"`;

View File

@ -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<TipProps>((props, { expose }) => {
const text = ref<string>('');
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]);

View File

@ -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,

View File

@ -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<StatusBarProps<ILeftHeroStatus>>(
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<StatusBarProps<IRightHeroStatus>>(
p => {
const font1 = '18px normal';
const font2 = '16px normal';
const font1 = new Font('normal', 18);
const font2 = new Font('normal', 16);
const minimap = ref<Sprite>();
const inNumpad = ref(false);
@ -340,8 +340,7 @@ export const RightStatusBar = defineComponent<StatusBarProps<IRightHeroStatus>>(
<TextContent
loc={[10, 42, 160, 60]}
text={skillDesc.value}
fontFamily="normal"
fontSize={14}
font={new Font('normal', 14)}
width={160}
lineHeight={4}
></TextContent>

View File

@ -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<ReplayingProps>(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);