import { type GameEffect } from '../core/gameResult' import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_UI_EFFECTS_CONFIG, type FeedbackCueKey, type GameUiEffectsConfig, type UiContentCardMotion, type UiHudDistanceMotion, type UiHudProgressMotion, type UiMapPulseMotion, type UiPunchButtonMotion, type UiPunchFeedbackMotion, type UiCueConfig, type UiStageMotion, } from './feedbackConfig' export interface UiEffectHost { showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void showContentCard: (title: string, body: string, motionClass?: string, options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }) => void setPunchButtonFxClass: (className: string) => void setHudProgressFxClass: (className: string) => void setHudDistanceFxClass: (className: string) => void showMapPulse: (controlId: string, motionClass?: string) => void showStageFx: (className: string) => void } export class UiEffectDirector { enabled: boolean config: GameUiEffectsConfig host: UiEffectHost punchButtonMotionTimer: number hudProgressMotionTimer: number hudDistanceMotionTimer: number punchButtonMotionToggle: boolean animationLevel: AnimationLevel constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) { this.enabled = true this.host = host this.config = config this.punchButtonMotionTimer = 0 this.hudProgressMotionTimer = 0 this.hudDistanceMotionTimer = 0 this.punchButtonMotionToggle = false this.animationLevel = 'standard' } configure(config: GameUiEffectsConfig): void { this.config = config this.clearPunchButtonMotion() this.clearHudProgressMotion() this.clearHudDistanceMotion() } setEnabled(enabled: boolean): void { this.enabled = enabled if (!enabled) { this.clearPunchButtonMotion() this.clearHudProgressMotion() this.clearHudDistanceMotion() } } setAnimationLevel(level: AnimationLevel): void { this.animationLevel = level } destroy(): void { this.clearPunchButtonMotion() this.clearHudProgressMotion() this.clearHudDistanceMotion() } clearPunchButtonMotion(): void { if (this.punchButtonMotionTimer) { clearTimeout(this.punchButtonMotionTimer) this.punchButtonMotionTimer = 0 } this.host.setPunchButtonFxClass('') } clearHudProgressMotion(): void { if (this.hudProgressMotionTimer) { clearTimeout(this.hudProgressMotionTimer) this.hudProgressMotionTimer = 0 } this.host.setHudProgressFxClass('') } clearHudDistanceMotion(): void { if (this.hudDistanceMotionTimer) { clearTimeout(this.hudDistanceMotionTimer) this.hudDistanceMotionTimer = 0 } this.host.setHudDistanceFxClass('') } getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string { if (motion === 'warning') { return 'game-punch-feedback--fx-warning' } if (motion === 'success') { return 'game-punch-feedback--fx-success' } if (motion === 'pop') { return 'game-punch-feedback--fx-pop' } return '' } getContentCardMotionClass(motion: UiContentCardMotion): string { if (motion === 'finish') { return 'game-content-card--fx-finish' } if (motion === 'pop') { return 'game-content-card--fx-pop' } return '' } getMapPulseMotionClass(motion: UiMapPulseMotion): string { if (motion === 'ready') { return 'map-stage__map-pulse--ready' } if (motion === 'finish') { return 'map-stage__map-pulse--finish' } if (motion === 'control') { return 'map-stage__map-pulse--control' } return '' } getStageMotionClass(motion: UiStageMotion): string { if (motion === 'control') { return 'map-stage__stage-fx--control' } if (motion === 'finish') { return 'map-stage__stage-fx--finish' } return '' } getHudProgressMotionClass(motion: UiHudProgressMotion): string { if (motion === 'finish') { return 'race-panel__progress--fx-finish' } if (motion === 'success') { return 'race-panel__progress--fx-success' } return '' } getHudDistanceMotionClass(motion: UiHudDistanceMotion): string { if (motion === 'success') { return 'race-panel__metric-group--fx-distance-success' } return '' } triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void { if (motion === 'none') { return } this.punchButtonMotionToggle = !this.punchButtonMotionToggle const variant = this.punchButtonMotionToggle ? 'a' : 'b' const className = motion === 'warning' ? `map-punch-button--fx-warning-${variant}` : `map-punch-button--fx-ready-${variant}` this.host.setPunchButtonFxClass(className) if (this.punchButtonMotionTimer) { clearTimeout(this.punchButtonMotionTimer) } this.punchButtonMotionTimer = setTimeout(() => { this.punchButtonMotionTimer = 0 this.host.setPunchButtonFxClass('') }, durationMs) as unknown as number } triggerHudProgressMotion(motion: UiHudProgressMotion, durationMs: number): void { const className = this.getHudProgressMotionClass(motion) if (!className) { return } this.host.setHudProgressFxClass(className) if (this.hudProgressMotionTimer) { clearTimeout(this.hudProgressMotionTimer) } this.hudProgressMotionTimer = setTimeout(() => { this.hudProgressMotionTimer = 0 this.host.setHudProgressFxClass('') }, durationMs) as unknown as number } triggerHudDistanceMotion(motion: UiHudDistanceMotion, durationMs: number): void { const className = this.getHudDistanceMotionClass(motion) if (!className) { return } this.host.setHudDistanceFxClass(className) if (this.hudDistanceMotionTimer) { clearTimeout(this.hudDistanceMotionTimer) } this.hudDistanceMotionTimer = setTimeout(() => { this.hudDistanceMotionTimer = 0 this.host.setHudDistanceFxClass('') }, durationMs) as unknown as number } getCue(key: FeedbackCueKey): UiCueConfig | null { if (!this.enabled || !this.config.enabled) { return null } const cue = this.config.cues[key] if (!cue || !cue.enabled) { return null } if (this.animationLevel === 'standard') { return cue } return { ...cue, stageMotion: 'none' as const, hudDistanceMotion: 'none' as const, durationMs: cue.durationMs > 0 ? Math.max(260, Math.round(cue.durationMs * 0.6)) : 0, } } handleEffects(effects: GameEffect[]): void { for (const effect of effects) { if (effect.type === 'punch_feedback' && effect.tone === 'warning') { const cue = this.getCue('punch_feedback:warning') this.host.showPunchFeedback( effect.text, effect.tone, cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '', ) if (cue) { this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs) } continue } if (effect.type === 'control_completed') { const key: FeedbackCueKey = effect.controlKind === 'start' ? 'control_completed:start' : effect.controlKind === 'finish' ? 'control_completed:finish' : 'control_completed:control' const cue = this.getCue(key) this.host.showPunchFeedback( `完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success', cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '', ) this.host.showContentCard( effect.displayTitle, effect.displayBody, cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '', { contentKey: effect.controlId, autoPopup: effect.displayAutoPopup, once: effect.displayOnce, priority: effect.displayPriority, }, ) if (cue && cue.mapPulseMotion !== 'none') { this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion)) } if (cue && cue.stageMotion !== 'none') { this.host.showStageFx(this.getStageMotionClass(cue.stageMotion)) } if (cue) { this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs) this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs) } continue } if (effect.type === 'guidance_state_changed' && effect.guidanceState === 'ready') { const cue = this.getCue('guidance:ready') if (cue) { this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs) if (cue.mapPulseMotion !== 'none' && effect.controlId) { this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion)) } } continue } if (effect.type === 'session_finished') { this.clearPunchButtonMotion() this.clearHudProgressMotion() this.clearHudDistanceMotion() } if (effect.type === 'session_cancelled') { this.clearPunchButtonMotion() this.clearHudProgressMotion() this.clearHudDistanceMotion() } } } }