498
src/components/preset-editor/characterAssetStudioModel.ts
Normal file
498
src/components/preset-editor/characterAssetStudioModel.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { AnimationState } from '../../types';
|
||||
|
||||
export const MASTER_VISUAL_WIDTH = 1024;
|
||||
export const MASTER_VISUAL_HEIGHT = 1536;
|
||||
export const GENERATED_FRAME_WIDTH = 192;
|
||||
export const GENERATED_FRAME_HEIGHT = 256;
|
||||
|
||||
export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [
|
||||
AnimationState.IDLE,
|
||||
AnimationState.ACQUIRE,
|
||||
AnimationState.ATTACK,
|
||||
AnimationState.RUN,
|
||||
AnimationState.JUMP,
|
||||
AnimationState.DOUBLE_JUMP,
|
||||
AnimationState.JUMP_ATTACK,
|
||||
AnimationState.DASH,
|
||||
AnimationState.HURT,
|
||||
AnimationState.DIE,
|
||||
AnimationState.CLIMB,
|
||||
AnimationState.WALL_SLIDE,
|
||||
];
|
||||
|
||||
export type DraftVisualCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type DraftAnimationClip = {
|
||||
animation: AnimationState;
|
||||
frames: string[];
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
};
|
||||
|
||||
type PoseTransform = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
rotation: number;
|
||||
alpha?: number;
|
||||
tintColor?: string;
|
||||
afterImage?: boolean;
|
||||
};
|
||||
|
||||
type ActionTemplate = {
|
||||
frames: number;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
poseAt: (progress: number, frameIndex: number, totalFrames: number) => PoseTransform;
|
||||
};
|
||||
|
||||
const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
|
||||
[AnimationState.IDLE]: {
|
||||
frames: 8,
|
||||
fps: 8,
|
||||
loop: true,
|
||||
poseAt: (progress) => {
|
||||
const wave = Math.sin(progress * Math.PI * 2);
|
||||
return {
|
||||
offsetX: wave * 1.5,
|
||||
offsetY: wave * -4,
|
||||
scaleX: 1 - wave * 0.015,
|
||||
scaleY: 1 + wave * 0.02,
|
||||
rotation: wave * 0.01,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.ACQUIRE]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: progress < 0.5 ? progress * 6 : (1 - progress) * 6,
|
||||
offsetY: progress < 0.5 ? progress * -18 : -18 + (progress - 0.5) * 18,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: progress < 0.5 ? -0.08 * progress : -0.04 * (1 - progress),
|
||||
}),
|
||||
},
|
||||
[AnimationState.ATTACK]: {
|
||||
frames: 6,
|
||||
fps: 12,
|
||||
loop: false,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: progress < 0.55 ? progress * 30 : 30 - (progress - 0.55) * 30,
|
||||
offsetY: progress < 0.55 ? progress * -12 : -12 + (progress - 0.55) * 10,
|
||||
scaleX: 1 + Math.max(0, Math.sin(progress * Math.PI)) * 0.06,
|
||||
scaleY: 1 - Math.max(0, Math.sin(progress * Math.PI)) * 0.03,
|
||||
rotation: progress < 0.55 ? -0.12 : 0.05 * (progress - 0.55),
|
||||
}),
|
||||
},
|
||||
[AnimationState.RUN]: {
|
||||
frames: 8,
|
||||
fps: 10,
|
||||
loop: true,
|
||||
poseAt: (progress) => {
|
||||
const cycle = Math.sin(progress * Math.PI * 2);
|
||||
return {
|
||||
offsetX: cycle * 8,
|
||||
offsetY: Math.abs(cycle) * -10,
|
||||
scaleX: 1 + Math.max(0, cycle) * 0.04,
|
||||
scaleY: 1 - Math.abs(cycle) * 0.04,
|
||||
rotation: cycle * 0.05,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: (progress) => {
|
||||
const arc = Math.sin(progress * Math.PI);
|
||||
return {
|
||||
offsetX: 0,
|
||||
offsetY: -36 * arc,
|
||||
scaleX: 1,
|
||||
scaleY: 1 - arc * 0.04,
|
||||
rotation: -0.02 + progress * 0.04,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.DOUBLE_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: (progress) => {
|
||||
const arc = Math.sin(progress * Math.PI);
|
||||
return {
|
||||
offsetX: progress < 0.5 ? 6 : -6,
|
||||
offsetY: -48 * arc,
|
||||
scaleX: 1 + arc * 0.03,
|
||||
scaleY: 1 - arc * 0.05,
|
||||
rotation: -0.08 + progress * 0.16,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.JUMP_ATTACK]: {
|
||||
frames: 6,
|
||||
fps: 12,
|
||||
loop: false,
|
||||
poseAt: (progress) => {
|
||||
const arc = Math.sin(progress * Math.PI);
|
||||
return {
|
||||
offsetX: progress * 18,
|
||||
offsetY: -28 * arc,
|
||||
scaleX: 1 + arc * 0.05,
|
||||
scaleY: 1 - arc * 0.05,
|
||||
rotation: -0.12 + progress * 0.18,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.DASH]: {
|
||||
frames: 5,
|
||||
fps: 14,
|
||||
loop: false,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: progress * 42,
|
||||
offsetY: -6,
|
||||
scaleX: 1 + progress * 0.08,
|
||||
scaleY: 1 - progress * 0.04,
|
||||
rotation: -0.04,
|
||||
afterImage: progress > 0.15,
|
||||
}),
|
||||
},
|
||||
[AnimationState.HURT]: {
|
||||
frames: 5,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: -18 * Math.sin(progress * Math.PI),
|
||||
offsetY: 4 * progress,
|
||||
scaleX: 1,
|
||||
scaleY: 1 - progress * 0.02,
|
||||
rotation: 0.08 * Math.sin(progress * Math.PI),
|
||||
tintColor: 'rgba(248, 113, 113, 0.22)',
|
||||
}),
|
||||
},
|
||||
[AnimationState.DIE]: {
|
||||
frames: 7,
|
||||
fps: 8,
|
||||
loop: false,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: progress * 18,
|
||||
offsetY: progress * 34,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: progress * 1.35,
|
||||
alpha: 1 - progress * 0.18,
|
||||
}),
|
||||
},
|
||||
[AnimationState.CLIMB]: {
|
||||
frames: 6,
|
||||
fps: 8,
|
||||
loop: true,
|
||||
poseAt: (progress) => {
|
||||
const cycle = Math.sin(progress * Math.PI * 2);
|
||||
return {
|
||||
offsetX: cycle * 2,
|
||||
offsetY: cycle * -12,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: cycle * 0.02,
|
||||
};
|
||||
},
|
||||
},
|
||||
[AnimationState.WALL_SLIDE]: {
|
||||
frames: 4,
|
||||
fps: 8,
|
||||
loop: true,
|
||||
poseAt: (progress) => ({
|
||||
offsetX: -8,
|
||||
offsetY: progress * 18,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: -0.05,
|
||||
alpha: 0.96,
|
||||
}),
|
||||
},
|
||||
[AnimationState.SKILL1]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL1_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL1_BULLET_FX]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL2]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL2_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL3]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL3_JUMP]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL3_BULLET_FX]: {
|
||||
frames: 4,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
[AnimationState.SKILL4]: {
|
||||
frames: 6,
|
||||
fps: 10,
|
||||
loop: false,
|
||||
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
|
||||
},
|
||||
};
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
return {canvas, context};
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
width: number;
|
||||
height: number;
|
||||
translateX?: number;
|
||||
translateY?: number;
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
alpha?: number;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
scale = 1,
|
||||
rotation = 0,
|
||||
alpha = 1,
|
||||
} = options;
|
||||
const fitScale = Math.min(width / image.width, height / image.height);
|
||||
const drawWidth = image.width * fitScale * scale;
|
||||
const drawHeight = image.height * fitScale * scale;
|
||||
const centerX = width / 2 + translateX;
|
||||
const centerY = height / 2 + translateY;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
context.translate(centerX, centerY);
|
||||
context.rotate(rotation);
|
||||
context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export async function buildVisualCandidatesFromSource(source: string) {
|
||||
const image = await loadImageFromSource(source);
|
||||
const variants: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
scale: number;
|
||||
translateY: number;
|
||||
tint?: string;
|
||||
}> = [
|
||||
{id: 'balanced', label: '平衡构图', scale: 1, translateY: 0},
|
||||
{id: 'closer', label: '主体更近', scale: 1.08, translateY: 18},
|
||||
{id: 'lighter', label: '轻提主体', scale: 0.96, translateY: -22, tint: 'rgba(16, 185, 129, 0.08)'},
|
||||
];
|
||||
|
||||
return variants.map((variant) => {
|
||||
const {canvas, context} = createCanvas(MASTER_VISUAL_WIDTH, MASTER_VISUAL_HEIGHT);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width * 0.82,
|
||||
height: canvas.height * 0.86,
|
||||
translateY: variant.translateY,
|
||||
scale: variant.scale,
|
||||
});
|
||||
|
||||
if (variant.tint) {
|
||||
context.save();
|
||||
context.globalCompositeOperation = 'source-atop';
|
||||
context.fillStyle = variant.tint;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.restore();
|
||||
}
|
||||
return {
|
||||
id: variant.id,
|
||||
label: variant.label,
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
} satisfies DraftVisualCandidate;
|
||||
});
|
||||
}
|
||||
|
||||
function drawShadow(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
pose: PoseTransform,
|
||||
) {
|
||||
context.save();
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.2)';
|
||||
context.beginPath();
|
||||
context.ellipse(
|
||||
width / 2 + pose.offsetX * 0.15,
|
||||
height * 0.92 + pose.offsetY * 0.05,
|
||||
width * 0.18,
|
||||
height * 0.04,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawTintOverlay(
|
||||
context: CanvasRenderingContext2D,
|
||||
tintColor: string,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
context.save();
|
||||
context.globalCompositeOperation = 'source-atop';
|
||||
context.fillStyle = tintColor;
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function renderPoseFrame(
|
||||
image: HTMLImageElement,
|
||||
pose: PoseTransform,
|
||||
) {
|
||||
const {canvas, context} = createCanvas(GENERATED_FRAME_WIDTH, GENERATED_FRAME_HEIGHT);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawShadow(context, canvas.width, canvas.height, pose);
|
||||
|
||||
const naturalAspect = image.width / image.height;
|
||||
const baseHeight = canvas.height * 0.82;
|
||||
const drawWidth = baseHeight * naturalAspect * pose.scaleX;
|
||||
const drawHeight = baseHeight * pose.scaleY;
|
||||
const bottomY = canvas.height * 0.9 + pose.offsetY;
|
||||
const centerX = canvas.width / 2 + pose.offsetX;
|
||||
|
||||
const drawSprite = (alpha: number, offsetX: number) => {
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
context.translate(centerX + offsetX, bottomY);
|
||||
context.rotate(pose.rotation);
|
||||
context.drawImage(image, -drawWidth / 2, -drawHeight, drawWidth, drawHeight);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
if (pose.afterImage) {
|
||||
drawSprite(0.18, -18);
|
||||
drawSprite(0.1, -28);
|
||||
}
|
||||
|
||||
drawSprite(pose.alpha ?? 1, 0);
|
||||
|
||||
if (pose.tintColor) {
|
||||
drawTintOverlay(context, pose.tintColor, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromMaster(
|
||||
masterSource: string,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
const image = await loadImageFromSource(masterSource);
|
||||
const template = ACTION_TEMPLATES[animation];
|
||||
const frames = Array.from({length: template.frames}, (_, frameIndex) => {
|
||||
const progress =
|
||||
template.frames <= 1
|
||||
? 0
|
||||
: frameIndex / Math.max(1, template.frames - 1);
|
||||
return renderPoseFrame(
|
||||
image,
|
||||
template.poseAt(progress, frameIndex, template.frames),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
animation,
|
||||
frames,
|
||||
fps: template.fps,
|
||||
loop: template.loop,
|
||||
frameWidth: GENERATED_FRAME_WIDTH,
|
||||
frameHeight: GENERATED_FRAME_HEIGHT,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
Reference in New Issue
Block a user