Add event-driven gameplay feedback framework

This commit is contained in:
2026-03-24 09:03:27 +08:00
parent 48159be900
commit 2c03d1a702
20 changed files with 1718 additions and 64 deletions

View File

@@ -0,0 +1,158 @@
export type FeedbackCueKey =
| 'session_started'
| 'session_finished'
| 'control_completed:start'
| 'control_completed:control'
| 'control_completed:finish'
| 'punch_feedback:warning'
| 'guidance:searching'
| 'guidance:approaching'
| 'guidance:ready'
export type HapticPattern = 'short' | 'long'
export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning'
export type UiContentCardMotion = 'none' | 'pop' | 'finish'
export type UiPunchButtonMotion = 'none' | 'ready' | 'warning'
export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish'
export type UiStageMotion = 'none' | 'finish'
export interface HapticCueConfig {
enabled: boolean
pattern: HapticPattern
}
export interface UiCueConfig {
enabled: boolean
punchFeedbackMotion: UiPunchFeedbackMotion
contentCardMotion: UiContentCardMotion
punchButtonMotion: UiPunchButtonMotion
mapPulseMotion: UiMapPulseMotion
stageMotion: UiStageMotion
durationMs: number
}
export interface GameHapticsConfig {
enabled: boolean
cues: Record<FeedbackCueKey, HapticCueConfig>
}
export interface GameUiEffectsConfig {
enabled: boolean
cues: Record<FeedbackCueKey, UiCueConfig>
}
export interface PartialHapticCueConfig {
enabled?: boolean
pattern?: HapticPattern
}
export interface PartialUiCueConfig {
enabled?: boolean
punchFeedbackMotion?: UiPunchFeedbackMotion
contentCardMotion?: UiContentCardMotion
punchButtonMotion?: UiPunchButtonMotion
mapPulseMotion?: UiMapPulseMotion
stageMotion?: UiStageMotion
durationMs?: number
}
export interface GameHapticsConfigOverrides {
enabled?: boolean
cues?: Partial<Record<FeedbackCueKey, PartialHapticCueConfig>>
}
export interface GameUiEffectsConfigOverrides {
enabled?: boolean
cues?: Partial<Record<FeedbackCueKey, PartialUiCueConfig>>
}
export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
enabled: true,
cues: {
session_started: { enabled: false, pattern: 'short' },
session_finished: { enabled: true, pattern: 'long' },
'control_completed:start': { enabled: true, pattern: 'short' },
'control_completed:control': { enabled: true, pattern: 'short' },
'control_completed:finish': { enabled: true, pattern: 'long' },
'punch_feedback:warning': { enabled: true, pattern: 'short' },
'guidance:searching': { enabled: false, pattern: 'short' },
'guidance:approaching': { enabled: false, pattern: 'short' },
'guidance:ready': { enabled: true, pattern: 'short' },
},
}
export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
enabled: true,
cues: {
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 },
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 },
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 },
},
}
function clampDuration(value: number, fallback: number): number {
return Number.isFinite(value) && value >= 0 ? value : fallback
}
function mergeHapticCue(baseCue: HapticCueConfig, override?: PartialHapticCueConfig): HapticCueConfig {
return {
enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
pattern: override && override.pattern ? override.pattern : baseCue.pattern,
}
}
function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueConfig {
return {
enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
punchFeedbackMotion: override && override.punchFeedbackMotion ? override.punchFeedbackMotion : baseCue.punchFeedbackMotion,
contentCardMotion: override && override.contentCardMotion ? override.contentCardMotion : baseCue.contentCardMotion,
punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion,
mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion,
stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion,
durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs),
}
}
export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides | null): GameHapticsConfig {
const cues: GameHapticsConfig['cues'] = {
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
}
return {
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_HAPTICS_CONFIG.enabled,
cues,
}
}
export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverrides | null): GameUiEffectsConfig {
const cues: GameUiEffectsConfig['cues'] = {
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
}
return {
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_UI_EFFECTS_CONFIG.enabled,
cues,
}
}

View File

