-
Notifications
You must be signed in to change notification settings - Fork 42
implemented UI for animation system #554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mariotee
wants to merge
1
commit into
main
Choose a base branch
from
feat/animation-system-ui
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,318 @@ | ||
| import { AnimationType, type AnimationEvent, type AnimationQueue } from './types'; | ||
| import type { IAnimationPreferences } from '@/app/_contexts/UserTypes'; | ||
| import type { AnimationExecutor } from './executors/AnimationExecutor'; | ||
| import { DamageExecutor } from './executors/DamageExecutor'; | ||
| import { HealExecutor } from './executors/HealExecutor'; | ||
| import { DefeatExecutor } from './executors/DefeatExecutor'; | ||
| import { LoseShieldExecutor } from './executors/LoseShieldExecutor'; | ||
|
|
||
| // Animation timing constants | ||
| const ANIMATION_TIMEOUT_MS = 2000; | ||
|
|
||
| // Define which animation types are considered "critical" (damage-related) | ||
| const DamageRelatedAnimations = new Set([ | ||
| AnimationType.Damage, | ||
| AnimationType.Heal, | ||
| ]); | ||
|
|
||
| /** | ||
| * Manages animation queue and execution on the client. | ||
| * Singleton pattern for global animation management. | ||
| */ | ||
| export class AnimationManager { | ||
| private static instance: AnimationManager | null = null; | ||
| private _isProcessing = false; | ||
| private _animationPreferences: IAnimationPreferences = { | ||
| disableAnimations: false, | ||
| onlyDamageAnimations: false, | ||
| fastAnimations: false, | ||
| }; | ||
|
|
||
| private queue: AnimationEvent[] = []; | ||
| private shouldCancelProcessing = false; | ||
| private executors: Map<AnimationType, AnimationExecutor>; | ||
|
|
||
| private constructor() { | ||
| // Initialize executor registry | ||
| this.executors = new Map(); | ||
| this.executors.set(AnimationType.Damage, new DamageExecutor()); | ||
| this.executors.set(AnimationType.Heal, new HealExecutor()); | ||
| this.executors.set(AnimationType.Defeat, new DefeatExecutor()); | ||
| this.executors.set(AnimationType.LoseShield, new LoseShieldExecutor()); | ||
| // Additional executors can be registered here | ||
| } | ||
|
|
||
| /** | ||
| * Gets the singleton instance of the AnimationManager. | ||
| */ | ||
| public static getInstance(): AnimationManager { | ||
| if (!AnimationManager.instance) { | ||
| AnimationManager.instance = new AnimationManager(); | ||
| } | ||
| return AnimationManager.instance; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if animations are currently processing. | ||
| */ | ||
| public get isProcessing(): boolean { | ||
| return this._isProcessing; | ||
| } | ||
|
|
||
| /** | ||
| * Gets current animation preferences. | ||
| */ | ||
| public get preferences(): IAnimationPreferences { | ||
| return { ...this._animationPreferences }; | ||
| } | ||
|
|
||
| /** | ||
| * Determines if a specific animation type should be played based on user preferences. | ||
| * @param animationType - The type of animation to check | ||
| * @returns true if the animation should be played | ||
| */ | ||
| private shouldPlayAnimation(animationType: AnimationType): boolean { | ||
| // Check master disable first | ||
| if (this._animationPreferences.disableAnimations) { | ||
| return false; | ||
| } | ||
|
|
||
| // Check selective mode (only damage animations) | ||
| if (this._animationPreferences.onlyDamageAnimations) { | ||
| return DamageRelatedAnimations.has(animationType); | ||
| } | ||
|
|
||
| // Play all animations | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Enqueues animation events from the server. | ||
| * Filters out animations based on user preferences. | ||
| * | ||
| * @param animationQueue - Animation queue from server | ||
| */ | ||
| public enqueue(animationQueue: AnimationQueue | null): void { | ||
| if (!animationQueue || this._animationPreferences.disableAnimations) { | ||
| return; | ||
| } | ||
|
|
||
| // Filter animations based on preferences | ||
| const allowedAnimations = animationQueue.events.filter(event => | ||
| this.shouldPlayAnimation(event.type) | ||
| ); | ||
|
|
||
| this.queue.push(...allowedAnimations); | ||
| } | ||
|
|
||
| /** | ||
| * Processes all animations in the queue. | ||
| * Respects priority ordering and executes simultaneous animations in parallel. | ||
| */ | ||
| public async processQueue(): Promise<void> { | ||
| if (this._isProcessing || this.queue.length === 0 || this._animationPreferences.disableAnimations) { | ||
| return; | ||
| } | ||
|
|
||
| this._isProcessing = true; | ||
| this.shouldCancelProcessing = false; | ||
|
|
||
| try { | ||
| // Optimize queue before processing | ||
| this.optimizeQueue(); | ||
|
|
||
| // Sort by priority (highest first) | ||
| this.queue.sort((a, b) => b.priority - a.priority); | ||
|
|
||
| // Group animations by simultaneity | ||
| const groups = this.groupSimultaneousAnimations(); | ||
|
|
||
| // Process each group - animations within a group execute in parallel, | ||
| // but groups are processed sequentially | ||
| for (const group of groups) { | ||
| // Check if we should cancel processing | ||
| if (this.shouldCancelProcessing) { | ||
| break; | ||
| } | ||
| await Promise.all(group.map(event => this.executeAnimation(event))); | ||
| } | ||
| } catch (error) { | ||
| console.error('[AnimationManager] Error during animation processing:', error); | ||
| throw error; | ||
| } finally { | ||
| this.queue = []; | ||
| this._isProcessing = false; | ||
| this.shouldCancelProcessing = false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Groups animations that should execute simultaneously (same groupId). | ||
| * Returns an array of arrays, where each inner array is a group of simultaneous animations. | ||
| * Groups are organized by priority - higher priority groups execute first. | ||
| */ | ||
| private groupSimultaneousAnimations(): AnimationEvent[][] { | ||
| // Group by priority level first, then by simultaneity | ||
| const priorityGroups = new Map<number, AnimationEvent[]>(); | ||
|
|
||
| for (const event of this.queue) { | ||
| const priority = event.priority; | ||
| if (!priorityGroups.has(priority)) { | ||
| priorityGroups.set(priority, []); | ||
| } | ||
| priorityGroups.get(priority)!.push(event); | ||
| } | ||
|
|
||
| // Sort priorities (highest first) and create groups | ||
| const sortedPriorities = Array.from(priorityGroups.keys()).sort((a, b) => b - a); | ||
| const groups: AnimationEvent[][] = []; | ||
|
|
||
| for (const priority of sortedPriorities) { | ||
| const eventsAtPriority = priorityGroups.get(priority)!; | ||
|
|
||
| // Within the same priority, group by simultaneity | ||
| const groupMap = new Map<string, AnimationEvent[]>(); | ||
| const standaloneAnimations: AnimationEvent[] = []; | ||
|
|
||
| for (const event of eventsAtPriority) { | ||
| const groupId = event.metadata?.groupId as string | undefined; | ||
| const isSimultaneous = event.metadata?.isSimultaneous as boolean | undefined; | ||
|
|
||
| if (isSimultaneous && groupId) { | ||
| if (!groupMap.has(groupId)) { | ||
| groupMap.set(groupId, []); | ||
| } | ||
| groupMap.get(groupId)!.push(event); | ||
| } else { | ||
| standaloneAnimations.push(event); | ||
| } | ||
| } | ||
|
|
||
| // Add simultaneous groups | ||
| groups.push(...Array.from(groupMap.values())); | ||
|
|
||
| // Add standalone animations | ||
| standaloneAnimations.forEach(event => groups.push([event])); | ||
| } | ||
|
|
||
| return groups; | ||
| } | ||
|
|
||
| /** | ||
| * Optimizes the queue by combining duplicate events. | ||
| */ | ||
| private optimizeQueue(): void { | ||
| const damageMap = new Map<string, AnimationEvent>(); | ||
| const otherEvents: AnimationEvent[] = []; | ||
|
|
||
| for (const event of this.queue) { | ||
| if (event.type === AnimationType.Damage) { | ||
| const existing = damageMap.get(event.targetId); | ||
| if (existing && event.value !== undefined) { | ||
| // Combine damage values | ||
| existing.value = (existing.value ?? 0) + event.value; | ||
| } else { | ||
| damageMap.set(event.targetId, { ...event }); | ||
| } | ||
| } else { | ||
| otherEvents.push(event); | ||
| } | ||
| } | ||
|
|
||
| this.queue = [...Array.from(damageMap.values()), ...otherEvents]; | ||
| } | ||
|
|
||
| /** | ||
| * Executes a single animation event with timeout protection. | ||
| */ | ||
| private async executeAnimation(event: AnimationEvent): Promise<void> { | ||
| const executor = this.executors.get(event.type); | ||
| if (!executor) { | ||
| console.warn(`No executor registered for animation type: ${event.type}`); | ||
| return; | ||
| } | ||
|
|
||
| // Find target element | ||
| const element = this.findTargetElement(event.targetId); | ||
| if (!element) { | ||
| console.warn(`[AnimationManager] Target element not found for: ${event.targetId}`); | ||
| // Silently skip - this is expected for upgrades that are detached before animation arrives | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| // Adjust animation duration based on speed preferences | ||
| const speedMultiplier = this._animationPreferences.fastAnimations ? 0.5 : 1; | ||
| const adjustedEvent = { | ||
| ...event, | ||
| durationMs: event.durationMs * speedMultiplier, | ||
| }; | ||
|
|
||
| // Execute with timeout protection | ||
| await Promise.race([ | ||
| executor.execute(element, adjustedEvent), | ||
| this.createTimeout(ANIMATION_TIMEOUT_MS) | ||
| ]); | ||
| } catch (error) { | ||
| console.error(`Animation execution failed for ${event.type}:`, error); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create a timeout promise that rejects after the specified duration. | ||
| */ | ||
| private createTimeout(ms: number): Promise<never> { | ||
| return new Promise((_, reject) => { | ||
| setTimeout(() => reject(new Error(`Animation timeout after ${ms}ms`)), ms); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Finds the DOM element for the given target ID. | ||
| * For deployed leaders, prioritizes finding them in the arena over the base zone. | ||
| */ | ||
| private findTargetElement(targetId: string): HTMLElement | null { | ||
| const selector = `[data-card-uuid="${targetId}"]`; | ||
| const allMatches = document.querySelectorAll<HTMLElement>(selector); | ||
|
|
||
| if (allMatches.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| if (allMatches.length === 1) { | ||
| return allMatches[0]; | ||
| } | ||
|
|
||
| // Multiple matches - likely a deployed leader that exists in both base zone and arena | ||
| // Exclude the deployed placeholder (the empty outline in the base zone) | ||
| for (const element of Array.from(allMatches)) { | ||
| const isDeployedPlaceholder = element.getAttribute('data-is-deployed-placeholder'); | ||
|
|
||
| // Skip the deployed placeholder, return the actual card in the arena | ||
| if (isDeployedPlaceholder !== 'true') { | ||
| return element; | ||
| } | ||
| } | ||
|
|
||
| // Fallback to first match (shouldn't reach here if logic is correct) | ||
| return allMatches[0]; | ||
| } | ||
|
|
||
| /* | ||
| * Updates animation preferences. | ||
| */ | ||
| public updatePreferences(preferences: Partial<IAnimationPreferences>): void { | ||
| this._animationPreferences = { ...this._animationPreferences, ...preferences }; | ||
| } | ||
|
|
||
| /** | ||
| * Clears all pending animations and cancels any in-progress processing. | ||
| */ | ||
| public clear(): void { | ||
| this.queue = []; | ||
| this.shouldCancelProcessing = true; | ||
| } | ||
| } | ||
|
|
||
| // Export singleton instance getter as default | ||
| export default AnimationManager.getInstance; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aren't we doing this on the BE already?