Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/parser/src/config/scriptConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const SCRIPT_CONFIG = [
{ scriptString: 'wait', scriptType: commandType.wait },
{ scriptString: 'callSteam', scriptType: commandType.callSteam },
{ scriptString: 'return', scriptType: commandType.return },
{ scriptString: 'character', scriptType: commandType.character },
];
export const ADD_NEXT_ARG_LIST = [
commandType.bgm,
Expand Down
1 change: 1 addition & 0 deletions packages/parser/src/interface/sceneInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export enum commandType {
wait,
callSteam, // 调用Steam功能
return, // 从被调用的场景返回
character, // 管理静态组合角色
}

/**
Expand Down
58 changes: 53 additions & 5 deletions packages/webgal/src/Core/Modules/animationFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { logger } from '@/Core/util/logger';
import { generateUniversalSoftOffAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftOff';
import cloneDeep from 'lodash/cloneDeep';
import { baseTransform } from '@/Core/Modules/stage/stageInterface';
import { baseTransform, ITransform } from '@/Core/Modules/stage/stageInterface';
import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline';
import { WebGAL } from '@/Core/WebGAL';
import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController';
Expand Down Expand Up @@ -44,6 +44,7 @@ export function getAnimationTimeline(
target: string,
writeDefault: boolean,
writeFullEffect = true,
sourceTransformOverride?: ITransform,
): AnimationFrame[] | null {
const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName);
if (effect) {
Expand All @@ -57,10 +58,11 @@ export function getAnimationTimeline(
if (effect.position) Object.keys(effect.position).forEach((k) => unionPositionKeys.add(k));
});
}
const useRelativeFrames = !writeDefault && effect.frameMode === 'relative';
const sourceTransform = writeDefault
? baseTransform
: cloneDeep(sourceTransformOverride ?? getAnimationSourceTransform(target, useRelativeFrames));
const mappedEffects = effect.effects.map((effect) => {
const targetSetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target);
const sourceTransform =
!writeDefault && targetSetEffect && targetSetEffect.transform ? targetSetEffect.transform : baseTransform;
let newEffect;

if (writeFullEffect) {
Expand All @@ -74,7 +76,8 @@ export function getAnimationTimeline(
newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' });
}

PixiStage.assignTransform(newEffect, effect, false);
const composedFrame = useRelativeFrames ? composeAnimationFrame(sourceTransform, effect) : effect;
PixiStage.assignTransform(newEffect, composedFrame, false);
newEffect.duration = effect.duration;
newEffect.ease = effect.ease;
return newEffect;
Expand All @@ -85,6 +88,51 @@ export function getAnimationTimeline(
return null;
}

function composeAnimationFrame(base: ITransform, frame: AnimationFrame): AnimationFrame {
const next = cloneDeep(frame);
if (next.position) {
if (next.position.x !== undefined) next.position.x += base.position?.x ?? 0;
if (next.position.y !== undefined) next.position.y += base.position?.y ?? 0;
}
if (next.scale) {
if (next.scale.x !== undefined) next.scale.x *= base.scale?.x ?? 1;
if (next.scale.y !== undefined) next.scale.y *= base.scale?.y ?? 1;
}
if (next.rotation !== undefined) next.rotation += base.rotation ?? 0;
if (next.alpha !== undefined) next.alpha *= base.alpha ?? 1;
return next;
}

function getAnimationSourceTransform(target: string, useLiveTargetFallback: boolean): ITransform {
const targetSetEffect = stageStateManager
.getCalculationStageState()
.effects.find((effect) => effect.target === target);
const liveTargetTransform = useLiveTargetFallback ? getCurrentTargetTransform(target) : null;
return cloneDeep(targetSetEffect?.transform ?? liveTargetTransform ?? baseTransform);
}

function getCurrentTargetTransform(target: string): ITransform | null {
const container = WebGAL.gameplay.pixiStage?.getStageObjByKey(target)?.pixiContainer;
if (!container) return null;

const transform = cloneDeep(baseTransform);
const containerRecord = container as unknown as Record<string, unknown>;
const transformRecord = transform as unknown as Record<string, unknown>;
for (const key of Object.keys(baseTransform)) {
const value = containerRecord[key];
if (typeof value === 'number') transformRecord[key] = value;
}
transform.alpha = container.alphaFilterVal ?? container.alpha ?? transform.alpha;
transform.position = transform.position ?? { x: 0, y: 0 };
transform.scale = transform.scale ?? { x: 1, y: 1 };
transform.position.x = container.x ?? transform.position.x ?? 0;
transform.position.y = container.y ?? transform.position.y ?? 0;
transform.scale.x = container.scale?.x ?? transform.scale.x ?? 1;
transform.scale.y = container.scale?.y ?? transform.scale.y ?? 1;
transform.rotation = container.rotation ?? transform.rotation;
return transform;
}

export function getAnimateDuration(animationName: string) {
const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName);
if (effect) {
Expand Down
2 changes: 2 additions & 0 deletions packages/webgal/src/Core/Modules/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface';
export interface IUserAnimation {
name: string;
effects: Array<AnimationFrame>;
/** User-authored frames are relative; unmarked engine timelines remain absolute. */
frameMode?: 'relative';
}

