Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8a491f2a | ||
|
|
ff115f70ca |
@@ -400,6 +400,7 @@
|
||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
|
||||
.editor-template-live-preview { display: grid; width: 100%; height: 44px; place-items: center; overflow: hidden; border: 1px solid #111111; background: #30343b; white-space: nowrap; }
|
||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
|
||||
@@ -946,6 +946,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
fontStatuses={fontStatuses}
|
||||
onAdd={(template) => { void addTextTemplate(template); }}
|
||||
onCategory={setTemplateCategory}
|
||||
onEnsure={(template) => { void ensureTemplateFonts(template); }}
|
||||
onQuery={setTemplateQuery}
|
||||
onRetry={() => { void retryTextFonts(); }}
|
||||
query={templateQuery}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
textTemplateFontOptions,
|
||||
textTemplateImageUrls,
|
||||
type TextTemplateImageLayer,
|
||||
type TextTemplateNinePatch,
|
||||
type TextTemplateParticleLayer,
|
||||
type TextTemplateTextLayer,
|
||||
} from "./text-assets.js";
|
||||
@@ -68,6 +69,47 @@ interface TextPixelGeometry {
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface NinePatchRectangle { height: number; width: number; x: number; y: number }
|
||||
export interface NinePatchSlice { destination: NinePatchRectangle; source: NinePatchRectangle }
|
||||
|
||||
function destinationEdges(size: number, first: number, last: number) {
|
||||
const fixed = first + last;
|
||||
if (fixed <= size || fixed === 0) return [first, last] as const;
|
||||
const ratio = size / fixed;
|
||||
return [first * ratio, last * ratio] as const;
|
||||
}
|
||||
|
||||
export function ninePatchSlices(
|
||||
width: number,
|
||||
height: number,
|
||||
patch: TextTemplateNinePatch,
|
||||
bitmapSize: { height: number; width: number } = { height: patch.sourceHeight, width: patch.sourceWidth },
|
||||
): NinePatchSlice[] {
|
||||
const [left, right] = destinationEdges(width, patch.left, patch.right);
|
||||
const [top, bottom] = destinationEdges(height, patch.top, patch.bottom);
|
||||
const sourceScaleX = bitmapSize.width / patch.sourceWidth;
|
||||
const sourceScaleY = bitmapSize.height / patch.sourceHeight;
|
||||
const sourceColumns = [0, patch.left * sourceScaleX, bitmapSize.width - patch.right * sourceScaleX, bitmapSize.width];
|
||||
const sourceRows = [0, patch.top * sourceScaleY, bitmapSize.height - patch.bottom * sourceScaleY, bitmapSize.height];
|
||||
const destinationColumns = [0, left, width - right, width];
|
||||
const destinationRows = [0, top, height - bottom, height];
|
||||
const slices: NinePatchSlice[] = [];
|
||||
for (let row = 0; row < 3; row += 1) {
|
||||
for (let column = 0; column < 3; column += 1) {
|
||||
const source = {
|
||||
height: sourceRows[row + 1]! - sourceRows[row]!, width: sourceColumns[column + 1]! - sourceColumns[column]!,
|
||||
x: sourceColumns[column]!, y: sourceRows[row]!,
|
||||
};
|
||||
const destination = {
|
||||
height: destinationRows[row + 1]! - destinationRows[row]!, width: destinationColumns[column + 1]! - destinationColumns[column]!,
|
||||
x: destinationColumns[column]!, y: destinationRows[row]!,
|
||||
};
|
||||
if (source.width > 0 && source.height > 0 && destination.width > 0 && destination.height > 0) slices.push({ destination, source });
|
||||
}
|
||||
}
|
||||
return slices;
|
||||
}
|
||||
|
||||
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||
const fontSize = element.font_size ?? 48;
|
||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||
@@ -97,10 +139,25 @@ function drawTemplateImageLayer(
|
||||
const image = resourceImages[layer.assetId];
|
||||
if (!image) return;
|
||||
context.save();
|
||||
context.globalAlpha *= layer.alpha;
|
||||
context.translate(layer.x, layer.y);
|
||||
context.rotate(layer.rotation * Math.PI / 180);
|
||||
context.scale(layer.scaleX, layer.scaleY);
|
||||
context.drawImage(image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
const left = -layer.anchorX * layer.width;
|
||||
const top = -layer.anchorY * layer.height;
|
||||
if (layer.ninePatch) {
|
||||
for (const slice of ninePatchSlices(
|
||||
layer.width,
|
||||
layer.height,
|
||||
layer.ninePatch,
|
||||
{ height: image.naturalHeight, width: image.naturalWidth },
|
||||
)) {
|
||||
context.drawImage(
|
||||
image, slice.source.x, slice.source.y, slice.source.width, slice.source.height,
|
||||
left + slice.destination.x, top + slice.destination.y, slice.destination.width, slice.destination.height,
|
||||
);
|
||||
}
|
||||
} else context.drawImage(image, left, top, layer.width, layer.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -158,6 +215,7 @@ function drawTemplateTextLayer(
|
||||
const align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as CanvasTextAlign;
|
||||
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n");
|
||||
context.save();
|
||||
context.globalAlpha *= layer.alpha;
|
||||
context.translate(layer.x, layer.y);
|
||||
context.rotate(layer.rotation * Math.PI / 180);
|
||||
context.scale(layer.scaleX, layer.scaleY);
|
||||
@@ -185,8 +243,11 @@ function drawTemplateTextLayer(
|
||||
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
|
||||
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
|
||||
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
|
||||
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
|
||||
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
|
||||
const centerY = (0.5 - layer.anchorY) * textHeight;
|
||||
const firstY = centerY - ((lines.length - 1) * fontSize * lineHeight) / 2;
|
||||
const anchorX = align === "left" ? -layer.anchorX * textWidth
|
||||
: align === "right" ? (1 - layer.anchorX) * textWidth
|
||||
: (0.5 - layer.anchorX) * textWidth;
|
||||
lines.forEach((line, index) => {
|
||||
const y = firstY + index * fontSize * lineHeight;
|
||||
if ((editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0) {
|
||||
|
||||
+2307
-1236
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,9 @@ export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||
export type TextAlign = "center" | "left" | "right";
|
||||
|
||||
export interface TextTemplateImageLayer {
|
||||
alpha: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
assetId: string;
|
||||
height: number;
|
||||
order: number;
|
||||
@@ -19,6 +22,16 @@ export interface TextTemplateImageLayer {
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
ninePatch?: TextTemplateNinePatch;
|
||||
}
|
||||
|
||||
export interface TextTemplateNinePatch {
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
sourceHeight: number;
|
||||
sourceWidth: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
|
||||
@@ -34,7 +47,10 @@ export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
|
||||
}
|
||||
|
||||
export interface TextTemplateTextLayer {
|
||||
alpha: number;
|
||||
align: TextAlign;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
editable: boolean;
|
||||
fillColor: string;
|
||||
fillPatternAssetId?: string;
|
||||
@@ -122,16 +138,23 @@ const defaults = {
|
||||
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
||||
|
||||
function imageLayer(layer: {
|
||||
asset_id: string; height: number; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
|
||||
alpha?: number; anchor_x?: number; anchor_y?: number; asset_id: string; height: number; nine_patch?: {
|
||||
bottom: number; left: number; right: number; source_height: number; source_width: number; top: number;
|
||||
}; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
|
||||
}): TextTemplateImageLayer {
|
||||
return {
|
||||
alpha: layer.alpha ?? 1, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
||||
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
|
||||
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
|
||||
...(layer.nine_patch ? { ninePatch: {
|
||||
bottom: layer.nine_patch.bottom, left: layer.nine_patch.left, right: layer.nine_patch.right,
|
||||
sourceHeight: layer.nine_patch.source_height, sourceWidth: layer.nine_patch.source_width, top: layer.nine_patch.top,
|
||||
} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface RawTextLayer {
|
||||
align: string; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number;
|
||||
alpha?: number; align: string; anchor_x?: number; anchor_y?: number; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number;
|
||||
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
|
||||
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
|
||||
stroke_width: number; text: string; width: number; x: number; y: number;
|
||||
@@ -149,7 +172,8 @@ function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]):
|
||||
textLayers: item.render_model.text_layers.map((value) => {
|
||||
const layer = value as unknown as RawTextLayer;
|
||||
return {
|
||||
align: layer.align as TextAlign, editable: layer.editable, fillColor: layer.fill_color,
|
||||
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
||||
editable: layer.editable, fillColor: layer.fill_color,
|
||||
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
|
||||
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
|
||||
lineHeight: layer.line_height, order: layer.order, rotation: layer.rotation, scaleX: layer.scale_x,
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||
|
||||
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||
];
|
||||
|
||||
function LiveTemplatePreview(props: { onEnsure: () => void; template: TextTemplateDefinition }) {
|
||||
const previewRef = useRef<HTMLSpanElement>(null);
|
||||
const layer = props.template.renderModel.textLayers.find((item) => item.editable) ?? props.template.renderModel.textLayers[0]!;
|
||||
useEffect(() => {
|
||||
const node = previewRef.current;
|
||||
if (!node || typeof IntersectionObserver === "undefined") {
|
||||
props.onEnsure();
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
observer.disconnect();
|
||||
props.onEnsure();
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [props.onEnsure, props.template.templateId]);
|
||||
const scale = Math.min(1, 38 / Math.max(1, layer.height), 112 / Math.max(1, layer.width));
|
||||
const style: CSSProperties = {
|
||||
color: layer.fillColor,
|
||||
fontFamily: `"${fontFamilyName(layer.fontId)}"`,
|
||||
fontSize: `${layer.fontSize * scale}px`,
|
||||
letterSpacing: `${layer.letterSpacing * scale}px`,
|
||||
lineHeight: layer.lineHeight,
|
||||
textShadow: layer.shadowBlur > 0 || layer.shadowOffsetX !== 0 || layer.shadowOffsetY !== 0
|
||||
? `${layer.shadowOffsetX * scale}px ${layer.shadowOffsetY * scale}px ${layer.shadowBlur * scale}px ${layer.shadowColor}`
|
||||
: undefined,
|
||||
transform: `rotate(${layer.rotation}deg) scale(${layer.scaleX}, ${layer.scaleY})`,
|
||||
WebkitTextStroke: layer.strokeWidth > 0 ? `${layer.strokeWidth * scale}px ${layer.strokeColor}` : undefined,
|
||||
};
|
||||
return <span className="editor-template-live-preview" ref={previewRef} style={style}>{layer.text || props.template.defaultText}</span>;
|
||||
}
|
||||
|
||||
export function TextTemplatePanel(props: {
|
||||
canAdd: boolean;
|
||||
category?: TextTemplateCategory;
|
||||
@@ -12,6 +46,7 @@ export function TextTemplatePanel(props: {
|
||||
onAdd: (template: TextTemplateDefinition) => void;
|
||||
onCategory: (category?: TextTemplateCategory) => void;
|
||||
onQuery: (query: string) => void;
|
||||
onEnsure: (template: TextTemplateDefinition) => void;
|
||||
onRetry: () => void;
|
||||
query: string;
|
||||
recentIds: readonly string[];
|
||||
@@ -36,7 +71,7 @@ export function TextTemplatePanel(props: {
|
||||
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||
{template.previewUrl
|
||||
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
|
||||
: <span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>}
|
||||
: <LiveTemplatePreview onEnsure={() => props.onEnsure(template)} template={template} />}
|
||||
<strong>{template.templateId}</strong>
|
||||
<span>{template.displayName}</span>
|
||||
{unavailable ? <small>素材暂不可用</small> : retryable ? <small>点击重试原版字体</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||
|
||||
@@ -280,6 +280,7 @@ function manifestFileMap(packageFiles) {
|
||||
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
|
||||
}
|
||||
const mappings = new Map();
|
||||
const spriteDefinitions = new Map();
|
||||
const manifestEntries = [];
|
||||
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
|
||||
let manifest;
|
||||
@@ -299,7 +300,10 @@ function manifestFileMap(packageFiles) {
|
||||
const resolved = existsSync(candidate) ? candidate
|
||||
: filesByName.get(normalizedName)?.[0]
|
||||
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
|
||||
if (resolved) mappings.set(uuid, resolved);
|
||||
if (resolved) {
|
||||
mappings.set(uuid, resolved);
|
||||
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
|
||||
}
|
||||
else mappings.set(uuid, fileName);
|
||||
}
|
||||
}
|
||||
@@ -333,9 +337,13 @@ function manifestFileMap(packageFiles) {
|
||||
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
|
||||
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
|
||||
: undefined;
|
||||
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
|
||||
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
|
||||
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
|
||||
}
|
||||
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
|
||||
}
|
||||
return mappings;
|
||||
return { mappings, spriteDefinitions };
|
||||
}
|
||||
|
||||
function colorHex(value, fallback = "#111111") {
|
||||
@@ -350,6 +358,29 @@ function quaternionDegrees(rotation) {
|
||||
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function spriteNinePatch(sprite) {
|
||||
const object = sprite?.object;
|
||||
if (Number(object?.m_type) !== 3) return undefined;
|
||||
const sourceWidth = Number(object.m_width);
|
||||
const sourceHeight = Number(object.m_height);
|
||||
const left = Number(object.m_startW);
|
||||
const rightEdge = Number(object.m_endW);
|
||||
const top = Number(object.m_startH);
|
||||
const bottomEdge = Number(object.m_endH);
|
||||
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|
||||
|| sourceWidth <= 0 || sourceHeight <= 0
|
||||
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|
||||
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
|
||||
return {
|
||||
bottom: sourceHeight - bottomEdge,
|
||||
left,
|
||||
right: sourceWidth - rightEdge,
|
||||
source_height: sourceHeight,
|
||||
source_width: sourceWidth,
|
||||
top,
|
||||
};
|
||||
}
|
||||
|
||||
function pngDimensions(path) {
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
|
||||
@@ -437,12 +468,18 @@ function prefabLayers(prefab, input) {
|
||||
const localScale = local.m_Scale ?? {};
|
||||
const scaleX = parent.scaleX * Number(localScale.x ?? 1);
|
||||
const scaleY = parent.scaleY * Number(localScale.y ?? 1);
|
||||
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
|
||||
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
|
||||
const parentRadians = parent.rotation * Math.PI / 180;
|
||||
const transform = {
|
||||
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
|
||||
anchorX: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.x ?? 0.5))),
|
||||
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
|
||||
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
|
||||
scaleX,
|
||||
scaleY,
|
||||
x: parent.x + (ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX),
|
||||
y: parent.y + (ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY),
|
||||
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
|
||||
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
|
||||
};
|
||||
const nodeComponents = components(object, resolve);
|
||||
const localUnderlines = [
|
||||
@@ -454,12 +491,14 @@ function prefabLayers(prefab, input) {
|
||||
if (textMesh) {
|
||||
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
|
||||
const style = textMesh.m_fontStyleInfo ?? {};
|
||||
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo
|
||||
: style.shadowInfos?.find((item) => item?.outlineInfo?.outlineSize > 0)?.outlineInfo;
|
||||
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
|
||||
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
|
||||
const fontUuid = textMesh.m_font?.uuid?.uuid;
|
||||
layers.push({
|
||||
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "left" : "center",
|
||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
||||
fill_color: colorHex(style.color),
|
||||
fill_pattern_asset_id: firstMaterialTextureAssetId(textRenderer, input),
|
||||
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
|
||||
@@ -468,7 +507,7 @@ function prefabLayers(prefab, input) {
|
||||
letter_spacing: Number(style.characterSpacing ?? 1),
|
||||
line_height: Number(style.lineSpacing ?? 1),
|
||||
order: order++,
|
||||
rotation: transform.rotation,
|
||||
rotation: -transform.rotation,
|
||||
scale_x: Math.sign(scaleX) || 1,
|
||||
scale_y: Math.sign(scaleY) || 1,
|
||||
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
|
||||
@@ -481,7 +520,7 @@ function prefabLayers(prefab, input) {
|
||||
type: "text",
|
||||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
y: -transform.y,
|
||||
});
|
||||
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
|
||||
const config = underline.p1?.exportParams ?? {};
|
||||
@@ -496,16 +535,17 @@ function prefabLayers(prefab, input) {
|
||||
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
|
||||
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
|
||||
layers.push({
|
||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||||
asset_id: assetId,
|
||||
height: targetHeight,
|
||||
order: order++,
|
||||
rotation: transform.rotation,
|
||||
rotation: -transform.rotation,
|
||||
scale_x: Math.sign(scaleX) || 1,
|
||||
scale_y: Math.sign(scaleY) || 1,
|
||||
type: "image",
|
||||
width: targetWidth,
|
||||
x: transform.x,
|
||||
y: transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
|
||||
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
|
||||
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
|
||||
});
|
||||
}
|
||||
@@ -516,7 +556,9 @@ function prefabLayers(prefab, input) {
|
||||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
||||
if (!assetId) continue;
|
||||
layers.push({
|
||||
alpha: Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
||||
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
||||
anchor_x: transform.anchorX,
|
||||
anchor_y: 1 - transform.anchorY,
|
||||
asset_id: assetId,
|
||||
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
|
||||
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
|
||||
@@ -528,13 +570,13 @@ function prefabLayers(prefab, input) {
|
||||
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
|
||||
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
|
||||
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
|
||||
rotation: transform.rotation,
|
||||
rotation: -transform.rotation,
|
||||
scale_x: Math.sign(scaleX) || 1,
|
||||
scale_y: Math.sign(scaleY) || 1,
|
||||
type: "particles",
|
||||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
y: -transform.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -542,25 +584,39 @@ function prefabLayers(prefab, input) {
|
||||
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
|
||||
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
|
||||
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
|
||||
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
|
||||
let imagePath;
|
||||
if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
||||
let ninePatch;
|
||||
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
|
||||
const sprite = readJson(spriteDefinitionPath);
|
||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||||
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
|
||||
? spritePath
|
||||
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
|
||||
ninePatch = spriteNinePatch(sprite);
|
||||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
||||
const sprite = readJson(spritePath);
|
||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
||||
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
|
||||
ninePatch = spriteNinePatch(sprite);
|
||||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
|
||||
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
|
||||
if (assetId) {
|
||||
layers.push({
|
||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
||||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
||||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
||||
asset_id: assetId,
|
||||
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
|
||||
order: order++,
|
||||
rotation: transform.rotation,
|
||||
rotation: -transform.rotation,
|
||||
scale_x: Math.sign(scaleX) || 1,
|
||||
scale_y: Math.sign(scaleY) || 1,
|
||||
type: "image",
|
||||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
y: -transform.y,
|
||||
...(ninePatch ? { nine_patch: ninePatch } : {}),
|
||||
});
|
||||
} else {
|
||||
input.unresolvedImages.push({ spriteUuid, spritePath });
|
||||
@@ -568,7 +624,7 @@ function prefabLayers(prefab, input) {
|
||||
}
|
||||
for (const child of object.m_Children ?? []) visit(child, transform);
|
||||
};
|
||||
for (const child of root?.m_Children ?? []) visit(child, { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
|
||||
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
|
||||
return layers;
|
||||
}
|
||||
|
||||
@@ -593,12 +649,28 @@ function normalizeModel(layers, defaultValue, fontResources) {
|
||||
layer.font_id = fontIdForFile(fontResources, layer.font_file);
|
||||
delete layer.font_file;
|
||||
}
|
||||
const bounds = layers.map((layer) => ({
|
||||
bottom: layer.y + layer.height / 2,
|
||||
left: layer.x - layer.width / 2,
|
||||
right: layer.x + layer.width / 2,
|
||||
top: layer.y - layer.height / 2,
|
||||
}));
|
||||
const bounds = layers.map((layer) => {
|
||||
const anchorX = Number(layer.anchor_x ?? 0.5);
|
||||
const anchorY = Number(layer.anchor_y ?? 0.5);
|
||||
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
|
||||
const cosine = Math.cos(radians);
|
||||
const sine = Math.sin(radians);
|
||||
const corners = [
|
||||
[-anchorX * layer.width, -anchorY * layer.height],
|
||||
[(1 - anchorX) * layer.width, -anchorY * layer.height],
|
||||
[-anchorX * layer.width, (1 - anchorY) * layer.height],
|
||||
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
|
||||
].map(([x, y]) => ({
|
||||
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
|
||||
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
|
||||
}));
|
||||
return {
|
||||
bottom: Math.max(...corners.map((item) => item.y)),
|
||||
left: Math.min(...corners.map((item) => item.x)),
|
||||
right: Math.max(...corners.map((item) => item.x)),
|
||||
top: Math.min(...corners.map((item) => item.y)),
|
||||
};
|
||||
});
|
||||
const left = Math.min(...bounds.map((item) => item.left));
|
||||
const right = Math.max(...bounds.map((item) => item.right));
|
||||
const top = Math.min(...bounds.map((item) => item.top));
|
||||
@@ -651,7 +723,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
||||
sourcePath,
|
||||
}));
|
||||
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
|
||||
const manifestMappings = manifestFileMap(packageFiles);
|
||||
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
|
||||
const unresolvedImages = [];
|
||||
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
|
||||
let prefab;
|
||||
@@ -660,7 +732,7 @@ export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const layers = prefabLayers(prefab, { imageIds, manifestMappings, unresolvedImages });
|
||||
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
|
||||
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
|
||||
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
||||
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
|
||||
|
||||
@@ -162,6 +162,47 @@ test("POSTV1-ASSET-ALL-16 retries a transient archived font failure without perm
|
||||
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("POSTV1-TEMPLATE-FIDELITY-17 renders archived styles instead of two-character preview placeholders", async ({ page }) => {
|
||||
const projectId = uuid(737);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
|
||||
const template = page.getByRole("button", { name: /FLOWER003 人生照片/ });
|
||||
const preview = template.locator(".editor-template-live-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(template.locator(".editor-template-mark")).toHaveCount(0);
|
||||
await expect(preview).toContainText("#人生照片");
|
||||
await expect(preview).toHaveCSS("font-family", /Dada_TEXT_FONT_FLOWER003_01/);
|
||||
await expect(preview).toHaveCSS("color", "rgb(255, 255, 248)");
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_TEXT_FONT_FLOWER003_01"'))).toBe(true);
|
||||
});
|
||||
|
||||
test("POSTV1-TEMPLATE-FIDELITY-17 isolates manual stroke state between templates", async ({ page }) => {
|
||||
const projectId = uuid(738);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
|
||||
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||
await page.getByLabel("启用描边").check();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: /FLOWER002 笑不活了/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements).toHaveLength(2);
|
||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||
expect(backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
||||
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
||||
|
||||
await page.getByLabel("文字模板切换").selectOption("H003");
|
||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
||||
await expect.poll(() => backend.canvas.elements[1]?.template_or_asset_id).toBe("H003");
|
||||
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
|
||||
const projectId = uuid(736);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { P0A_DYNAMIC_STICKERS } from "../../apps/web/src/dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js";
|
||||
import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js";
|
||||
import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js";
|
||||
import { ninePatchSlices } from "../../apps/web/src/editor-stage.js";
|
||||
import {
|
||||
P0A_COLOR_CARD_IDS,
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
@@ -82,6 +83,54 @@ describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
|
||||
|
||||
const particleText = byId.get("H013")!;
|
||||
expect(particleText.render_model.particle_layers).toHaveLength(3);
|
||||
|
||||
const ninePatchLayers = complexAssetCatalog.text_templates.flatMap((item) => item.render_model.image_layers)
|
||||
.filter((layer) => "nine_patch" in layer);
|
||||
expect(ninePatchLayers.length).toBeGreaterThanOrEqual(100);
|
||||
expect(byId.get("FLOWER023")!.render_model.image_layers[0]).toMatchObject({
|
||||
nine_patch: { bottom: 16, left: 53, right: 25, source_height: 63, source_width: 159, top: 12 },
|
||||
});
|
||||
expect(byId.get("H001")!.render_model.image_layers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ anchor_x: 0 }),
|
||||
expect.objectContaining({ anchor_x: 1 }),
|
||||
]));
|
||||
expect(byId.get("H079")!.render_model.text_layers.slice(0, 3).every((layer) => Math.abs(layer.rotation + 10) < 0.001)).toBe(true);
|
||||
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ anchor_y: 0 });
|
||||
|
||||
const heading041 = byId.get("H041")!;
|
||||
const heading041Text = heading041.render_model.text_layers[0]!;
|
||||
const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!;
|
||||
expect(heading041Text.stroke_width).toBe(0);
|
||||
expect(heading041Underline.y).toBeGreaterThan(heading041Text.y);
|
||||
expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 });
|
||||
});
|
||||
|
||||
it("preserves nine-patch edges while stretching only the decoration center", () => {
|
||||
const slices = ninePatchSlices(231, 63, {
|
||||
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
|
||||
});
|
||||
expect(slices).toHaveLength(9);
|
||||
expect(slices[0]).toEqual({ destination: { height: 12, width: 53, x: 0, y: 0 }, source: { height: 12, width: 53, x: 0, y: 0 } });
|
||||
expect(slices[4]).toEqual({ destination: { height: 35, width: 153, x: 53, y: 12 }, source: { height: 35, width: 81, x: 53, y: 12 } });
|
||||
expect(slices[8]).toEqual({ destination: { height: 16, width: 25, x: 206, y: 47 }, source: { height: 16, width: 25, x: 134, y: 47 } });
|
||||
});
|
||||
|
||||
it("scales nine-patch source slices to the deployed bitmap without changing layout edges", () => {
|
||||
const slices = ninePatchSlices(231, 63, {
|
||||
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
|
||||
}, { height: 199, width: 509 });
|
||||
|
||||
expect(slices[0]!.destination).toEqual({ height: 12, width: 53, x: 0, y: 0 });
|
||||
expect(slices[4]!.destination).toEqual({ height: 35, width: 153, x: 53, y: 12 });
|
||||
expect(slices[8]!.destination).toEqual({ height: 16, width: 25, x: 206, y: 47 });
|
||||
expect(slices[4]!.source.x).toBeCloseTo(169.667, 3);
|
||||
expect(slices[4]!.source.y).toBeCloseTo(37.905, 3);
|
||||
expect(slices[4]!.source.width).toBeCloseTo(259.302, 3);
|
||||
expect(slices[4]!.source.height).toBeCloseTo(110.556, 3);
|
||||
expect(slices[8]!.source.x).toBeCloseTo(428.969, 3);
|
||||
expect(slices[8]!.source.y).toBeCloseTo(148.46, 2);
|
||||
expect(slices[8]!.source.width).toBeCloseTo(80.031, 3);
|
||||
expect(slices[8]!.source.height).toBeCloseTo(50.54, 2);
|
||||
});
|
||||
|
||||
it("exposes every generated definition to the editor", () => {
|
||||
|
||||
Reference in New Issue
Block a user