@@ -0,0 +1,57 @@
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
import { SoundDirector } from '../audio/soundDirector'
import { type GameEffect } from '../core/gameResult'
import {
DEFAULT_GAME_HAPTICS_CONFIG,
DEFAULT_GAME_UI_EFFECTS_CONFIG,
type GameHapticsConfig,
type GameUiEffectsConfig,
} from './feedbackConfig'
import { HapticsDirector } from './hapticsDirector'
import { UiEffectDirector, type UiEffectHost } from './uiEffectDirector'
export interface FeedbackHost extends UiEffectHost {
stopLocationTracking: () => void
}
export interface FeedbackConfigBundle {
audioConfig?: GameAudioConfig
hapticsConfig?: GameHapticsConfig
uiEffectsConfig?: GameUiEffectsConfig
}
export class FeedbackDirector {
soundDirector: SoundDirector
hapticsDirector: HapticsDirector
uiEffectDirector: UiEffectDirector
host: FeedbackHost
constructor(host: FeedbackHost, config?: FeedbackConfigBundle) {
this.host = host
this.soundDirector = new SoundDirector(config && config.audioConfig ? config.audioConfig : DEFAULT_GAME_AUDIO_CONFIG)
this.hapticsDirector = new HapticsDirector(config && config.hapticsConfig ? config.hapticsConfig : DEFAULT_GAME_HAPTICS_CONFIG)
this.uiEffectDirector = new UiEffectDirector(host, config && config.uiEffectsConfig ? config.uiEffectsConfig : DEFAULT_GAME_UI_EFFECTS_CONFIG)
}
configure(config: FeedbackConfigBundle): void {
this.soundDirector.configure(config.audioConfig || DEFAULT_GAME_AUDIO_CONFIG)
this.hapticsDirector.configure(config.hapticsConfig || DEFAULT_GAME_HAPTICS_CONFIG)
this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
}
destroy(): void {
this.soundDirector.destroy()
this.hapticsDirector.destroy()
this.uiEffectDirector.destroy()
}
handleEffects(effects: GameEffect[]): void {
this.soundDirector.handleEffects(effects)
this.hapticsDirector.handleEffects(effects)
this.uiEffectDirector.handleEffects(effects)
if (effects.some((effect) => effect.type === 'session_finished')) {
this.host.stopLocationTracking()
}
}
}

View File

@@ -0,0 +1,85 @@
import { type GameEffect } from '../core/gameResult'
import { DEFAULT_GAME_HAPTICS_CONFIG, type FeedbackCueKey, type GameHapticsConfig } from './feedbackConfig'
export class HapticsDirector {
enabled: boolean
config: GameHapticsConfig
constructor(config: GameHapticsConfig = DEFAULT_GAME_HAPTICS_CONFIG) {
this.enabled = true
this.config = config
}
configure(config: GameHapticsConfig): void {
this.config = config
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
destroy(): void {}
trigger(key: FeedbackCueKey): void {
if (!this.enabled || !this.config.enabled) {
return
}
const cue = this.config.cues[key]
if (!cue || !cue.enabled) {
return
}
try {
if (cue.pattern === 'long') {
wx.vibrateLong()
} else {
wx.vibrateShort({ type: 'medium' })
}
} catch {}
}
handleEffects(effects: GameEffect[]): void {
for (const effect of effects) {
if (effect.type === 'session_started') {
this.trigger('session_started')
continue
}
if (effect.type === 'session_finished') {
this.trigger('session_finished')
continue
}
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
this.trigger('punch_feedback:warning')
continue
}
if (effect.type === 'guidance_state_changed') {
if (effect.guidanceState === 'searching') {
this.trigger('guidance:searching')
continue
}
if (effect.guidanceState === 'approaching') {
this.trigger('guidance:approaching')
continue
}
this.trigger('guidance:ready')
continue
}
if (effect.type === 'control_completed') {
if (effect.controlKind === 'start') {
this.trigger('control_completed:start')
continue
}
if (effect.controlKind === 'finish') {
this.trigger('control_completed:finish')
continue
}
this.trigger('control_completed:control')
}
}
}
}

View File

@@ -0,0 +1,194 @@
import { type GameEffect } from '../core/gameResult'
import {
DEFAULT_GAME_UI_EFFECTS_CONFIG,
type FeedbackCueKey,
type GameUiEffectsConfig,
type UiContentCardMotion,
type UiMapPulseMotion,
type UiPunchButtonMotion,
type UiPunchFeedbackMotion,
type UiStageMotion,
} from './feedbackConfig'
export interface UiEffectHost {
showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
showContentCard: (title: string, body: string, motionClass?: string) => void
setPunchButtonFxClass: (className: string) => void
showMapPulse: (controlId: string, motionClass?: string) => void
showStageFx: (className: string) => void
}
export class UiEffectDirector {
enabled: boolean
config: GameUiEffectsConfig
host: UiEffectHost
punchButtonMotionTimer: number
punchButtonMotionToggle: boolean
constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) {
this.enabled = true
this.host = host
this.config = config
this.punchButtonMotionTimer = 0
this.punchButtonMotionToggle = false
}
configure(config: GameUiEffectsConfig): void {
this.config = config
this.clearPunchButtonMotion()
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
if (!enabled) {
this.clearPunchButtonMotion()
}
}
destroy(): void {
this.clearPunchButtonMotion()
}
clearPunchButtonMotion(): void {
if (this.punchButtonMotionTimer) {
clearTimeout(this.punchButtonMotionTimer)
this.punchButtonMotionTimer = 0
}
this.host.setPunchButtonFxClass('')
}
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 === 'finish') {
return 'map-stage__stage-fx--finish'
}
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
}
getCue(key: FeedbackCueKey) {
if (!this.enabled || !this.config.enabled) {
return null
}
const cue = this.config.cues[key]
if (!cue || !cue.enabled) {
return null
}
return cue
}
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) : '',
)
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))
}
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()
}
}
}
}