export type AnimationFrame = ITransform & { duration: number; ease: string };
Expand Down
1 change: 1 addition & 0 deletions packages/webgal/src/Core/Modules/stage/stageInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface IStageAnimationSetting {
exitDuration?: number;
enterAnimationIgnoreDefault?: boolean;
exitAnimationIgnoreDefault?: boolean;
baseTransform?: ITransform;
}

export type StageAnimationSettingUpdatableKey = Exclude<keyof IStageAnimationSetting, 'target'>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { IStageAnimationSetting } from '@/Core/Modules/stage/stageInterface';
import { getAnimateDuration, getAnimationTimeline } from '@/Core/Modules/animationFunctions';
import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline';
import { WebGAL } from '@/Core/WebGAL';

const timers = new Map<string, ReturnType<typeof setTimeout>>();

export function playDeferredCharacterPresentation(target: string, setting: IStageAnimationSetting | undefined): void {
clearDeferredCharacterPresentation(target);
if (!setting?.enterAnimationName || WebGAL.gameplay.skipAnimation) return;

const duration = getAnimateDuration(setting.enterAnimationName);
const timeline = getAnimationTimeline(
setting.enterAnimationName,
target,
false,
!(setting.enterAnimationIgnoreDefault ?? false),
setting.baseTransform,
);
if (!timeline) return;
const animation = generateTimelineObj(timeline, target, duration);

const animationKey = getAnimationKey(target);
WebGAL.gameplay.pixiStage?.registerAnimation(animation, animationKey, target);
if (duration <= 0) {
WebGAL.gameplay.pixiStage?.removeAnimation(animationKey);
return;
}
timers.set(
target,
setTimeout(() => {
timers.delete(target);
WebGAL.gameplay.pixiStage?.removeAnimation(animationKey);
}, duration),
);
}

export function clearDeferredCharacterPresentation(target: string): void {
const timer = timers.get(target);
if (timer !== undefined) {
clearTimeout(timer);
timers.delete(target);
}
WebGAL.gameplay.pixiStage?.removeAnimation(getAnimationKey(target));
}

function getAnimationKey(target: string): string {
return `${target}-deferred-enter`;
}
57 changes: 57 additions & 0 deletions packages/webgal/src/Core/character/characterFigureService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter';
import { composeCharacterImage } from './characterImageComposer';
import type { ICharacterFigureSource } from './characterFigureSource';
import {
type ICharacterComposition,
type ICharacterTemplate,
resolveCharacterTemplateSelection,
validateCharacterTemplate,
} from './characterTemplate';

class CharacterFigureService {
private readonly templateTasks = new Map<string, Promise<ICharacterTemplate>>();
private readonly compositionTasks = new Map<string, Promise<string>>();
private readonly compositionResults = new Map<string, string>();

public async prepare(source: ICharacterFigureSource): Promise<string> {
const templateUrl = assetSetter(`${source.name}/figure.json`, fileType.figure);
const template = await this.getTemplate(templateUrl);
const composition: ICharacterComposition = resolveCharacterTemplateSelection(template, source.items);

const key = JSON.stringify([templateUrl, composition.canvas, composition.layers.map((layer) => layer.name)]);
const cachedResult = this.compositionResults.get(key);
if (cachedResult) return cachedResult;
const runningTask = this.compositionTasks.get(key);
if (runningTask) return runningTask;

const task = composeCharacterImage(composition, templateUrl)
.then((sourceUrl) => {
if (!sourceUrl) throw new Error(`角色 ${source.name} 未生成有效图片`);
this.compositionResults.set(key, sourceUrl);
return sourceUrl;
})
.finally(() => this.compositionTasks.delete(key));
this.compositionTasks.set(key, task);
return task;
}

private getTemplate(templateUrl: string): Promise<ICharacterTemplate> {
const cachedTask = this.templateTasks.get(templateUrl);
if (cachedTask) return cachedTask;
const task = fetch(templateUrl)
.then(async (response) => {
if (!response.ok) throw new Error(`无法读取角色模板 ${templateUrl}:HTTP ${response.status}`);
const template = (await response.json()) as ICharacterTemplate;
validateCharacterTemplate(template);
return template;
})
.catch((error) => {
this.templateTasks.delete(templateUrl);
throw error;
});
this.templateTasks.set(templateUrl, task);
return task;
}
}

export const characterFigureService = new CharacterFigureService();
56 changes: 56 additions & 0 deletions packages/webgal/src/Core/character/characterFigureSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CharacterTemplateError } from './characterTemplate';
import type { IFigurePosition, IStageState } from '@/Core/Modules/stage/stageInterface';
import { listFigureTargets } from './characterFigureTarget';

const CHARACTER_FIGURE_SOURCE_PREFIX = 'webgal-character-source:';

/** 可直接写入 Figure Target 与存档的角色来源描述。 */
export interface ICharacterFigureSource {
name: string;
items: string[];
}

