Add event-driven gameplay feedback framework
This commit is contained in:
156
miniprogram/game/audio/audioConfig.ts
Normal file
156
miniprogram/game/audio/audioConfig.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type AudioCueKey =
|
||||
| 'session_started'
|
||||
| 'control_completed:start'
|
||||
| 'control_completed:control'
|
||||
| 'control_completed:finish'
|
||||
| 'punch_feedback:warning'
|
||||
| 'guidance:searching'
|
||||
| 'guidance:approaching'
|
||||
| 'guidance:ready'
|
||||
|
||||
export interface AudioCueConfig {
|
||||
src: string
|
||||
volume: number
|
||||
loop: boolean
|
||||
loopGapMs: number
|
||||
}
|
||||
|
||||
export interface GameAudioConfig {
|
||||
enabled: boolean
|
||||
masterVolume: number
|
||||
obeyMuteSwitch: boolean
|
||||
approachDistanceMeters: number
|
||||
cues: Record<AudioCueKey, AudioCueConfig>
|
||||
}
|
||||
|
||||
export interface PartialAudioCueConfig {
|
||||
src?: string
|
||||
volume?: number
|
||||
loop?: boolean
|
||||
loopGapMs?: number
|
||||
}
|
||||
|
||||
export interface GameAudioConfigOverrides {
|
||||
enabled?: boolean
|
||||
masterVolume?: number
|
||||
obeyMuteSwitch?: boolean
|
||||
approachDistanceMeters?: number
|
||||
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||
}
|
||||
|
||||
export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
||||
enabled: true,
|
||||
masterVolume: 1,
|
||||
obeyMuteSwitch: true,
|
||||
approachDistanceMeters: 20,
|
||||
cues: {
|
||||
session_started: {
|
||||
src: '/assets/sounds/session-start.wav',
|
||||
volume: 0.78,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:start': {
|
||||
src: '/assets/sounds/start-complete.wav',
|
||||
volume: 0.84,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:control': {
|
||||
src: '/assets/sounds/control-complete.wav',
|
||||
volume: 0.8,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:finish': {
|
||||
src: '/assets/sounds/finish-complete.wav',
|
||||
volume: 0.92,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'punch_feedback:warning': {
|
||||
src: '/assets/sounds/warning.wav',
|
||||
volume: 0.72,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'guidance:searching': {
|
||||
src: '/assets/sounds/guidance-searching.wav',
|
||||
volume: 0.48,
|
||||
loop: true,
|
||||
loopGapMs: 1800,
|
||||
},
|
||||
'guidance:approaching': {
|
||||
src: '/assets/sounds/guidance-approaching.wav',
|
||||
volume: 0.58,
|
||||
loop: true,
|
||||
loopGapMs: 950,
|
||||
},
|
||||
'guidance:ready': {
|
||||
src: '/assets/sounds/guidance-ready.wav',
|
||||
volume: 0.68,
|
||||
loop: true,
|
||||
loopGapMs: 650,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function clampVolume(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
|
||||
}
|
||||
|
||||
function clampDistance(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
|
||||
function clampGap(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig {
|
||||
const cues: GameAudioConfig['cues'] = {
|
||||
session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started },
|
||||
'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] },
|
||||
'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] },
|
||||
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
||||
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
||||
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
||||
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
||||
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
||||
}
|
||||
|
||||
if (overrides && overrides.cues) {
|
||||
const keys = Object.keys(overrides.cues) as AudioCueKey[]
|
||||
for (const key of keys) {
|
||||
const cue = overrides.cues[key]
|
||||
if (!cue) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof cue.src === 'string' && cue.src) {
|
||||
cues[key].src = cue.src
|
||||
}
|
||||
|
||||
if (cue.volume !== undefined) {
|
||||
cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume)
|
||||
}
|
||||
|
||||
if (cue.loop !== undefined) {
|
||||
cues[key].loop = !!cue.loop
|
||||
}
|
||||
|
||||
if (cue.loopGapMs !== undefined) {
|
||||
cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
|
||||
masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
|
||||
obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
|
||||
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||
cues,
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
import { type GameEffect } from '../core/gameResult'
|
||||
|
||||
type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
|
||||
|
||||
const SOUND_SRC: Record<SoundKey, string> = {
|
||||
'session-start': '/assets/sounds/session-start.wav',
|
||||
'start-complete': '/assets/sounds/start-complete.wav',
|
||||
'control-complete': '/assets/sounds/control-complete.wav',
|
||||
'finish-complete': '/assets/sounds/finish-complete.wav',
|
||||
warning: '/assets/sounds/warning.wav',
|
||||
}
|
||||
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from './audioConfig'
|
||||
|
||||
export class SoundDirector {
|
||||
enabled: boolean
|
||||
contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
|
||||
config: GameAudioConfig
|
||||
contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
|
||||
loopTimers: Partial<Record<AudioCueKey, number>>
|
||||
activeGuidanceCue: AudioCueKey | null
|
||||
|
||||
constructor() {
|
||||
constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
|
||||
this.enabled = true
|
||||
this.config = config
|
||||
this.contexts = {}
|
||||
this.loopTimers = {}
|
||||
this.activeGuidanceCue = null
|
||||
}
|
||||
|
||||
configure(config: GameAudioConfig): void {
|
||||
this.config = config
|
||||
this.resetContexts()
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
const keys = Object.keys(this.contexts) as SoundKey[]
|
||||
resetContexts(): void {
|
||||
const timerKeys = Object.keys(this.loopTimers) as AudioCueKey[]
|
||||
for (const key of timerKeys) {
|
||||
const timer = this.loopTimers[key]
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
this.loopTimers = {}
|
||||
|
||||
const keys = Object.keys(this.contexts) as AudioCueKey[]
|
||||
for (const key of keys) {
|
||||
const context = this.contexts[key]
|
||||
if (!context) {
|
||||
@@ -34,10 +45,15 @@ export class SoundDirector {
|
||||
context.destroy()
|
||||
}
|
||||
this.contexts = {}
|
||||
this.activeGuidanceCue = null
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.resetContexts()
|
||||
}
|
||||
|
||||
handleEffects(effects: GameEffect[]): void {
|
||||
if (!this.enabled || !effects.length) {
|
||||
if (!this.enabled || !this.config.enabled || !effects.length) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,55 +61,138 @@ export class SoundDirector {
|
||||
|
||||
for (const effect of effects) {
|
||||
if (effect.type === 'session_started') {
|
||||
this.play('session-start')
|
||||
this.play('session_started')
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
|
||||
this.play('warning')
|
||||
this.play('punch_feedback:warning')
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.type === 'guidance_state_changed') {
|
||||
if (effect.guidanceState === 'searching') {
|
||||
this.startGuidanceLoop('guidance:searching')
|
||||
continue
|
||||
}
|
||||
if (effect.guidanceState === 'approaching') {
|
||||
this.startGuidanceLoop('guidance:approaching')
|
||||
continue
|
||||
}
|
||||
this.startGuidanceLoop('guidance:ready')
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.type === 'control_completed') {
|
||||
this.stopGuidanceLoop()
|
||||
if (effect.controlKind === 'start') {
|
||||
this.play('start-complete')
|
||||
this.play('control_completed:start')
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.controlKind === 'finish') {
|
||||
this.play('finish-complete')
|
||||
this.play('control_completed:finish')
|
||||
continue
|
||||
}
|
||||
|
||||
this.play('control-complete')
|
||||
this.play('control_completed:control')
|
||||
continue
|
||||
}
|
||||
|
||||
if (effect.type === 'session_finished' && !hasFinishCompletion) {
|
||||
this.play('finish-complete')
|
||||
if (effect.type === 'session_finished') {
|
||||
this.stopGuidanceLoop()
|
||||
if (!hasFinishCompletion) {
|
||||
this.play('control_completed:finish')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
play(key: SoundKey): void {
|
||||
play(key: AudioCueKey): void {
|
||||
const cue = this.config.cues[key]
|
||||
if (!cue || !cue.src) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearLoopTimer(key)
|
||||
const context = this.getContext(key)
|
||||
context.stop()
|
||||
context.seek(0)
|
||||
if (typeof context.seek === 'function') {
|
||||
context.seek(0)
|
||||
}
|
||||
context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
|
||||
context.play()
|
||||
}
|
||||
|
||||
getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext {
|
||||
|
||||
startGuidanceLoop(key: AudioCueKey): void {
|
||||
if (this.activeGuidanceCue === key) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stopGuidanceLoop()
|
||||
this.activeGuidanceCue = key
|
||||
this.play(key)
|
||||
}
|
||||
|
||||
stopGuidanceLoop(): void {
|
||||
if (!this.activeGuidanceCue) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearLoopTimer(this.activeGuidanceCue)
|
||||
const context = this.contexts[this.activeGuidanceCue]
|
||||
if (context) {
|
||||
context.stop()
|
||||
if (typeof context.seek === 'function') {
|
||||
context.seek(0)
|
||||
}
|
||||
}
|
||||
this.activeGuidanceCue = null
|
||||
}
|
||||
|
||||
|
||||
clearLoopTimer(key: AudioCueKey): void {
|
||||
const timer = this.loopTimers[key]
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
delete this.loopTimers[key]
|
||||
}
|
||||
}
|
||||
|
||||
handleCueEnded(key: AudioCueKey): void {
|
||||
const cue = this.config.cues[key]
|
||||
if (!cue.loop || this.activeGuidanceCue !== key || !this.enabled || !this.config.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearLoopTimer(key)
|
||||
this.loopTimers[key] = setTimeout(() => {
|
||||
delete this.loopTimers[key]
|
||||
if (this.activeGuidanceCue === key && this.enabled && this.config.enabled) {
|
||||
this.play(key)
|
||||
}
|
||||
}, cue.loopGapMs) as unknown as number
|
||||
}
|
||||
|
||||
getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
|
||||
const existing = this.contexts[key]
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const cue = this.config.cues[key]
|
||||
const context = wx.createInnerAudioContext()
|
||||
context.src = SOUND_SRC[key]
|
||||
context.src = cue.src
|
||||
context.autoplay = false
|
||||
context.loop = false
|
||||
context.obeyMuteSwitch = true
|
||||
context.volume = 1
|
||||
context.obeyMuteSwitch = this.config.obeyMuteSwitch
|
||||
if (typeof context.onEnded === 'function') {
|
||||
context.onEnded(() => {
|
||||
this.handleCueEnded(key)
|
||||
})
|
||||
}
|
||||
context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
|
||||
this.contexts[key] = context
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||
|
||||
export type GameMode = 'classic-sequential'
|
||||
export type GameControlKind = 'start' | 'control' | 'finish'
|
||||
@@ -28,4 +29,5 @@ export interface GameDefinition {
|
||||
punchPolicy: PunchPolicyType
|
||||
controls: GameControl[]
|
||||
autoFinishOnLastControl: boolean
|
||||
audioConfig?: GameAudioConfig
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { type GameSessionState } from './gameSessionState'
|
||||
import { type GameSessionState, type GuidanceState } from './gameSessionState'
|
||||
import { type GamePresentationState } from '../presentation/presentationState'
|
||||
|
||||
export type GameEffect =
|
||||
| { type: 'session_started' }
|
||||
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
|
||||
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
|
||||
| { type: 'session_finished' }
|
||||
|
||||
export interface GameResult {
|
||||
|
||||
@@ -57,6 +57,7 @@ export class GameRuntime {
|
||||
currentTargetControlId: null,
|
||||
inRangeControlId: null,
|
||||
score: 0,
|
||||
guidanceState: 'searching',
|
||||
}
|
||||
const result: GameResult = {
|
||||
nextState: emptyState,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
||||
|
||||
export interface GameSessionState {
|
||||
status: GameSessionStatus
|
||||
@@ -8,4 +9,5 @@ export interface GameSessionState {
|
||||
currentTargetControlId: string | null
|
||||
inRangeControlId: string | null
|
||||
score: number
|
||||
guidanceState: GuidanceState
|
||||
}
|
||||
|
||||
158
miniprogram/game/feedback/feedbackConfig.ts
Normal file
158
miniprogram/game/feedback/feedbackConfig.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
57
miniprogram/game/feedback/feedbackDirector.ts
Normal file
57
miniprogram/game/feedback/feedbackDirector.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
85
miniprogram/game/feedback/hapticsDirector.ts
Normal file
85
miniprogram/game/feedback/hapticsDirector.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
miniprogram/game/feedback/uiEffectDirector.ts
Normal file
194
miniprogram/game/feedback/uiEffectDirector.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
|
||||
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
|
||||
import { type GameEvent } from '../core/gameEvent'
|
||||
import { type GameEffect, type GameResult } from '../core/gameResult'
|
||||
@@ -56,6 +57,31 @@ function getTargetText(control: GameControl): string {
|
||||
return '目标圈'
|
||||
}
|
||||
|
||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
||||
if (distanceMeters <= approachDistanceMeters) {
|
||||
return 'approaching'
|
||||
}
|
||||
|
||||
return 'searching'
|
||||
}
|
||||
|
||||
function getGuidanceEffects(
|
||||
previousState: GameSessionState['guidanceState'],
|
||||
nextState: GameSessionState['guidanceState'],
|
||||
controlId: string | null,
|
||||
): GameEffect[] {
|
||||
if (previousState === nextState) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
|
||||
}
|
||||
|
||||
|
||||
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
|
||||
if (state.status === 'idle') {
|
||||
@@ -207,6 +233,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
||||
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
||||
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
|
||||
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
|
||||
guidanceState: nextTarget ? 'searching' : 'searching',
|
||||
}
|
||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
||||
|
||||
@@ -235,6 +262,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
currentTargetControlId: getInitialTargetId(definition),
|
||||
inRangeControlId: null,
|
||||
score: 0,
|
||||
guidanceState: 'searching',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
startedAt: event.at,
|
||||
endedAt: null,
|
||||
inRangeControlId: null,
|
||||
guidanceState: 'searching',
|
||||
}
|
||||
return {
|
||||
nextState,
|
||||
@@ -263,6 +292,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
...state,
|
||||
status: 'finished',
|
||||
endedAt: event.at,
|
||||
guidanceState: 'searching',
|
||||
}
|
||||
return {
|
||||
nextState,
|
||||
@@ -291,19 +321,26 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
if (event.type === 'gps_updated') {
|
||||
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
|
||||
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
|
||||
const guidanceState = getGuidanceState(definition, distanceMeters)
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
inRangeControlId,
|
||||
guidanceState,
|
||||
}
|
||||
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id)
|
||||
|
||||
if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
|
||||
return applyCompletion(definition, nextState, currentTarget, event.at)
|
||||
const completionResult = applyCompletion(definition, nextState, currentTarget, event.at)
|
||||
return {
|
||||
...completionResult,
|
||||
effects: [...guidanceEffects, ...completionResult.effects],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nextState,
|
||||
presentation: buildPresentation(definition, nextState),
|
||||
effects: [],
|
||||
effects: guidanceEffects,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user