From 1a4254202e6c2f6b49ed660b85fd90b2d4172dc1 Mon Sep 17 00:00:00 2001 From: starrybamboo <735845305@qq.com> Date: Tue, 11 Aug 2026 02:28:07 +0800 Subject: [PATCH 1/3] feat: add static composite characters and relative animations --- packages/parser/src/config/scriptConfig.ts | 1 + .../parser/src/interface/sceneInterface.ts | 1 + .../src/Core/Modules/animationFunctions.ts | 58 +++- .../webgal/src/Core/Modules/animations.ts | 21 ++ .../src/Core/Modules/stage/stageInterface.ts | 1 + .../characterDeferredPresentationRuntime.ts | 49 ++++ .../Core/character/characterFigureService.ts | 57 ++++ .../Core/character/characterFigureSource.ts | 56 ++++ .../character/characterFigureSourceSync.ts | 93 ++++++ .../Core/character/characterFigureTarget.ts | 46 +++ .../Core/character/characterImageComposer.ts | 63 ++++ .../src/Core/character/characterTemplate.ts | 275 ++++++++++++++++++ .../Core/controller/scene/sceneInterface.ts | 1 + .../stage/pixi/syncPixiStageState.ts | 48 ++- .../src/Core/gameScripts/changeFigure.ts | 37 ++- .../webgal/src/Core/gameScripts/character.ts | 105 +++++++ packages/webgal/src/Core/initializeScript.ts | 6 +- .../webgal/src/Core/parser/sceneParser.ts | 2 + 18 files changed, 908 insertions(+), 12 deletions(-) create mode 100644 packages/webgal/src/Core/character/characterDeferredPresentationRuntime.ts create mode 100644 packages/webgal/src/Core/character/characterFigureService.ts create mode 100644 packages/webgal/src/Core/character/characterFigureSource.ts create mode 100644 packages/webgal/src/Core/character/characterFigureSourceSync.ts create mode 100644 packages/webgal/src/Core/character/characterFigureTarget.ts create mode 100644 packages/webgal/src/Core/character/characterImageComposer.ts create mode 100644 packages/webgal/src/Core/character/characterTemplate.ts create mode 100644 packages/webgal/src/Core/gameScripts/character.ts diff --git a/packages/parser/src/config/scriptConfig.ts b/packages/parser/src/config/scriptConfig.ts index f9c5a3620..6866574ef 100644 --- a/packages/parser/src/config/scriptConfig.ts +++ b/packages/parser/src/config/scriptConfig.ts @@ -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, diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts index 657ea53ae..9c3de7683 100644 --- a/packages/parser/src/interface/sceneInterface.ts +++ b/packages/parser/src/interface/sceneInterface.ts @@ -41,6 +41,7 @@ export enum commandType { wait, callSteam, // 调用Steam功能 return, // 从被调用的场景返回 + character, // 管理静态组合角色 } /** diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index e46dd5b9e..2764a87cc 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -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'; @@ -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) { @@ -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) { @@ -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; @@ -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; + const transformRecord = transform as unknown as Record; + 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) { diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 142c50b00..1141cb9ed 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -3,10 +3,31 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; export interface IUserAnimation { name: string; effects: Array; + /** Runtime marker for frames composed on top of the target's resolved transform. */ + frameMode?: 'relative'; } export type AnimationFrame = ITransform & { duration: number; ease: string }; +export type UserAnimationResource = + | Array + | { + effects: Array; + /** Structured resources are relative by default; opt into legacy absolute values explicitly. */ + frameMode?: 'relative' | 'absolute'; + }; + +export function createUserAnimation(name: string, resource: UserAnimationResource): IUserAnimation { + if (Array.isArray(resource)) { + return { name, effects: resource }; + } + return { + name, + effects: resource.effects, + ...(resource.frameMode !== 'absolute' ? { frameMode: 'relative' as const } : {}), + }; +} + export class AnimationManager { // public nextEnterAnimationName: Map = new Map(); // public nextExitAnimationName: Map = new Map(); diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index c5d90acbe..24b812947 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -44,6 +44,7 @@ export interface IStageAnimationSetting { exitDuration?: number; enterAnimationIgnoreDefault?: boolean; exitAnimationIgnoreDefault?: boolean; + baseTransform?: ITransform; } export type StageAnimationSettingUpdatableKey = Exclude; diff --git a/packages/webgal/src/Core/character/characterDeferredPresentationRuntime.ts b/packages/webgal/src/Core/character/characterDeferredPresentationRuntime.ts new file mode 100644 index 000000000..15b53c727 --- /dev/null +++ b/packages/webgal/src/Core/character/characterDeferredPresentationRuntime.ts @@ -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>(); + +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`; +} diff --git a/packages/webgal/src/Core/character/characterFigureService.ts b/packages/webgal/src/Core/character/characterFigureService.ts new file mode 100644 index 000000000..80c58a736 --- /dev/null +++ b/packages/webgal/src/Core/character/characterFigureService.ts @@ -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>(); + private readonly compositionTasks = new Map>(); + private readonly compositionResults = new Map(); + + public async prepare(source: ICharacterFigureSource): Promise { + 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 { + 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(); diff --git a/packages/webgal/src/Core/character/characterFigureSource.ts b/packages/webgal/src/Core/character/characterFigureSource.ts new file mode 100644 index 000000000..765f49591 --- /dev/null +++ b/packages/webgal/src/Core/character/characterFigureSource.ts @@ -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; +} diff --git a/packages/webgal/src/Core/character/characterFigureSourceSync.ts b/packages/webgal/src/Core/character/characterFigureSourceSync.ts new file mode 100644 index 000000000..428271d6b --- /dev/null +++ b/packages/webgal/src/Core/character/characterFigureSourceSync.ts @@ -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(); + private appliedSourceKeys = new Map(); + private pendingRequests = new Map(); + 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]); +} diff --git a/packages/webgal/src/Core/character/characterFigureTarget.ts b/packages/webgal/src/Core/character/characterFigureTarget.ts new file mode 100644 index 000000000..ef7118176 --- /dev/null +++ b/packages/webgal/src/Core/character/characterFigureTarget.ts @@ -0,0 +1,46 @@ +import type { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { + FIGURE_POSITIONS, + figureStateKeyByPosition, + type IFigurePosition, + type IStageState, +} from '@/Core/Modules/stage/stageInterface'; +import { getFigurePositionFromArgs, getStringArgByKey } from '@/Core/util/getSentenceArg'; + +export interface IFigureTarget { + key: string; + position: IFigurePosition; + isFree: boolean; +} + +export interface IFigureTargetState extends IFigureTarget { + source: string; +} + +export function resolveFigureTarget(sentence: ISentence): IFigureTarget { + const position = getFigurePositionFromArgs(sentence) || 'center'; + const explicitId = getStringArgByKey(sentence, 'id') ?? ''; + return { + key: explicitId || `fig-${position}`, + position, + isFree: explicitId !== '', + }; +} + +export function listFigureTargets(state: IStageState): IFigureTargetState[] { + const targets = FIGURE_POSITIONS.map((position) => ({ + key: `fig-${position}`, + position, + isFree: false, + source: state[figureStateKeyByPosition[position]] ?? '', + })); + targets.push( + ...state.freeFigure.map((figure) => ({ + key: figure.key, + position: figure.basePosition, + isFree: true, + source: figure.name, + })), + ); + return targets; +} diff --git a/packages/webgal/src/Core/character/characterImageComposer.ts b/packages/webgal/src/Core/character/characterImageComposer.ts new file mode 100644 index 000000000..173090ddf --- /dev/null +++ b/packages/webgal/src/Core/character/characterImageComposer.ts @@ -0,0 +1,63 @@ +import { CharacterTemplateError, type ICharacterComposition } from './characterTemplate'; + +interface ILoadedCharacterImage { + source: CanvasImageSource; + width: number; + height: number; +} + +export async function composeCharacterImage(composition: ICharacterComposition, templateUrl: string): Promise { + const loadedLayers = await Promise.all( + composition.layers.map(async (layer) => ({ + layer, + image: await loadCharacterImage(resolveCharacterComponentUrl(layer.src, templateUrl)), + })), + ); + const canvas = document.createElement('canvas'); + canvas.width = composition.canvas.width; + canvas.height = composition.canvas.height; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('无法创建角色组合画布'); + } + for (const { layer, image } of loadedLayers) { + const { width, height } = resolveDrawSize(layer, image); + context.drawImage(image.source, layer.x, layer.y, width, height); + } + return canvas.toDataURL('image/png'); +} + +function resolveDrawSize( + layer: ICharacterComposition['layers'][number], + image: ILoadedCharacterImage, +): { width: number; height: number } { + if (layer.width !== undefined && layer.height !== undefined) { + return { width: layer.width, height: layer.height }; + } + if (layer.scale !== undefined) { + return { width: image.width * layer.scale, height: image.height * layer.scale }; + } + return { width: image.width, height: image.height }; +} + +function resolveCharacterComponentUrl(componentPath: string, templateUrl: string): string { + const characterDirectoryUrl = new URL('./', new URL(templateUrl, window.location.href)); + const componentUrl = new URL(componentPath, characterDirectoryUrl); + if ( + componentUrl.origin !== characterDirectoryUrl.origin || + !componentUrl.pathname.startsWith(characterDirectoryUrl.pathname) + ) { + throw new CharacterTemplateError(`角色部件路径越出角色目录:${componentPath}`); + } + return componentUrl.toString(); +} + +function loadCharacterImage(sourceUrl: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => + resolve({ source: image, width: image.naturalWidth || image.width, height: image.naturalHeight || image.height }); + image.onerror = () => reject(new Error(`无法加载角色部件:${sourceUrl}`)); + image.src = sourceUrl; + }); +} diff --git a/packages/webgal/src/Core/character/characterTemplate.ts b/packages/webgal/src/Core/character/characterTemplate.ts new file mode 100644 index 000000000..029d7aa1a --- /dev/null +++ b/packages/webgal/src/Core/character/characterTemplate.ts @@ -0,0 +1,275 @@ +export interface ICharacterSelector { + characterName: string; + items: string[]; +} + +export interface ICharacterCanvas { + width: number; + height: number; +} + +export interface ICharacterComponent { + src: string; + x: number; + y: number; + scale?: number; + width?: number; + height?: number; +} + +export interface ICharacterPreset { + /** 预设自己的合成画布;立绘组可使用默认底图的裁剪后尺寸。 */ + canvas?: ICharacterCanvas; + items: string[]; +} + +export type ICharacterPresetDefinition = string[] | ICharacterPreset; + +export interface ICharacterTemplate { + Version: 1; + canvas: ICharacterCanvas; + components: Record; + presets?: Record; +} + +export interface ICharacterCompositionLayer extends ICharacterComponent { + name: string; +} + +export interface ICharacterComposition { + canvas: ICharacterCanvas; + layers: ICharacterCompositionLayer[]; +} + +const MAX_CHARACTER_CANVAS_EDGE = 8192; +const MAX_CHARACTER_CANVAS_PIXELS = 16_777_216; + +export class CharacterTemplateError extends Error { + public constructor(message: string) { + super(message); + this.name = 'CharacterTemplateError'; + } +} + +export function parseCharacterSelector(rawSelector: string): ICharacterSelector { + const selector = rawSelector.trim(); + const separatorIndex = selector.indexOf('/'); + const characterName = (separatorIndex >= 0 ? selector.slice(0, separatorIndex) : selector).trim(); + if (!characterName || characterName === '.' || characterName === '..' || /[\\/%?#]/.test(characterName)) { + throw new CharacterTemplateError(`非法角色名:${characterName || '(空)'}`); + } + + if (separatorIndex < 0) { + return { characterName, items: [] }; + } + + const rawItems = selector.slice(separatorIndex + 1); + const items = rawItems.split(',').map((item) => item.trim()); + if (items.length === 0 || items.some((item) => item === '')) { + throw new CharacterTemplateError(`角色 ${characterName} 的组合列表不能为空`); + } + return { characterName, items }; +} + +export function resolveCharacterTemplateSelection( + template: ICharacterTemplate, + items: string[], +): ICharacterComposition { + if (items.length === 0) { + throw new CharacterTemplateError('组合列表不能为空'); + } + + const layers: ICharacterCompositionLayer[] = []; + let selectedCanvas: ICharacterCanvas | undefined; + const expandItem = (name: string): void => { + if (hasOwn(template.components, name)) { + const component = template.components[name]; + layers.push(normalizeCompositionLayer(name, component)); + return; + } + if (hasOwn(template.presets, name)) { + const preset = template.presets![name]; + const presetCanvas = getPresetCanvas(preset); + if (presetCanvas) { + if (selectedCanvas && !sameCanvas(selectedCanvas, presetCanvas)) { + throw new CharacterTemplateError('一次角色组合选择不能混用不同预设画布'); + } + selectedCanvas = presetCanvas; + } + getPresetItems(preset).forEach(expandItem); + return; + } + throw new CharacterTemplateError(`未找到角色部件或预设:${name}`); + }; + items.forEach((item) => expandItem(item)); + + return { + canvas: { ...(selectedCanvas ?? template.canvas) }, + layers, + }; +} + +export function validateCharacterTemplate(template: ICharacterTemplate): void { + if (!template || typeof template !== 'object' || Array.isArray(template)) { + throw new CharacterTemplateError('角色模板必须是对象'); + } + if (template.Version !== 1) { + throw new CharacterTemplateError(`不支持的角色模板版本:${String(template.Version)}`); + } + validateCanvas(template.canvas, '角色模板画布'); + if (!template.components || typeof template.components !== 'object' || Array.isArray(template.components)) { + throw new CharacterTemplateError('角色模板 components 必须是对象'); + } + if ( + template.presets !== undefined && + (!template.presets || typeof template.presets !== 'object' || Array.isArray(template.presets)) + ) { + throw new CharacterTemplateError('角色模板 presets 必须是对象'); + } + const duplicateName = Object.keys(template.presets ?? {}).find((name) => hasOwn(template.components, name)); + if (duplicateName) { + throw new CharacterTemplateError(`角色模板部件与预设名称重复:${duplicateName}`); + } + Object.entries(template.components).forEach(([name, component]) => validateComponent(name, component)); + Object.entries(template.presets ?? {}).forEach(([presetName, preset]) => { + if (!Array.isArray(preset) && (!preset || typeof preset !== 'object' || !Array.isArray(preset.items))) { + throw new CharacterTemplateError(`角色组合预设 ${presetName} 必须是列表或带 items 的对象`); + } + const presetItems = getPresetItems(preset); + if (presetItems.length === 0) { + throw new CharacterTemplateError(`角色组合预设 ${presetName} 的列表不能为空`); + } + const presetCanvas = getPresetCanvas(preset); + if (presetCanvas) { + validateCanvas(presetCanvas, `角色组合预设 ${presetName} 的画布`); + } + presetItems.forEach((item) => { + if (typeof item !== 'string' || !item) { + throw new CharacterTemplateError(`角色组合预设 ${presetName} 包含非法引用`); + } + if (!hasOwn(template.components, item) && !hasOwn(template.presets, item)) { + throw new CharacterTemplateError(`角色组合预设 ${presetName} 引用了不存在的部件或预设:${item}`); + } + }); + }); + validatePresetCycles(template); +} + +function validatePresetCycles(template: ICharacterTemplate): void { + const visited = new Set(); + const visit = (name: string, stack: string[]): void => { + // visited 允许不同分支复用预设,只有当前递归链上的重复才构成循环。 + if (hasOwn(template.components, name) || visited.has(name)) { + return; + } + const cycleStart = stack.indexOf(name); + if (cycleStart >= 0) { + const cycle = [...stack.slice(cycleStart), name].join(' -> '); + throw new CharacterTemplateError(`角色组合预设存在循环引用:${cycle}`); + } + getPresetItems(template.presets![name]).forEach((item) => visit(item, [...stack, name])); + visited.add(name); + }; + Object.keys(template.presets ?? {}).forEach((name) => visit(name, [])); +} + +function validateComponent(name: string, component: ICharacterComponent) { + if (!component || typeof component !== 'object' || typeof component.src !== 'string' || !component.src.trim()) { + throw new CharacterTemplateError(`角色部件 ${name} 缺少有效的 src`); + } + validateCharacterComponentPath(component.src); + if (![component.x, component.y].every(Number.isFinite)) { + throw new CharacterTemplateError(`角色部件 ${name} 的 x、y 必须是有限数值`); + } + + const hasScale = component.scale !== undefined; + const hasWidth = component.width !== undefined; + const hasHeight = component.height !== undefined; + if (hasScale && (hasWidth || hasHeight)) { + throw new CharacterTemplateError(`角色部件 ${name} 的 scale 不能与 width、height 同时提供`); + } + if (hasWidth !== hasHeight) { + throw new CharacterTemplateError(`角色部件 ${name} 的 width、height 必须成对提供`); + } + if (hasScale && !isFinitePositiveNumber(component.scale)) { + throw new CharacterTemplateError(`角色部件 ${name} 的 scale 必须是有限正数`); + } + if (hasWidth && (!isFinitePositiveNumber(component.width) || !isFinitePositiveNumber(component.height))) { + throw new CharacterTemplateError(`角色部件 ${name} 的 width、height 必须是有限正数`); + } +} + +function normalizeCompositionLayer(name: string, component: ICharacterComponent): ICharacterCompositionLayer { + const layer = { name, src: component.src, x: component.x, y: component.y }; + if (component.scale !== undefined) { + return { ...layer, scale: component.scale }; + } + if (component.width !== undefined && component.height !== undefined) { + return { ...layer, width: component.width, height: component.height }; + } + return layer; +} + +function getPresetItems(preset: ICharacterPresetDefinition): string[] { + return Array.isArray(preset) ? preset : preset.items; +} + +function getPresetCanvas(preset: ICharacterPresetDefinition): ICharacterCanvas | undefined { + return Array.isArray(preset) ? undefined : preset.canvas; +} + +function validateCanvas(canvas: ICharacterCanvas, label: string): void { + if (!isPositiveInteger(canvas?.width) || !isPositiveInteger(canvas?.height)) { + throw new CharacterTemplateError(`${label}宽高必须是正整数`); + } + if (canvas.width > MAX_CHARACTER_CANVAS_EDGE || canvas.height > MAX_CHARACTER_CANVAS_EDGE) { + throw new CharacterTemplateError(`${label}单边不能超过 ${MAX_CHARACTER_CANVAS_EDGE}`); + } + if (canvas.width * canvas.height > MAX_CHARACTER_CANVAS_PIXELS) { + throw new CharacterTemplateError(`${label}总像素不能超过 ${MAX_CHARACTER_CANVAS_PIXELS}`); + } +} + +function sameCanvas(left: ICharacterCanvas, right: ICharacterCanvas): boolean { + return left.width === right.width && left.height === right.height; +} + +export function validateCharacterComponentPath(componentPath: string): void { + if (isNonRelativeComponentPath(componentPath)) { + throw new CharacterTemplateError(`角色部件路径必须相对角色目录:${componentPath}`); + } + let decodedPath: string; + try { + decodedPath = decodeURIComponent(componentPath); + } catch { + throw new CharacterTemplateError(`角色部件路径包含非法编码:${componentPath}`); + } + // 解码后再检查,避免百分号编码隐藏绝对路径或目录回退段。 + if (isNonRelativeComponentPath(decodedPath)) { + throw new CharacterTemplateError(`角色部件路径必须相对角色目录:${componentPath}`); + } + const pathSegments = decodedPath.replace(/\\/g, '/').split('/'); + if (pathSegments.includes('..')) { + throw new CharacterTemplateError(`角色部件路径越出角色目录:${componentPath}`); + } + const resourcePath = decodedPath.split(/[?#]/, 1)[0]; + if (!/\.(?:png|webp|jpe?g)$/i.test(resourcePath)) { + throw new CharacterTemplateError(`角色部件只支持静态 PNG、WebP 或 JPEG:${componentPath}`); + } +} + +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function isFinitePositiveNumber(value: unknown): value is number { + return Number.isFinite(value) && (value as number) > 0; +} + +function isNonRelativeComponentPath(componentPath: string): boolean { + return /^(?:[a-z][a-z\d+.-]*:|[\\/])/i.test(componentPath); +} + +function hasOwn(record: object | undefined, key: string): boolean { + return record !== undefined && Object.prototype.hasOwnProperty.call(record, key); +} diff --git a/packages/webgal/src/Core/controller/scene/sceneInterface.ts b/packages/webgal/src/Core/controller/scene/sceneInterface.ts index ed9489aea..c87cd24d8 100644 --- a/packages/webgal/src/Core/controller/scene/sceneInterface.ts +++ b/packages/webgal/src/Core/controller/scene/sceneInterface.ts @@ -42,6 +42,7 @@ export enum commandType { wait, callSteam, // 调用Steam功能 return, // 从被调用的场景返回 + character, // 管理静态组合角色 } /** diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index de5d59d3c..a66186caf 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -5,7 +5,7 @@ import { figureStateKeyByPosition, normalizeFigureBounds, } from '@/Core/Modules/stage/stageInterface'; -import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; +import { stageStateManager, type IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; import { DEFAULT_BG_IN_DURATION, DEFAULT_BG_OUT_DURATION, DEFAULT_FIG_IN_DURATION } from '@/Core/constants'; import { WebGAL } from '@/Core/WebGAL'; import type { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; @@ -13,6 +13,12 @@ import { getAnimateDuration, getExitAnimation } from '@/Core/Modules/animationFu import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; +import { CharacterFigureSourceSync } from '@/Core/character/characterFigureSourceSync'; +import { + clearDeferredCharacterPresentation, + playDeferredCharacterPresentation, +} from '@/Core/character/characterDeferredPresentationRuntime'; +import { collectCharacterFigureTargets, parseCharacterFigureSource } from '@/Core/character/characterFigureSource'; interface ISyncFigureSlotPayload { key: string; @@ -23,6 +29,37 @@ interface ISyncFigureSlotPayload { skipAnimation: boolean; } +const characterFigureSourceSync = new CharacterFigureSourceSync({ + replaceFigure: ({ key, sourceUrl, position, skipAnimation }) => { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + pixiStage.removeAnimation(`${key}-softin`); + const currentFigure = pixiStage.getStageObjByKey(key); + if (currentFigure) { + removeFig(currentFigure, `${key}-softin`, skipAnimation); + } + pixiStage.addFigure(key, sourceUrl, position); + const state = stageStateManager.getViewStageState(); + const setting = state.animationSettings.find((item) => item.target === key); + const effect = state.effects.find((item) => item.target === key); + applyStageEffectToTarget(key, effect?.transform); + if (skipAnimation) { + clearDeferredCharacterPresentation(key); + } else { + playDeferredCharacterPresentation(key, setting); + } + }, + removeFigure: (key, skipAnimation) => { + clearDeferredCharacterPresentation(key); + const currentFigure = WebGAL.gameplay.pixiStage?.getStageObjByKey(key); + if (currentFigure) { + removeFig(currentFigure, `${key}-softin`, skipAnimation); + } + }, + hasFigure: (key) => !!WebGAL.gameplay.pixiStage?.getStageObjByKey(key), + reportError: (message, error) => logger.error(message, error), +}); + /** * 立绘对象的身份:图片地址、基准位置、Live2D 绘制范围。 * @@ -49,6 +86,11 @@ function getEnterDuration(stageState: IStageState, target: string, isBg: boolean export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { + const characterTargets = collectCharacterFigureTargets(stageState); + if (options.skipAnimation) { + characterTargets.forEach((target) => clearDeferredCharacterPresentation(target.key)); + } + characterFigureSourceSync.sync(characterTargets, options.skipAnimation); syncBg(stageState, options.skipAnimation); syncFigures(stageState, options.skipAnimation); syncLive2d(stageState); @@ -155,6 +197,10 @@ function syncFigureSlot(payload: ISyncFigureSlotPayload) { const softInAniKey = `${key}-softin`; const currentFigure = pixiStage.getStageObjByKey(key); + if (sourceUrl && parseCharacterFigureSource(sourceUrl)) { + return; + } + // 旧存档中可能没有新增位置的字段,这里同时容错 undefined if (sourceUrl) { const identity = getFigureIdentity(payload); diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index 78486e49e..6ffd1d71b 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -8,7 +8,13 @@ import { getNumberArgByKey, getStringArgByKey, } from '@/Core/util/getSentenceArg'; -import { figureStateKeyByPosition, IFreeFigure, normalizeFigureBounds } from '@/Core/Modules/stage/stageInterface'; +import { + baseTransform, + figureStateKeyByPosition, + IFreeFigure, + ITransform, + normalizeFigureBounds, +} from '@/Core/Modules/stage/stageInterface'; import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; @@ -148,6 +154,15 @@ export function changeFigure(sentence: ISentence): IPerform { // 处理 transform 和 默认 transform let animationObj: AnimationFrame[]; const frame = transformString ? parseTransformFrame(transformString) : null; + const currentTransform = stageStateManager + .getCalculationStageState() + .effects.find((effect) => effect.target === key)?.transform; + const transformBase = isIdentityChanged ? baseTransform : currentTransform ?? baseTransform; + const presentationBaseTransform = frame ? buildTransformFromFrame(transformBase, frame) : cloneDeep(transformBase); + if (frame || isIdentityChanged) { + stageStateManager.updateEffect({ target: key, transform: presentationBaseTransform }); + } + stageStateManager.updateAnimationSettings({ target: key, key: 'baseTransform', value: presentationBaseTransform }); if (frame) { applyTransform(frame); } else { @@ -241,9 +256,14 @@ export function changeFigure(sentence: ISentence): IPerform { * 下面的代码是设置自由立绘的 */ const freeFigureItem: IFreeFigure = { key, name: content, basePosition: pos }; + if (content !== '') { + stageStateManager.setFreeFigureByKey(freeFigureItem); + } setAnimationNames(key, sentence); postFigureStateSet(); - stageStateManager.setFreeFigureByKey(freeFigureItem); + if (content === '') { + stageStateManager.setFreeFigureByKey(freeFigureItem); + } } else { /** * 下面的代码是设置与位置关联的立绘的 @@ -305,3 +325,16 @@ function getOverrideBoundsArr(raw: string): undefined | [number, number, number, if (isPass) return parseOverrideBoundsResult as [number, number, number, number]; else return undefined; } + +function buildTransformFromFrame(base: ITransform, frame: Partial): ITransform { + const transform = cloneDeep(base); + if (frame.position) { + transform.position = { ...transform.position, ...frame.position }; + } + if (frame.scale) { + transform.scale = { ...transform.scale, ...frame.scale }; + } + const { position, scale, duration, ease, ...rest } = frame; + Object.assign(transform, rest); + return transform; +} diff --git a/packages/webgal/src/Core/gameScripts/character.ts b/packages/webgal/src/Core/gameScripts/character.ts new file mode 100644 index 000000000..18b51cc7d --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/character.ts @@ -0,0 +1,105 @@ +import { commandType, type ISentence } from '@/Core/controller/scene/sceneInterface'; +import { createNonePerform, type IPerform } from '@/Core/Modules/perform/performInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { CharacterTemplateError, parseCharacterSelector } from '@/Core/character/characterTemplate'; +import { parseCharacterFigureSource, serializeCharacterFigureSource } from '@/Core/character/characterFigureSource'; +import { + listFigureTargets, + resolveFigureTarget, + type IFigureTargetState, +} from '@/Core/character/characterFigureTarget'; +import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; +import { logger } from '@/Core/util/logger'; +import { WEBGAL_NONE } from '@/Core/constants'; +import { changeFigure } from './changeFigure'; + +const CHARACTER_UNSUPPORTED_FIGURE_ARGS = new Set([ + 'motion', + 'skin', + 'expression', + 'bounds', + 'blink', + 'focus', + 'mouthOpen', + 'mouthClose', + 'mouthHalfOpen', + 'eyesOpen', + 'eyesClose', + 'animationFlag', +]); + +export function character(sentence: ISentence): IPerform { + try { + const content = sentence.content.trim(); + // scriptExecutor normalizes the public `none` sentinel to an empty string before dispatch. + if (content === '' || content === WEBGAL_NONE) { + clearCharacterFigureTargets(); + return createNonePerform(); + } + + const selector = parseCharacterSelector(content); + if (getBooleanArgByKey(sentence, 'clear')) { + clearCharacterFigureTargets(selector.characterName); + return createNonePerform(); + } + if (selector.items.length === 0) { + throw new CharacterTemplateError(`角色 ${selector.characterName} 的组合列表不能为空`); + } + const unsupportedArg = sentence.args.find((arg) => CHARACTER_UNSUPPORTED_FIGURE_ARGS.has(arg.key)); + if (unsupportedArg) { + throw new CharacterTemplateError(`静态组合角色不支持参数 -${unsupportedArg.key}`); + } + + const target = resolveFigureTarget(sentence); + clearCharacterFigureTargets(selector.characterName, target.key); + const source = serializeCharacterFigureSource({ name: selector.characterName, items: selector.items }); + // Character 只提供一个可持久化的静态图片来源;Figure 状态、transform、animation 与退场 + // 全部委托给上游 changeFigure。真正图片异步就绪后由 Character source adapter 交付给 Pixi。 + changeFigure(toStaticFigureSentence(sentence, source)); + return createNonePerform(); + } catch (error) { + const message = error instanceof CharacterTemplateError ? error.message : String(error); + logger.error(`character 命令无效:${message}`); + return createNonePerform(); + } +} + +function clearCharacterFigureTargets(characterName?: string, exceptTargetKey?: string): void { + const targets = listFigureTargets(stageStateManager.getCalculationStageState()); + for (const target of targets) { + if (!target.source || target.key === exceptTargetKey) { + continue; + } + const source = parseCharacterFigureSource(target.source); + if (source && (characterName === undefined || source.name === characterName)) { + changeFigure(toClearFigureSentence(target)); + } + } +} + +function toStaticFigureSentence(sentence: ISentence, source: string): ISentence { + return { + ...sentence, + command: commandType.changeFigure, + commandRaw: 'changeFigure', + content: source, + args: sentence.args, + }; +} + +function toClearFigureSentence(target: IFigureTargetState): ISentence { + const args: ISentence['args'] = [{ key: target.position, value: true }]; + if (target.isFree) { + args.push({ key: 'id', value: target.key }); + } + return { + command: commandType.changeFigure, + commandRaw: 'changeFigure', + content: WEBGAL_NONE, + args, + sentenceAssets: [], + subScene: [], + inlineComment: '', + isLineBreakHolder: false, + }; +} diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index 80af20d46..43c6ceb23 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -16,6 +16,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { autoFastSaveGame } from './controller/storage/fastSaveLoad'; +import { createUserAnimation, type UserAnimationResource } from '@/Core/Modules/animations'; export const isIOS = window.__WEBGAL_DEVICE_INFO__?.isIOS ?? false; // 判断是否是 iOS 终端 @@ -99,10 +100,7 @@ function getUserAnimation() { for (const animationName of animations) { axios.get(`./game/animation/${animationName}.json`).then((res) => { if (res.data) { - const userAnimation = { - name: animationName, - effects: res.data, - }; + const userAnimation = createUserAnimation(animationName, res.data as UserAnimationResource); WebGAL.animationManager.addAnimation(userAnimation); } }); diff --git a/packages/webgal/src/Core/parser/sceneParser.ts b/packages/webgal/src/Core/parser/sceneParser.ts index 2bde9e89e..98db90eea 100644 --- a/packages/webgal/src/Core/parser/sceneParser.ts +++ b/packages/webgal/src/Core/parser/sceneParser.ts @@ -7,6 +7,7 @@ import { bgm } from '@/Core/gameScripts/bgm'; import { callSceneScript } from '@/Core/gameScripts/callSceneScript'; import { changeBg } from '@/Core/gameScripts/changeBg'; import { changeFigure } from '@/Core/gameScripts/changeFigure'; +import { character } from '@/Core/gameScripts/character'; import { changeSceneScript } from '@/Core/gameScripts/changeSceneScript'; import { choose } from '@/Core/gameScripts/choose'; import { comment } from '@/Core/gameScripts/comment'; @@ -43,6 +44,7 @@ export const SCRIPT_TAG_MAP = defineScripts({ say: ScriptConfig(commandType.say, say), changeBg: ScriptConfig(commandType.changeBg, changeBg), changeFigure: ScriptConfig(commandType.changeFigure, changeFigure), + character: ScriptConfig(commandType.character, character), bgm: ScriptConfig(commandType.bgm, bgm, { next: true }), playVideo: ScriptConfig(commandType.video, playVideo), pixiPerform: ScriptConfig(commandType.pixi, pixi, { next: true }), From 4c2a8b306f6f3264482bdd6f263a882c599f17b0 Mon Sep 17 00:00:00 2001 From: starrybamboo <735845305@qq.com> Date: Tue, 11 Aug 2026 03:01:55 +0800 Subject: [PATCH 2/3] fix: make user animation resources relative --- packages/webgal/src/Core/Modules/animations.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 1141cb9ed..56e1928ac 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -3,7 +3,7 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; export interface IUserAnimation { name: string; effects: Array; - /** Runtime marker for frames composed on top of the target's resolved transform. */ + /** User-authored frames are composed on top of the target's resolved transform. */ frameMode?: 'relative'; } @@ -13,19 +13,11 @@ export type UserAnimationResource = | Array | { effects: Array; - /** Structured resources are relative by default; opt into legacy absolute values explicitly. */ - frameMode?: 'relative' | 'absolute'; }; export function createUserAnimation(name: string, resource: UserAnimationResource): IUserAnimation { - if (Array.isArray(resource)) { - return { name, effects: resource }; - } - return { - name, - effects: resource.effects, - ...(resource.frameMode !== 'absolute' ? { frameMode: 'relative' as const } : {}), - }; + const effects = Array.isArray(resource) ? resource : resource.effects; + return { name, effects, frameMode: 'relative' }; } export class AnimationManager { From 7a3bb094467e4980bf8704777d93799134804430 Mon Sep 17 00:00:00 2001 From: starrybamboo <735845305@qq.com> Date: Tue, 11 Aug 2026 03:14:02 +0800 Subject: [PATCH 3/3] refactor: keep the existing animation resource format --- packages/webgal/src/Core/Modules/animations.ts | 13 +------------ packages/webgal/src/Core/initializeScript.ts | 7 +++++-- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 56e1928ac..bab3d7861 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -3,23 +3,12 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; export interface IUserAnimation { name: string; effects: Array; - /** User-authored frames are composed on top of the target's resolved transform. */ + /** User-authored frames are relative; unmarked engine timelines remain absolute. */ frameMode?: 'relative'; } export type AnimationFrame = ITransform & { duration: number; ease: string }; -export type UserAnimationResource = - | Array - | { - effects: Array; - }; - -export function createUserAnimation(name: string, resource: UserAnimationResource): IUserAnimation { - const effects = Array.isArray(resource) ? resource : resource.effects; - return { name, effects, frameMode: 'relative' }; -} - export class AnimationManager { // public nextEnterAnimationName: Map = new Map(); // public nextExitAnimationName: Map = new Map(); diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index 43c6ceb23..cb313b378 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -16,7 +16,6 @@ import { WebGAL } from '@/Core/WebGAL'; import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { autoFastSaveGame } from './controller/storage/fastSaveLoad'; -import { createUserAnimation, type UserAnimationResource } from '@/Core/Modules/animations'; export const isIOS = window.__WEBGAL_DEVICE_INFO__?.isIOS ?? false; // 判断是否是 iOS 终端 @@ -100,7 +99,11 @@ function getUserAnimation() { for (const animationName of animations) { axios.get(`./game/animation/${animationName}.json`).then((res) => { if (res.data) { - const userAnimation = createUserAnimation(animationName, res.data as UserAnimationResource); + const userAnimation = { + name: animationName, + effects: res.data, + frameMode: 'relative' as const, + }; WebGAL.animationManager.addAnimation(userAnimation); } });