export interface ICharacterFigureTarget {
key: string;
position: IFigurePosition;
source: ICharacterFigureSource;
}

export function serializeCharacterFigureSource(source: ICharacterFigureSource): string {
return `${CHARACTER_FIGURE_SOURCE_PREFIX}${encodeURIComponent(JSON.stringify([source.name, source.items]))}`;
}

export function parseCharacterFigureSource(value: string): ICharacterFigureSource | null {
if (!value.startsWith(CHARACTER_FIGURE_SOURCE_PREFIX)) {
return null;
}

try {
const decoded = JSON.parse(decodeURIComponent(value.slice(CHARACTER_FIGURE_SOURCE_PREFIX.length))) as unknown;
if (
!Array.isArray(decoded) ||
decoded.length !== 2 ||
typeof decoded[0] !== 'string' ||
!decoded[0] ||
!Array.isArray(decoded[1]) ||
decoded[1].length === 0 ||
decoded[1].some((item) => typeof item !== 'string' || !item)
) {
throw new Error('invalid source payload');
}
return { name: decoded[0], items: [...decoded[1]] };
} catch (error) {
throw new CharacterTemplateError(`角色 Figure 来源描述无效:${String(error)}`);
}
}

export function collectCharacterFigureTargets(state: IStageState): ICharacterFigureTarget[] {
const targets: ICharacterFigureTarget[] = [];
for (const target of listFigureTargets(state)) {
const source = parseCharacterFigureSource(target.source);
if (source) {
targets.push({ key: target.key, position: target.position, source });
}
}
return targets;
}
93 changes: 93 additions & 0 deletions packages/webgal/src/Core/character/characterFigureSourceSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { characterFigureService } from './characterFigureService';
import type { ICharacterFigureTarget } from './characterFigureSource';

export interface ICharacterFigureDelivery {
key: string;
sourceUrl: string;
position: ICharacterFigureTarget['position'];
skipAnimation: boolean;
}

export interface ICharacterFigureSourceAdapter {
replaceFigure: (figure: ICharacterFigureDelivery) => void;
removeFigure: (key: string, skipAnimation: boolean) => void;
hasFigure: (key: string) => boolean;
reportError: (message: string, error: unknown) => void;
}

interface IPendingCharacterRequest {
epoch: number;
sourceKey: string;
skipAnimation: boolean;
}

export class CharacterFigureSourceSync {
// 这里只保存运行时请求世代;可恢复来源始终以 Figure Target 为唯一事实源。
private latestTargets = new Map<string, ICharacterFigureTarget>();
private appliedSourceKeys = new Map<string, string>();
private pendingRequests = new Map<string, IPendingCharacterRequest>();
private nextEpoch = 1;

public constructor(private readonly adapter: ICharacterFigureSourceAdapter) {}

public sync(targets: ICharacterFigureTarget[], skipAnimation: boolean): void {
const nextTargets = new Map(targets.map((target) => [target.key, target]));
for (const key of this.latestTargets.keys()) {
if (!nextTargets.has(key)) {
this.adapter.removeFigure(key, skipAnimation);
this.appliedSourceKeys.delete(key);
this.pendingRequests.delete(key);
}
}
this.latestTargets = nextTargets;

for (const target of targets) {
const sourceKey = getCharacterSourceKey(target);
const hasAppliedFigure = this.adapter.hasFigure(target.key);
const pendingRequest = this.pendingRequests.get(target.key);
if (this.appliedSourceKeys.get(target.key) === sourceKey && hasAppliedFigure) {
continue;
}
if (pendingRequest?.sourceKey === sourceKey) {
pendingRequest.skipAnimation ||= skipAnimation;
continue;
}
// 每次新来源请求获得单调世代;只有仍匹配最新逻辑状态的世代可以写入 Pixi 舞台。
const request = { epoch: this.nextEpoch++, sourceKey, skipAnimation };
this.pendingRequests.set(target.key, request);
void characterFigureService
.prepare(target.source)
.then((sourceUrl) => this.applyPreparedFigure(target, request, sourceUrl))
.catch((error) => {
const isLatestRequest = this.pendingRequests.get(target.key)?.epoch === request.epoch;
if (isLatestRequest) {
this.pendingRequests.delete(target.key);
this.adapter.reportError(`角色 ${target.source.name} 的组合图片准备失败`, error);
}
});
}
}

private applyPreparedFigure(target: ICharacterFigureTarget, request: IPendingCharacterRequest, sourceUrl: string) {
const latest = this.latestTargets.get(target.key);
if (
!latest ||
getCharacterSourceKey(latest) !== request.sourceKey ||
this.pendingRequests.get(target.key)?.epoch !== request.epoch
) {
return;
}
this.adapter.replaceFigure({
key: target.key,
sourceUrl,
position: target.position,
skipAnimation: request.skipAnimation,
});
this.appliedSourceKeys.set(target.key, request.sourceKey);
this.pendingRequests.delete(target.key);
}
}

function getCharacterSourceKey(target: ICharacterFigureTarget): string {
return JSON.stringify([target.source.name, target.source.items, target.position]);
}
Loading