Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -1,4 +1,8 @@
import { AnimationState } from '../../types';
import {
AnimationState,
type Character,
type CharacterAnimationConfig,
} from '../../types';
export const MASTER_VISUAL_WIDTH = 1024;
export const MASTER_VISUAL_HEIGHT = 1536;
@@ -35,6 +39,80 @@ export type DraftAnimationClip = {
loop: boolean;
frameWidth: number;
frameHeight: number;
previewVideoPath?: string;
};
const DEFAULT_CHARACTER_ANIMATIONS: Record<
AnimationState,
CharacterAnimationConfig
> = {
[AnimationState.ACQUIRE]: {
frames: 1,
prefix: 'acquire',
folder: 'acquire',
},
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
[AnimationState.DOUBLE_JUMP]: {
frames: 1,
prefix: 'double jump',
folder: 'double jump',
},
[AnimationState.JUMP_ATTACK]: {
frames: 1,
prefix: 'jump attack',
folder: 'jump attack',
},
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
[AnimationState.SKILL1_JUMP]: {
frames: 1,
prefix: 'skill1 jump',
folder: 'skill1 jump',
},
[AnimationState.SKILL1_BULLET]: {
frames: 1,
prefix: 'skill1 bullet',
folder: 'skill1 bullet',
},
[AnimationState.SKILL1_BULLET_FX]: {
frames: 1,
prefix: 'skill1 bullet FX',
folder: 'skill1 bullet FX',
},
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
[AnimationState.SKILL2_JUMP]: {
frames: 1,
prefix: 'skill2 jump',
folder: 'skill2 jump',
},
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
[AnimationState.SKILL3_JUMP]: {
frames: 1,
prefix: 'skill3 jump',
folder: 'skill3 jump',
},
[AnimationState.SKILL3_BULLET]: {
frames: 1,
prefix: 'skill3 bullet',
folder: 'skill3 bullet',
},
[AnimationState.SKILL3_BULLET_FX]: {
frames: 1,
prefix: 'skill3 bullet FX',
folder: 'skill3 bullet FX',
},
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
[AnimationState.WALL_SLIDE]: {
frames: 1,
prefix: 'Wall Slide',
folder: 'Wall Slide',
},
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
};
type PoseTransform = {
@@ -52,7 +130,11 @@ type ActionTemplate = {
frames: number;
fps: number;
loop: boolean;
poseAt: (progress: number, frameIndex: number, totalFrames: number) => PoseTransform;
poseAt: (
progress: number,
frameIndex: number,
totalFrames: number,
) => PoseTransform;
};
const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
@@ -226,67 +308,133 @@ const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
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 }),
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 }),
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 }),
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 }),
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 }),
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 }),
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 }),
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 }),
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 }),
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 }),
poseAt: () => ({
offsetX: 0,
offsetY: 0,
scaleX: 1,
scaleY: 1,
rotation: 0,
}),
},
};
@@ -309,6 +457,19 @@ export function loadImageFromSource(source: string) {
});
}
function loadVideoFromSource(source: string) {
return new Promise<HTMLVideoElement>((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.muted = true;
video.playsInline = true;
video.onloadeddata = () => resolve(video);
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
video.src = source;
});
}
function createCanvas(width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
@@ -317,7 +478,51 @@ function createCanvas(width: number, height: number) {
if (!context) {
throw new Error('无法创建画布上下文');
}
return {canvas, context};
return { canvas, context };
}
function drawContainedSource(
context: CanvasRenderingContext2D,
source: CanvasImageSource,
sourceWidth: number,
sourceHeight: number,
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 / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * fitScale * scale;
const drawHeight = sourceHeight * 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(
source,
-drawWidth / 2,
-drawHeight / 2,
drawWidth,
drawHeight,
);
context.restore();
}
function drawContainedImage(
@@ -342,18 +547,15 @@ function drawContainedImage(
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();
drawContainedSource(context, image, image.width, image.height, {
width,
height,
translateX,
translateY,
scale,
rotation,
alpha,
});
}
export async function buildVisualCandidatesFromSource(source: string) {
@@ -365,13 +567,22 @@ export async function buildVisualCandidatesFromSource(source: string) {
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)'},
{ 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);
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,
@@ -432,11 +643,11 @@ function drawTintOverlay(
context.restore();
}
function renderPoseFrame(
image: HTMLImageElement,
pose: PoseTransform,
) {
const {canvas, context} = createCanvas(GENERATED_FRAME_WIDTH, GENERATED_FRAME_HEIGHT);
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);
@@ -452,7 +663,13 @@ function renderPoseFrame(
context.globalAlpha = alpha;
context.translate(centerX + offsetX, bottomY);
context.rotate(pose.rotation);
context.drawImage(image, -drawWidth / 2, -drawHeight, drawWidth, drawHeight);
context.drawImage(
image,
-drawWidth / 2,
-drawHeight,
drawWidth,
drawHeight,
);
context.restore();
};
@@ -476,11 +693,9 @@ export async function buildAnimationClipFromMaster(
) {
const image = await loadImageFromSource(masterSource);
const template = ACTION_TEMPLATES[animation];
const frames = Array.from({length: template.frames}, (_, frameIndex) => {
const frames = Array.from({ length: template.frames }, (_, frameIndex) => {
const progress =
template.frames <= 1
? 0
: frameIndex / Math.max(1, template.frames - 1);
template.frames <= 1 ? 0 : frameIndex / Math.max(1, template.frames - 1);
return renderPoseFrame(
image,
template.poseAt(progress, frameIndex, template.frames),
@@ -496,3 +711,309 @@ export async function buildAnimationClipFromMaster(
frameHeight: GENERATED_FRAME_HEIGHT,
} satisfies DraftAnimationClip;
}
function applyGreenScreenAlpha(
context: CanvasRenderingContext2D,
width: number,
height: number,
) {
const imageData = context.getImageData(0, 0, width, height);
const pixels = imageData.data;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
if (alpha === 0) {
continue;
}
if (green > 96 && greenLead > 34) {
const fade = Math.max(0, 255 - greenLead * 5);
pixels[index + 3] = Math.min(alpha, fade);
if (greenLead > 60) {
pixels[index + 3] = 0;
}
}
}
context.putImageData(imageData, 0, 0);
}
async function normalizeFrameSourceToDataUrl(
frameSource: string,
options: {
frameWidth: number;
frameHeight: number;
applyChromaKey: boolean;
},
) {
const image = await loadImageFromSource(frameSource);
const { canvas, context } = createCanvas(
options.frameWidth,
options.frameHeight,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
return canvas.toDataURL('image/png');
}
function seekVideo(video: HTMLVideoElement, targetTime: number) {
return new Promise<void>((resolve, reject) => {
if (Math.abs(video.currentTime - targetTime) < 0.001) {
window.requestAnimationFrame(() => resolve());
return;
}
const handleSeeked = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error('视频定位失败'));
};
const cleanup = () => {
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('error', handleError);
};
video.addEventListener('seeked', handleSeeked, { once: true });
video.addEventListener('error', handleError, { once: true });
video.currentTime = Math.max(0, targetTime);
});
}
export async function buildAnimationClipFromImageSources(
sources: string[],
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
},
) {
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const frames = await Promise.all(
sources.map((source) =>
normalizeFrameSourceToDataUrl(source, {
frameWidth,
frameHeight,
applyChromaKey: options.applyChromaKey ?? false,
}),
),
);
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
} satisfies DraftAnimationClip;
}
export async function buildAnimationClipFromVideoSource(
videoSource: string,
options: {
animation: AnimationState;
fps: number;
loop: boolean;
frameCount?: number;
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
},
) {
const video = await loadVideoFromSource(videoSource);
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
const duration =
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
const derivedFrameCount = Math.max(
2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
);
const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = [];
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
const progress = options.loop
? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(duration - 0.001, duration * progress);
await seekVideo(video, targetTime);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
frames.push(canvas.toDataURL('image/png'));
}
return {
animation: options.animation,
frames,
fps: Math.max(1, options.fps),
loop: options.loop,
frameWidth,
frameHeight,
previewVideoPath: videoSource,
} satisfies DraftAnimationClip;
}
function getCharacterAnimationConfig(
character: Character,
animation: AnimationState,
) {
return (
character.animationMap?.[animation] ??
DEFAULT_CHARACTER_ANIMATIONS[animation] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_CHARACTER_ANIMATIONS[AnimationState.IDLE]
);
}
function getCharacterAnimationFrameSources(
character: Character,
animation: AnimationState,
) {
const config = getCharacterAnimationConfig(character, animation);
const startFrame = config.startFrame || 1;
const frameCount = Math.max(1, config.frames);
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
return Array.from({ length: frameCount }, (_, index) => {
const frameNumber = String(startFrame + index).padStart(2, '0');
if (normalizedBasePath) {
return config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
}
const folder = encodeURIComponent(character.assetFolder);
const variant = encodeURIComponent(character.assetVariant);
const animationFolder = encodeURIComponent(config.folder);
return config.file
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
});
}
function waitFrame(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取 Blob 失败'));
reader.readAsDataURL(blob);
});
}
function pickRecordMimeType() {
const candidates = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
];
if (typeof MediaRecorder === 'undefined') {
return '';
}
return (
candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ??
''
);
}
export async function buildReferenceVideoFromCharacterAnimation(
character: Character,
animation: AnimationState,
options: {
fps?: number;
width?: number;
height?: number;
repeatLoops?: number;
} = {},
) {
if (typeof MediaRecorder === 'undefined') {
throw new Error('当前浏览器不支持 MediaRecorder无法生成内置模板视频。');
}
const frameSources = getCharacterAnimationFrameSources(character, animation);
const images = await Promise.all(
frameSources.map((frameSource) => loadImageFromSource(frameSource)),
);
const width = options.width ?? GENERATED_FRAME_WIDTH;
const height = options.height ?? GENERATED_FRAME_HEIGHT;
const fps = Math.max(1, options.fps ?? 8);
const repeatLoops = Math.max(1, options.repeatLoops ?? 2);
const { canvas, context } = createCanvas(width, height);
const stream = canvas.captureStream(fps);
const mimeType = pickRecordMimeType();
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
);
};
});
recorder.start();
for (let loopIndex = 0; loopIndex < repeatLoops; loopIndex += 1) {
for (const image of images) {
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
await waitFrame(Math.max(40, Math.round(1000 / fps)));
}
}
await waitFrame(80);
recorder.stop();
const blob = await stopPromise;
return blobToDataUrl(blob);
}