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

@@ -1,5 +1,17 @@
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
import {
mergeGameHapticsConfig,
mergeGameUiEffectsConfig,
type FeedbackCueKey,
type GameHapticsConfig,
type GameHapticsConfigOverrides,
type GameUiEffectsConfig,
type GameUiEffectsConfigOverrides,
type PartialHapticCueConfig,
type PartialUiCueConfig,
} from '../game/feedback/feedbackConfig'
export interface TileZoomBounds {
minX: number
@@ -33,6 +45,9 @@ export interface RemoteMapConfig {
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
}
interface ParsedGameConfig {
@@ -44,6 +59,9 @@ interface ParsedGameConfig {
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
declinationDeg: number
}
@@ -188,6 +206,134 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
return {}
}
const normalized: Record<string, unknown> = {}
const keys = Object.keys(rawValue as Record<string, unknown>)
for (const key of keys) {
normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
}
return normalized
}
function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (record[key] !== undefined) {
return record[key]
}
}
return undefined
}
function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
if (typeof rawValue !== 'string') {
return undefined
}
const trimmed = rawValue.trim()
if (!trimmed) {
return undefined
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
if (trimmed.startsWith('/assets/')) {
return trimmed
}
if (trimmed.startsWith('assets/')) {
return `/${trimmed}`
}
return resolveUrl(baseUrl, trimmed)
}
function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
if (typeof rawValue === 'string') {
const src = resolveAudioSrc(baseUrl, rawValue)
return src ? { src } : null
}
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return null
}
const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
const volumeRaw = getFirstDefined(normalized, ['volume'])
const loopRaw = getFirstDefined(normalized, ['loop'])
const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
const cue: PartialAudioCueConfig = {}
if (src) {
cue.src = src
}
if (volumeRaw !== undefined) {
cue.volume = parsePositiveNumber(volumeRaw, 1)
}
if (loopRaw !== undefined) {
cue.loop = parseBoolean(loopRaw, false)
}
if (loopGapRaw !== undefined) {
cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
}
return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
}
function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return mergeGameAudioConfig()
}
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
{ key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
{ key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
]
const cues: GameAudioConfigOverrides['cues'] = {}
for (const cueDef of cueMap) {
const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
const cue = buildAudioCueOverride(cueRaw, baseUrl)
if (cue) {
cues[cueDef.key] = cue
}
}
return mergeGameAudioConfig({
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
masterVolume: normalized.mastervolume !== undefined
? parsePositiveNumber(normalized.mastervolume, 1)
: normalized.volume !== undefined
? parsePositiveNumber(normalized.volume, 1)
: undefined,
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
approachDistanceMeters: normalized.approachdistancemeters !== undefined
? parsePositiveNumber(normalized.approachdistancemeters, 20)
: normalized.approachdistance !== undefined
? parsePositiveNumber(normalized.approachdistance, 20)
: undefined,
cues,
})
}
function parseLooseJsonObject(text: string): Record<string, unknown> {
const parsed: Record<string, unknown> = {}
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
@@ -214,7 +360,244 @@ function parseLooseJsonObject(text: string): Record<string, unknown> {
return parsed
}
function parseGameConfigFromJson(text: string): ParsedGameConfig {
function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
if (rawValue === 'short' || rawValue === 'long') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'short' || normalized === 'long') {
return normalized
}
}
return undefined
}
function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
return normalized
}
}
return undefined
}
function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
return normalized
}
}
return undefined
}
function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
return normalized
}
}
return undefined
}
function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
return normalized
}
}
return undefined
}
function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
if (rawValue === 'none' || rawValue === 'finish') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'finish') {
return normalized
}
}
return undefined
}
function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
if (typeof rawValue === 'boolean') {
return { enabled: rawValue }
}
const pattern = parseHapticPattern(rawValue)
if (pattern) {
return { enabled: true, pattern }
}
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return null
}
const cue: PartialHapticCueConfig = {}
if (normalized.enabled !== undefined) {
cue.enabled = parseBoolean(normalized.enabled, true)
}
const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
if (parsedPattern) {
cue.pattern = parsedPattern
}
return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
}
function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return null
}
const cue: PartialUiCueConfig = {}
if (normalized.enabled !== undefined) {
cue.enabled = parseBoolean(normalized.enabled, true)
}
const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
if (punchFeedbackMotion) {
cue.punchFeedbackMotion = punchFeedbackMotion
}
const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
if (contentCardMotion) {
cue.contentCardMotion = contentCardMotion
}
const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
if (punchButtonMotion) {
cue.punchButtonMotion = punchButtonMotion
}
const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
if (mapPulseMotion) {
cue.mapPulseMotion = mapPulseMotion
}
const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
if (stageMotion) {
cue.stageMotion = stageMotion
}
const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
if (durationRaw !== undefined) {
cue.durationMs = parsePositiveNumber(durationRaw, 0)
}
return cue.enabled !== undefined ||
cue.punchFeedbackMotion !== undefined ||
cue.contentCardMotion !== undefined ||
cue.punchButtonMotion !== undefined ||
cue.mapPulseMotion !== undefined ||
cue.stageMotion !== undefined ||
cue.durationMs !== undefined
? cue
: null
}
function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return mergeGameHapticsConfig()
}
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
{ key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
{ key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
{ key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
const cues: GameHapticsConfigOverrides['cues'] = {}
for (const cueDef of cueMap) {
const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
if (cue) {
cues[cueDef.key] = cue
}
}
return mergeGameHapticsConfig({
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
cues,
})
}
function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return mergeGameUiEffectsConfig()
}
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
{ key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
{ key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
{ key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
const cues: GameUiEffectsConfigOverrides['cues'] = {}
for (const cueDef of cueMap) {
const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
if (cue) {
cues[cueDef.key] = cue
}
}
return mergeGameUiEffectsConfig({
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
cues,
})
}
function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(text)
@@ -238,6 +621,19 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
normalizedGame[key.toLowerCase()] = rawGame[key]
}
}
const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
? rawGame.uiEffects
: rawGame && rawGame.uieffects !== undefined
? rawGame.uieffects
: rawGame && rawGame.ui !== undefined
? rawGame.ui
: (parsed as Record<string, unknown>).uiEffects !== undefined
? (parsed as Record<string, unknown>).uiEffects
: (parsed as Record<string, unknown>).uieffects !== undefined
? (parsed as Record<string, unknown>).uieffects
: (parsed as Record<string, unknown>).ui
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
@@ -272,11 +668,14 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
true,
),
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
declinationDeg: parseDeclinationValue(normalized.declination),
}
}
function parseGameConfigFromYaml(text: string): ParsedGameConfig {
function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
const config: Record<string, string> = {}
const lines = text.split(/\r?\n/)
@@ -317,6 +716,48 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
5,
),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
audioConfig: parseAudioConfig({
enabled: config.audioenabled,
masterVolume: config.audiomastervolume,
obeyMuteSwitch: config.audioobeymuteswitch,
approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
cues: {
session_started: config.audiosessionstarted,
'control_completed:start': config.audiostartcomplete,
'control_completed:control': config.audiocontrolcomplete,
'control_completed:finish': config.audiofinishcomplete,
'punch_feedback:warning': config.audiowarning,
'guidance:searching': config.audiosearching,
'guidance:approaching': config.audioapproaching,
'guidance:ready': config.audioready,
},
}, gameConfigUrl),
hapticsConfig: parseHapticsConfig({
enabled: config.hapticsenabled,
cues: {
session_started: config.hapticsstart,
session_finished: config.hapticsfinish,
'control_completed:start': config.hapticsstartcomplete,
'control_completed:control': config.hapticscontrolcomplete,
'control_completed:finish': config.hapticsfinishcomplete,
'punch_feedback:warning': config.hapticswarning,
'guidance:searching': config.hapticssearching,
'guidance:approaching': config.hapticsapproaching,
'guidance:ready': config.hapticsready,
},
}),
uiEffectsConfig: parseUiEffectsConfig({
enabled: config.uieffectsenabled,
cues: {
session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
},
}),
declinationDeg: parseDeclinationValue(config.declination),
}
}
@@ -328,7 +769,7 @@ function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig
trimmedText.startsWith('[') ||
/\.json(?:[?#].*)?$/i.test(gameConfigUrl)
return isJson ? parseGameConfigFromJson(trimmedText) : parseGameConfigFromYaml(trimmedText)
return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
}
function extractStringField(text: string, key: string): string | null {
@@ -538,6 +979,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,
uiEffectsConfig: gameConfig.uiEffectsConfig,
}
}