uiEffectDirector.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { type GameEffect } from '../core/gameResult'
  2. import { type AnimationLevel } from '../../utils/animationLevel'
  3. import {
  4. DEFAULT_GAME_UI_EFFECTS_CONFIG,
  5. type FeedbackCueKey,
  6. type GameUiEffectsConfig,
  7. type UiContentCardMotion,
  8. type UiHudDistanceMotion,
  9. type UiHudProgressMotion,
  10. type UiMapPulseMotion,
  11. type UiPunchButtonMotion,
  12. type UiPunchFeedbackMotion,
  13. type UiCueConfig,
  14. type UiStageMotion,
  15. } from './feedbackConfig'
  16. export interface UiEffectHost {
  17. showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
  18. showContentCard: (title: string, body: string, motionClass?: string, options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }) => void
  19. setPunchButtonFxClass: (className: string) => void
  20. setHudProgressFxClass: (className: string) => void
  21. setHudDistanceFxClass: (className: string) => void
  22. showMapPulse: (controlId: string, motionClass?: string) => void
  23. showStageFx: (className: string) => void
  24. }
  25. export class UiEffectDirector {
  26. enabled: boolean
  27. config: GameUiEffectsConfig
  28. host: UiEffectHost
  29. punchButtonMotionTimer: number
  30. hudProgressMotionTimer: number
  31. hudDistanceMotionTimer: number
  32. punchButtonMotionToggle: boolean
  33. animationLevel: AnimationLevel
  34. constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) {
  35. this.enabled = true
  36. this.host = host
  37. this.config = config
  38. this.punchButtonMotionTimer = 0
  39. this.hudProgressMotionTimer = 0
  40. this.hudDistanceMotionTimer = 0
  41. this.punchButtonMotionToggle = false
  42. this.animationLevel = 'standard'
  43. }
  44. configure(config: GameUiEffectsConfig): void {
  45. this.config = config
  46. this.clearPunchButtonMotion()
  47. this.clearHudProgressMotion()
  48. this.clearHudDistanceMotion()
  49. }
  50. setEnabled(enabled: boolean): void {
  51. this.enabled = enabled
  52. if (!enabled) {
  53. this.clearPunchButtonMotion()
  54. this.clearHudProgressMotion()
  55. this.clearHudDistanceMotion()
  56. }
  57. }
  58. setAnimationLevel(level: AnimationLevel): void {
  59. this.animationLevel = level
  60. }
  61. destroy(): void {
  62. this.clearPunchButtonMotion()
  63. this.clearHudProgressMotion()
  64. this.clearHudDistanceMotion()
  65. }
  66. clearPunchButtonMotion(): void {
  67. if (this.punchButtonMotionTimer) {
  68. clearTimeout(this.punchButtonMotionTimer)
  69. this.punchButtonMotionTimer = 0
  70. }
  71. this.host.setPunchButtonFxClass('')
  72. }
  73. clearHudProgressMotion(): void {
  74. if (this.hudProgressMotionTimer) {
  75. clearTimeout(this.hudProgressMotionTimer)
  76. this.hudProgressMotionTimer = 0
  77. }
  78. this.host.setHudProgressFxClass('')
  79. }
  80. clearHudDistanceMotion(): void {
  81. if (this.hudDistanceMotionTimer) {
  82. clearTimeout(this.hudDistanceMotionTimer)
  83. this.hudDistanceMotionTimer = 0
  84. }
  85. this.host.setHudDistanceFxClass('')
  86. }
  87. getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string {
  88. if (motion === 'warning') {
  89. return 'game-punch-feedback--fx-warning'
  90. }
  91. if (motion === 'success') {
  92. return 'game-punch-feedback--fx-success'
  93. }
  94. if (motion === 'pop') {
  95. return 'game-punch-feedback--fx-pop'
  96. }
  97. return ''
  98. }
  99. getContentCardMotionClass(motion: UiContentCardMotion): string {
  100. if (motion === 'finish') {
  101. return 'game-content-card--fx-finish'
  102. }
  103. if (motion === 'pop') {
  104. return 'game-content-card--fx-pop'
  105. }
  106. return ''
  107. }
  108. getMapPulseMotionClass(motion: UiMapPulseMotion): string {
  109. if (motion === 'ready') {
  110. return 'map-stage__map-pulse--ready'
  111. }
  112. if (motion === 'finish') {
  113. return 'map-stage__map-pulse--finish'
  114. }
  115. if (motion === 'control') {
  116. return 'map-stage__map-pulse--control'
  117. }
  118. return ''
  119. }
  120. getStageMotionClass(motion: UiStageMotion): string {
  121. if (motion === 'control') {
  122. return 'map-stage__stage-fx--control'
  123. }
  124. if (motion === 'finish') {
  125. return 'map-stage__stage-fx--finish'
  126. }
  127. return ''
  128. }
  129. getHudProgressMotionClass(motion: UiHudProgressMotion): string {
  130. if (motion === 'finish') {
  131. return 'race-panel__progress--fx-finish'
  132. }
  133. if (motion === 'success') {
  134. return 'race-panel__progress--fx-success'
  135. }
  136. return ''
  137. }
  138. getHudDistanceMotionClass(motion: UiHudDistanceMotion): string {
  139. if (motion === 'success') {
  140. return 'race-panel__metric-group--fx-distance-success'
  141. }
  142. return ''
  143. }
  144. triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void {
  145. if (motion === 'none') {
  146. return
  147. }
  148. this.punchButtonMotionToggle = !this.punchButtonMotionToggle
  149. const variant = this.punchButtonMotionToggle ? 'a' : 'b'
  150. const className = motion === 'warning'
  151. ? `map-punch-button--fx-warning-${variant}`
  152. : `map-punch-button--fx-ready-${variant}`
  153. this.host.setPunchButtonFxClass(className)
  154. if (this.punchButtonMotionTimer) {
  155. clearTimeout(this.punchButtonMotionTimer)
  156. }
  157. this.punchButtonMotionTimer = setTimeout(() => {
  158. this.punchButtonMotionTimer = 0
  159. this.host.setPunchButtonFxClass('')
  160. }, durationMs) as unknown as number
  161. }
  162. triggerHudProgressMotion(motion: UiHudProgressMotion, durationMs: number): void {
  163. const className = this.getHudProgressMotionClass(motion)
  164. if (!className) {
  165. return
  166. }
  167. this.host.setHudProgressFxClass(className)
  168. if (this.hudProgressMotionTimer) {
  169. clearTimeout(this.hudProgressMotionTimer)
  170. }
  171. this.hudProgressMotionTimer = setTimeout(() => {
  172. this.hudProgressMotionTimer = 0
  173. this.host.setHudProgressFxClass('')
  174. }, durationMs) as unknown as number
  175. }
  176. triggerHudDistanceMotion(motion: UiHudDistanceMotion, durationMs: number): void {
  177. const className = this.getHudDistanceMotionClass(motion)
  178. if (!className) {
  179. return
  180. }
  181. this.host.setHudDistanceFxClass(className)
  182. if (this.hudDistanceMotionTimer) {
  183. clearTimeout(this.hudDistanceMotionTimer)
  184. }
  185. this.hudDistanceMotionTimer = setTimeout(() => {
  186. this.hudDistanceMotionTimer = 0
  187. this.host.setHudDistanceFxClass('')
  188. }, durationMs) as unknown as number
  189. }
  190. getCue(key: FeedbackCueKey): UiCueConfig | null {
  191. if (!this.enabled || !this.config.enabled) {
  192. return null
  193. }
  194. const cue = this.config.cues[key]
  195. if (!cue || !cue.enabled) {
  196. return null
  197. }
  198. if (this.animationLevel === 'standard') {
  199. return cue
  200. }
  201. return {
  202. ...cue,
  203. stageMotion: 'none' as const,
  204. hudDistanceMotion: 'none' as const,
  205. durationMs: cue.durationMs > 0 ? Math.max(260, Math.round(cue.durationMs * 0.6)) : 0,
  206. }
  207. }
  208. handleEffects(effects: GameEffect[]): void {
  209. for (const effect of effects) {
  210. if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
  211. const cue = this.getCue('punch_feedback:warning')
  212. this.host.showPunchFeedback(
  213. effect.text,
  214. effect.tone,
  215. cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
  216. )
  217. if (cue) {
  218. this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs)
  219. }
  220. continue
  221. }
  222. if (effect.type === 'control_completed') {
  223. const key: FeedbackCueKey = effect.controlKind === 'start'
  224. ? 'control_completed:start'
  225. : effect.controlKind === 'finish'
  226. ? 'control_completed:finish'
  227. : 'control_completed:control'
  228. const cue = this.getCue(key)
  229. this.host.showPunchFeedback(
  230. `完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`,
  231. 'success',
  232. cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
  233. )
  234. if (effect.controlKind !== 'finish' && effect.displayAutoPopup) {
  235. this.host.showContentCard(
  236. effect.displayTitle,
  237. effect.displayBody,
  238. cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
  239. {
  240. contentKey: effect.controlId,
  241. autoPopup: effect.displayAutoPopup,
  242. once: effect.displayOnce,
  243. priority: effect.displayPriority,
  244. },
  245. )
  246. }
  247. if (cue && cue.mapPulseMotion !== 'none') {
  248. this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
  249. }
  250. if (cue && cue.stageMotion !== 'none') {
  251. this.host.showStageFx(this.getStageMotionClass(cue.stageMotion))
  252. }
  253. if (cue) {
  254. this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs)
  255. this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs)
  256. }
  257. continue
  258. }
  259. if (effect.type === 'guidance_state_changed' && effect.guidanceState === 'ready') {
  260. const cue = this.getCue('guidance:ready')
  261. if (cue) {
  262. this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs)
  263. if (cue.mapPulseMotion !== 'none' && effect.controlId) {
  264. this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
  265. }
  266. }
  267. continue
  268. }
  269. if (effect.type === 'session_finished') {
  270. this.clearPunchButtonMotion()
  271. this.clearHudProgressMotion()
  272. this.clearHudDistanceMotion()
  273. }
  274. if (effect.type === 'session_cancelled') {
  275. this.clearPunchButtonMotion()
  276. this.clearHudProgressMotion()
  277. this.clearHudDistanceMotion()
  278. }
  279. }
  280. }
  281. }