初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

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