diff --git a/docs/audio-sources.md b/docs/audio-sources.md index c384957..fd0258c 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -45,6 +45,8 @@ The source files in this section were downloaded from their official Pixabay det All seven files were resampled to 48 kHz stereo MP3. Tactical cues were silence-trimmed, equalized by role, and balanced to approximately -17.5 to -19 LUFS. The rain and fire recordings were reduced to stable excerpts, balanced near -25 LUFS, and rebuilt with a one-second end-to-start crossfade so repeated playback does not introduce a hard seam. +Battle playback uses track-specific loop trims for high-crest fire and mountain-wind recordings. Fallback volume is calculated from the measured integrated loudness of each active music-and-ambience pair, so quiet and loud BGM masters retain the same perceptual offset. Automated mix checks measure the actual encoded files across all 66 campaign battles and keep ambience 14-18 LU below its BGM; fire and snow-wind peaks must remain at least 1.5 dB below the paired music peak. + ## Narrative Interface Cues The source file in this section was downloaded from its official Pixabay detail page on 2026-07-22 and uses the Pixabay Content License linked above. @@ -82,7 +84,7 @@ The following short movement clips were derived in-project on 2026-07-23 from th | `src/assets/audio/sfx/hoof-wood-{1,2}.mp3` | Mounted movement through forest or across a wooden bridge | `src/assets/audio/sfx/horse-gallop.mp3` | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/ | | `src/assets/audio/sfx/hoof-wet-{1,2}.mp3` | Mounted movement through river, water, marsh, or swamp | `src/assets/audio/sfx/horse-gallop.mp3` | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/ | -The derivatives are 0.34-0.48 second one-shots. They were excerpted from separate points in the parent recordings, resampled to 48 kHz stereo MP3, given 8 ms attack and 55 ms release fades, terrain-specific EQ, and conservative loudness/peak balancing. Runtime playback uses two-clip no-repeat pools per terrain and separate foot/hoof pools. Existing combat pools also use per-track gain compensation derived from integrated loudness measurements so randomized variants do not jump sharply in perceived level. +The derivatives are 0.34-0.48 second one-shots. They were excerpted from separate points in the parent recordings, resampled to 48 kHz stereo MP3, given 8 ms attack and 55 ms release fades, terrain-specific EQ, and conservative loudness/peak balancing. Runtime playback samples surfaces across the full movement route, treats rain and passable river-bank/ford tiles as wet, prevents the same active clip from doubling, and caps foot and hoof groups at two voices each. Existing combat pools also use per-track gain compensation derived from integrated loudness measurements so randomized variants do not jump sharply in perceived level. ## Sound Effects diff --git a/package.json b/package.json index 4c74c9a..f8d883d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "measure:performance": "node scripts/measure-performance.mjs", "verify:performance": "node scripts/measure-performance.mjs", "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", + "verify:narrative-continuity": "node scripts/verify-narrative-continuity.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:campaign-completion": "node scripts/verify-campaign-completion.mjs", diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 1922e15..c223a86 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1669,6 +1669,12 @@ async function verifyBattleEnvironment(page, battleId) { const expected = battleEnvironmentExpectations.get(battleId); const actualAmbienceKey = state?.audio?.currentAmbienceKey ?? state?.audio?.pendingAmbienceKey ?? null; const actualMusicKey = state?.audio?.currentMusicKey ?? state?.audio?.pendingMusicKey ?? null; + const arcAmbienceMatches = + environment?.ambienceKey === actualAmbienceKey && + (actualAmbienceKey === null || + (Number.isFinite(environment?.ambienceVolume) && + environment.ambienceVolume > 0 && + environment.ambienceVolume <= 0.2)); if (!expected) { if ( @@ -1676,8 +1682,7 @@ async function verifyBattleEnvironment(page, battleId) { environment?.profileId !== `arc-${environment.presentationArcId}` || environment?.effect !== 'arc-wash' || environment?.musicKey !== actualMusicKey || - actualAmbienceKey !== null || - environment?.ambienceKey !== null || + !arcAmbienceMatches || environment?.active !== true || environment?.objectCount !== 1 || environment?.maskedObjectCount !== 1 || @@ -1718,6 +1723,14 @@ async function verifyBattleEnvironment(page, battleId) { } async function verifySortieDoctrine(page, battle) { + await page.waitForFunction( + (battleId) => { + const doctrine = window.__HEROS_DEBUG__?.battle()?.sortieDoctrine; + return doctrine?.battleId === battleId && doctrine?.openingBannerShown === true; + }, + battle.id, + { timeout: 30000 } + ); const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const doctrine = state?.sortieDoctrine; if (!doctrine?.active || doctrine.battleId !== battle.id || doctrine.openingBannerShown !== true) { @@ -2150,9 +2163,11 @@ async function playBattleWithoutDebugVictory(page, battle) { syncUnit(unit); }; const resolveOutcome = () => scene.resolveBattleOutcomeIfNeeded?.(); + const isSecureObjectiveCategory = (objective) => + objective.category === 'bonus' || objective.category === 'required'; const activeSecureObjectives = () => (state().objectives ?? []).filter((objective) => { - return objective.category === 'bonus' && objective.status !== 'done' && objective.targetTile && objective.id !== 'quick'; + return isSecureObjectiveCategory(objective) && objective.status !== 'done' && objective.targetTile && objective.id !== 'quick'; }); const objectiveDistance = (unitOrTile, objective) => Math.abs(unitOrTile.x - objective.targetTile.x) + Math.abs(unitOrTile.y - objective.targetTile.y); @@ -2185,7 +2200,7 @@ async function playBattleWithoutDebugVictory(page, battle) { }; const secureObjectivesSatisfied = () => { const objectives = (state().objectives ?? []).filter((objective) => { - return objective.category === 'bonus' && objective.id !== 'quick' && objective.targetTile; + return isSecureObjectiveCategory(objective) && objective.id !== 'quick' && objective.targetTile; }); return objectives.length > 0 && objectives.every((objective) => objective.achieved || isObjectiveHeldByAlly(objective)); }; @@ -2432,7 +2447,7 @@ async function playBattleWithoutDebugVictory(page, battle) { if (no >= 53 && no <= 66 && round >= (no <= 54 ? 10 : 14)) { const objectives = (state().objectives ?? []).filter((objective) => { - return objective.category === 'bonus' && objective.targetTile && objective.id !== 'quick'; + return isSecureObjectiveCategory(objective) && objective.targetTile && objective.id !== 'quick'; }); const usedUnits = new Set(); const objectiveUnitPreferences = { @@ -2830,6 +2845,25 @@ async function playBattleWithoutDebugVictory(page, battle) { return { x: 25, y: 13 }; } } + if (no === 22) { + const objectives = state().objectives ?? []; + const objectiveRoutes = [ + { id: 'fire-ship-landing', point: { x: 32, y: 18 } }, + { id: 'wind-signal', point: { x: 36, y: 15 } }, + { id: 'chain-fleet', point: { x: 37, y: 14 } } + ]; + for (const route of objectiveRoutes) { + const objective = objectives.find((entry) => entry.id === route.id); + if (objective && !isObjectiveHeldByAlly(objective)) { + return route.point; + } + } + const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved; + if (!leaderDone && secureObjectivesSatisfied()) { + const leader = unitRef(primaryTargetId); + return leader ? { x: leader.x, y: leader.y } : undefined; + } + } if (no === 23) { const objectives = state().objectives ?? []; const southernVillage = objectives.find((objective) => objective.id === 'southern-village'); @@ -2878,11 +2912,16 @@ async function playBattleWithoutDebugVictory(page, battle) { } } if (no === 33) { - const storehouse = (state().objectives ?? []).find((objective) => objective.id === 'chengdu-storehouse'); + const objectives = state().objectives ?? []; + const outerVillage = objectives.find((objective) => objective.id === 'outer-village'); + const storehouse = objectives.find((objective) => objective.id === 'chengdu-storehouse'); + if (outerVillage && !isObjectiveHeldByAlly(outerVillage)) { + return { x: 47, y: 29 }; + } if (storehouse && !isObjectiveHeldByAlly(storehouse)) { return { x: 59, y: 32 }; } - const leaderDone = (state().objectives ?? []).find((objective) => objective.id === 'leader')?.achieved; + const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved; if (!leaderDone && secureObjectivesSatisfied()) { return { x: 62, y: 18 }; } diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index 585837c..eabb2f8 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -36,6 +36,8 @@ const compensatedPoolKeys = ['melee-swing', 'melee-impact', 'arrow-impact']; const ffmpegCommand = process.env.FFMPEG_PATH?.trim() || 'ffmpeg'; const ffprobeCommand = process.env.FFPROBE_PATH?.trim() || 'ffprobe'; let audioInspectionToolStatus; +const integratedLoudnessCache = new Map(); +const audioVolumeCache = new Map(); const requiredBattleSceneAudioMethods = [ 'playSoundscape', 'playAllyTurnCue', @@ -109,6 +111,9 @@ try { const { musicTracks, ambienceTracks, + ambienceTrackGainCompensation = {}, + ambienceTrackIntegratedLoudnessLufs = {}, + musicTrackIntegratedLoudnessLufs = {}, effectTracks, effectTrackGainCompensation = {}, movementEffectPoolsBySurface = {} @@ -116,6 +121,9 @@ try { const effectPools = audioAssets.effectPools ?? {}; const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { campaignPresentationTransitionPolicy, resolveCampaignPresentationSoundscape } = await server.ssrLoadModule( + '/src/game/data/campaignPresentationProfiles.ts' + ); const errors = []; validateTrackMap(errors, 'music', musicTracks, 'bgm'); @@ -138,6 +146,21 @@ try { movementEffectPoolsBySurface ); validateEffectPoolMixBalance(errors, effectPools, effectTracks, effectTrackGainCompensation); + validateAmbienceLoopGain(errors, ambienceTracks, ambienceTrackGainCompensation); + validateLoopLoudnessCalibration(errors, { + musicTracks, + ambienceTracks, + musicTrackIntegratedLoudnessLufs, + ambienceTrackIntegratedLoudnessLufs + }); + validateCampaignBattleMix(errors, { + battleScenarios, + musicTracks, + ambienceTracks, + ambienceTrackGainCompensation, + campaignPresentationTransitionPolicy, + resolveCampaignPresentationSoundscape + }); validateEffectPoolRegistration(errors, effectPools); validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); @@ -303,6 +326,145 @@ function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) { }); } +function validateAmbienceLoopGain(errors, ambienceTracks, gainCompensation) { + Object.keys(ambienceTracks).forEach((trackKey) => { + const gain = gainCompensation[trackKey]; + if (!Number.isFinite(gain) || gain < 0.5 || gain > 2) { + errors.push(`ambience loop gain for "${trackKey}" must stay within 0.5..2`); + } + }); + Object.keys(gainCompensation).forEach((trackKey) => { + if (!(trackKey in ambienceTracks)) { + errors.push(`ambience loop gain references unknown track "${trackKey}"`); + } + }); + if (gainCompensation['battle-fire'] > 0.75) { + errors.push('battle-fire loop gain must retain enough headroom for its high-crest crackle peaks'); + } + if (gainCompensation['mountain-wind-ambience'] > 0.9) { + errors.push('mountain-wind loop gain must retain enough headroom for exposed snow-wind gusts'); + } +} + +function validateLoopLoudnessCalibration( + errors, + { musicTracks, ambienceTracks, musicTrackIntegratedLoudnessLufs, ambienceTrackIntegratedLoudnessLufs } +) { + if (!audioInspectionToolsAvailable(errors)) { + return; + } + [ + ['music', musicTracks, musicTrackIntegratedLoudnessLufs], + ['ambience', ambienceTracks, ambienceTrackIntegratedLoudnessLufs] + ].forEach(([category, tracks, calibration]) => { + Object.entries(tracks).forEach(([trackKey, url]) => { + const expectedLoudness = calibration[trackKey]; + if (!Number.isFinite(expectedLoudness)) { + errors.push(`${category} track "${trackKey}" needs encoded integrated-loudness calibration`); + return; + } + const path = assetUrlToPath(url); + const measuredLoudness = path + ? measureIntegratedLoudness(path, errors, `${category}/${trackKey}`) + : undefined; + if (measuredLoudness !== undefined && Math.abs(measuredLoudness - expectedLoudness) > 0.15) { + errors.push( + `${category} track "${trackKey}" loudness calibration drifted: ` + + `stored ${expectedLoudness.toFixed(2)}, measured ${measuredLoudness.toFixed(2)} LUFS` + ); + } + }); + }); +} + +function validateCampaignBattleMix( + errors, + { + battleScenarios, + musicTracks, + ambienceTracks, + ambienceTrackGainCompensation, + campaignPresentationTransitionPolicy, + resolveCampaignPresentationSoundscape + } +) { + if (!audioInspectionToolsAvailable(errors)) { + return; + } + + const uniquePairs = new Set(); + Object.keys(battleScenarios).forEach((battleId) => { + const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' }); + if (!plan?.ambienceKey || !Number.isFinite(plan.ambienceVolume) || !Number.isFinite(plan.musicVolume)) { + errors.push(`${battleId} must resolve a complete calibrated battle soundscape`); + return; + } + const musicPath = assetUrlToPath(musicTracks[plan.musicKey] ?? ''); + const ambiencePath = assetUrlToPath(ambienceTracks[plan.ambienceKey] ?? ''); + if (!musicPath || !ambiencePath) { + errors.push(`${battleId} calibrated soundscape references an unreadable loop asset`); + return; + } + const musicLoudness = measureIntegratedLoudness(musicPath, errors, `${battleId}/${plan.musicKey}`); + const ambienceLoudness = measureIntegratedLoudness(ambiencePath, errors, `${battleId}/${plan.ambienceKey}`); + if (musicLoudness === undefined || ambienceLoudness === undefined) { + return; + } + const ambienceGain = ambienceTrackGainCompensation[plan.ambienceKey] ?? 1; + uniquePairs.add(`${plan.musicKey}|${plan.ambienceKey}|${plan.musicVolume}|${plan.ambienceVolume}|${ambienceGain}`); + const effectiveMusicLoudness = musicLoudness + linearGainDb(plan.musicVolume); + const effectiveAmbienceLoudness = ambienceLoudness + linearGainDb(plan.ambienceVolume * ambienceGain); + const relativeLoudness = effectiveAmbienceLoudness - effectiveMusicLoudness; + if ( + relativeLoudness < campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMinimumLu || + relativeLoudness > campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMaximumLu + ) { + errors.push( + `${battleId} ambience must sit 14..18 LU below music; received ${relativeLoudness.toFixed(2)} LU ` + + `(${plan.ambienceKey} at ${plan.ambienceVolume} x ${ambienceGain})` + ); + } + }); + if (uniquePairs.size < 10) { + errors.push(`campaign battle mix should exercise at least 10 unique loop combinations; received ${uniquePairs.size}`); + } + + [ + 'twenty-second-battle-red-cliffs-fire', + 'forty-sixth-battle-yiling-fire', + 'sixty-sixth-battle-wuzhang-final' + ].forEach((battleId) => { + const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' }); + if (!plan?.ambienceKey || !Number.isFinite(plan.ambienceVolume) || !Number.isFinite(plan.musicVolume)) { + errors.push(`${battleId} must resolve a complete peak-calibrated battle soundscape`); + return; + } + const musicPath = assetUrlToPath(musicTracks[plan.musicKey] ?? ''); + const ambiencePath = assetUrlToPath(ambienceTracks[plan.ambienceKey] ?? ''); + if (!musicPath || !ambiencePath) { + return; + } + const musicVolume = measureAudioVolume(musicPath, errors, `${battleId}/${plan.musicKey}`); + const ambienceVolume = measureAudioVolume(ambiencePath, errors, `${battleId}/${plan.ambienceKey}`); + if (!musicVolume || !ambienceVolume) { + return; + } + const ambienceGain = ambienceTrackGainCompensation[plan.ambienceKey] ?? 1; + const effectiveMusicPeak = musicVolume.peakDb + linearGainDb(plan.musicVolume); + const effectiveAmbiencePeak = ambienceVolume.peakDb + linearGainDb(plan.ambienceVolume * ambienceGain); + if (effectiveAmbiencePeak > effectiveMusicPeak - 1.5) { + errors.push( + `${battleId} ambience peak must remain at least 1.5 dB below music; received ` + + `${effectiveAmbiencePeak.toFixed(2)} dB vs ${effectiveMusicPeak.toFixed(2)} dB` + ); + } + }); +} + +function linearGainDb(gain) { + return 20 * Math.log10(Math.max(0.0001, gain)); +} + function validateBattlefieldSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); if (!docs.includes('2026-07-21')) { @@ -688,6 +850,9 @@ function probeAudio(path, errors, context) { } function measureAudioVolume(path, errors, context) { + if (audioVolumeCache.has(path)) { + return audioVolumeCache.get(path); + } const result = spawnSync( ffmpegCommand, ['-hide_banner', '-nostats', '-i', path, '-af', 'volumedetect', '-f', 'null', '-'], @@ -700,10 +865,15 @@ function measureAudioVolume(path, errors, context) { errors.push(`${context}: ffmpeg could not measure mean/peak volume`); return undefined; } - return { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) }; + const measurement = { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) }; + audioVolumeCache.set(path, measurement); + return measurement; } function measureIntegratedLoudness(path, errors, context) { + if (integratedLoudnessCache.has(path)) { + return integratedLoudnessCache.get(path); + } const result = spawnSync( ffmpegCommand, ['-hide_banner', '-nostats', '-i', path, '-filter_complex', 'ebur128=peak=true', '-f', 'null', '-'], @@ -716,6 +886,7 @@ function measureIntegratedLoudness(path, errors, context) { errors.push(`${context}: ffmpeg could not measure integrated loudness`); return undefined; } + integratedLoudnessCache.set(path, loudness); return loudness; } diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index ca7cddd..2831996 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -4,6 +4,15 @@ import { readFileSync } from 'node:fs'; const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8'); +const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8'); + +const applyStoryPage = privateMethodBody(storySource, 'applyPage'); +assert.match( + applyStoryPage, + /chapterText\?\.setText\(page\.chapter\)\.setVisible\(!page\.cutscene\)/, + 'cutscenes must hide the generic chapter label so it cannot overlap the result or reward strip' +); +assert.match(storySource, /chapterVisible: this\.chapterText\?\.visible \?\? false/); const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses'); assert.match(tickStatuses, /let burnTicked = false/); @@ -74,6 +83,17 @@ const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattle assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/); assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue'); +const combatAssetLoading = privateMethodBody(battleSource, 'ensureScenarioCombatAssets'); +assert.match( + combatAssetLoading, + /if \(this\.phase === 'deployment'\) \{\s*this\.renderSortieOrderHud\(\);\s*this\.renderDeploymentPanel\(\);/s, + 'deployment text and its status HUD must be rebuilt once delayed combat assets settle' +); +const deploymentPanel = privateMethodBody(battleSource, 'renderDeploymentPanel'); +assert.match(deploymentPanel, /const combatAssetsReady =/); +assert.match(deploymentPanel, /'준비 중 · 누르면 자동 시작'/); +assert.match(deploymentPanel, /combatAssetsReady\s*\? '전투 시작'/s); + const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget'); assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]*\{ playCue: false \}\)/); const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent'); @@ -83,13 +103,27 @@ const movementSound = privateMethodBody(battleSource, 'playMovementSound'); assert.match(movementSound, /const minInterval = isMounted \? 125 : 180/); assert.match(movementSound, /Math\.floor\(duration \/ minInterval\) \+ 1/); assert.match(movementSound, /pulseCount > 1 \? duration \/ \(pulseCount - 1\) : 0/); -assert.match(movementSound, /playMovementStep\(isMounted, index, \{\s*terrain,/s); +assert.match( + movementSound, + /playMovementStep\(isMounted, index, \{\s*terrain: movementAudioTerrainForPulse\(terrainPath, index, pulseCount\),/s +); assert.doesNotMatch(movementSound, /stopAfterMs/, 'movement one-shots should clean up on ended instead of truncating each step'); assert.equal( - countMatches(battleSource, /playMovementSound\(unit, movementDuration, movementDistance, battleMap\.terrain\[y\]\?\.\[x\]\)/g), + countMatches(battleSource, /this\.movementAudioTerrainPath\(unit, fromX, fromY, x, y\)/g), 2, - 'both synchronous and asynchronous movement paths must pass destination terrain to movement audio' + 'both synchronous and asynchronous movement paths must pass a sampled terrain route to movement audio' ); +assert.match(battleSource, /particles\?\.kind === 'rain'/, 'rain battles should route movement through the wet pool'); +const movementPlayback = privateMethodBody(soundSource, 'playMovementEffect'); +assert.match(movementPlayback, /activeVoices\.length >= this\.movementVoiceCap[\s\S]*return false/); +assert.doesNotMatch( + movementPlayback, + /retireEffectElement/, + 'movement voice limiting should skip an excess pulse instead of hard-cutting a playing sample' +); +const meleeRushPlayback = publicMethodBody(soundSource, 'playMeleeRush'); +assert.match(meleeRushPlayback, /return this\.playEffect\(/, 'melee rush should bypass the saturated movement-step voice group'); +assert.doesNotMatch(meleeRushPlayback, /playMovementEffect/, 'melee rush must not be dropped by the movement-step ceiling'); const mapCombat = privateMethodBody(battleSource, 'playCombatMapPresentation'); assert.match(mapCombat, /contactDelayMs = Math\.max\(70, Math\.round\(duration \* 0\.42\)\)/); diff --git a/scripts/verify-battle-event-queue.mjs b/scripts/verify-battle-event-queue.mjs index c2f017f..16b259a 100644 --- a/scripts/verify-battle-event-queue.mjs +++ b/scripts/verify-battle-event-queue.mjs @@ -45,6 +45,13 @@ assert.match(saveSource, /pendingBattleEvents\?: BattleSavePendingEvent\[\]/, 'p const pendingOutcome = privateMethodBody(battleSource, 'pendingBattleOutcome'); assert.match(pendingOutcome, /requiredVictoryObjectiveStates\(\)/, 'victory must consult required narrative objectives'); assert.match(pendingOutcome, /victory-gate-pending/, 'an unmet narrative gate must explain why battle continues'); +const objectiveState = privateMethodBody(battleSource, 'objectiveState'); +assert.match( + objectiveState, + /triggeredBattleEvents\.has\(this\.objectiveEventKey\(objective\.id, 'achieved'\)\)/, + 'a secured terrain objective must remain complete after its achievement feedback is recorded' +); +assert.match(objectiveState, /const secured = previouslySecured \|\| securedNow/); const triggerTacticalEvent = privateMethodBody(battleSource, 'triggerTacticalEvent'); const tacticalReaction = privateMethodBody(battleSource, 'tacticalEventReaction'); diff --git a/scripts/verify-battle-scenario-data.mjs b/scripts/verify-battle-scenario-data.mjs index ec7a715..8aca187 100644 --- a/scripts/verify-battle-scenario-data.mjs +++ b/scripts/verify-battle-scenario-data.mjs @@ -36,7 +36,12 @@ try { ]); const errors = []; const expectedRequiredVictoryObjectives = new Map([ - ['seventeenth-battle-wolong-visit-road', new Set(['scholar-rendezvous', 'longzhong-cottage'])] + ['seventeenth-battle-wolong-visit-road', new Set(['scholar-rendezvous', 'longzhong-cottage'])], + ['twenty-second-battle-red-cliffs-fire', new Set(['fire-ship-landing', 'wind-signal', 'chain-fleet'])], + ['thirty-third-battle-chengdu-surrender', new Set(['outer-village', 'chengdu-storehouse'])], + ['forty-sixth-battle-yiling-fire', new Set(['fire-camps', 'retreat-road'])], + ['fifty-fourth-battle-meng-huo-final-capture', new Set(['council-road', 'village-witnesses'])], + ['sixty-sixth-battle-wuzhang-final', new Set(['preserve-wuzhang-camps', 'protect-returning-villages'])] ]); scenarioEntries.forEach(([scenarioId, scenario]) => { diff --git a/scripts/verify-campaign-presentation-profiles.mjs b/scripts/verify-campaign-presentation-profiles.mjs index 1150b73..2bc48a6 100644 --- a/scripts/verify-campaign-presentation-profiles.mjs +++ b/scripts/verify-campaign-presentation-profiles.mjs @@ -48,7 +48,7 @@ try { const { campaignBattleRouteEntries } = await server.ssrLoadModule( '/src/game/state/campaignRouting.ts' ); - const { musicTracks, ambienceTracks } = await server.ssrLoadModule( + const { ambienceTrackGainCompensation, musicTracks, ambienceTracks } = await server.ssrLoadModule( '/src/game/audio/audioAssets.ts' ); const { campSkinDefinitions, campSkinBattleArcs } = await server.ssrLoadModule( @@ -197,7 +197,12 @@ try { const environment = battleEnvironmentProfiles[battleId]; const expectedAmbienceKey = environment?.soundscape.ambienceKey ?? profile?.arc.audio.defaultAmbienceKey; const expectedAmbienceVolume = environment?.soundscape.ambienceVolume ?? - campaignBattleAmbienceFallbackVolume(profile?.arc.audio.defaultAmbienceVolume); + campaignBattleAmbienceFallbackVolume( + profile?.arc.audio.defaultAmbienceVolume, + profile?.arc.audio.defaultAmbienceKey, + profile?.arc.audio.battleMusicKey, + profile?.arc.audio.anchorMusicVolume + ); assertEqual(battlePlan?.musicKey, profile?.arc.audio.battleMusicKey, `${battleId}: battle music plan`); assertEqual(battlePlan?.ambienceKey, expectedAmbienceKey, `${battleId}: battle ambience plan`); assertEqual(battlePlan?.ambienceVolume, expectedAmbienceVolume, `${battleId}: battle ambience volume`); @@ -218,6 +223,47 @@ try { 'battle ambience fallback count' ); + [ + [battleIds[0], 'campfire-ambience', 0.166], + [battleIds[16], 'mountain-wind-ambience', 0.153], + [battleIds[32], 'river-night-ambience', 0.093], + [battleIds[53], 'nanzhong-night-ambience', 0.084] + ].forEach(([battleId, ambienceKey, expectedVolume]) => { + const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' }); + assertEqual(plan?.ambienceKey, ambienceKey, `${battleId}: calibrated fallback track`); + assertEqual(plan?.ambienceVolume, expectedVolume, `${battleId}: calibrated fallback target volume`); + }); + const quietStoryCampfirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[9], stage: 'battle' }); + const quietFrontierMountainPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[54], stage: 'battle' }); + assert( + quietStoryCampfirePlan?.musicKey === 'story-dark' && + quietStoryCampfirePlan.ambienceKey === 'campfire-ambience' && + quietStoryCampfirePlan.ambienceVolume <= 0.085, + `${quietStoryCampfirePlan?.battleId}: quiet story-dark pairing must attenuate campfire fallback` + ); + assert( + quietFrontierMountainPlan?.musicKey === 'camp-frontier' && + quietFrontierMountainPlan.ambienceKey === 'mountain-wind-ambience' && + quietFrontierMountainPlan.ambienceVolume <= 0.105, + `${quietFrontierMountainPlan?.battleId}: quiet frontier pairing must attenuate mountain fallback` + ); + + const redCliffsFirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[21], stage: 'battle' }); + const yilingFirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[45], stage: 'battle' }); + const wuzhangSnowPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[65], stage: 'battle' }); + [redCliffsFirePlan, yilingFirePlan].forEach((plan) => { + assert( + plan?.ambienceKey === 'battle-fire' && + plan.ambienceVolume * ambienceTrackGainCompensation['battle-fire'] <= 0.095, + `${plan?.battleId}: fire crackle effective volume must retain its peak ceiling` + ); + }); + assert( + wuzhangSnowPlan?.ambienceKey === 'mountain-wind-ambience' && + wuzhangSnowPlan.ambienceVolume * ambienceTrackGainCompensation['mountain-wind-ambience'] <= 0.08, + `${wuzhangSnowPlan?.battleId}: snow-wind effective volume must retain its gust ceiling` + ); + const preservedStoryPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[0], stage: 'story', @@ -274,9 +320,12 @@ try { 'specific-profile-then-arc-fallback', 'battle ambience behavior' ); - assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackScale, 0.58, 'battle ambience fallback scale'); - assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, 0.05, 'battle ambience fallback minimum'); - assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, 0.065, 'battle ambience fallback maximum'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackScale, 1, 'battle ambience fallback scale'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, 0.08, 'battle ambience fallback minimum'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, 0.2, 'battle ambience fallback maximum'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceTargetRelativeLoudnessLu, -16, 'battle ambience target offset'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMinimumLu, -18, 'battle ambience lower bound'); + assertEqual(campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMaximumLu, -14, 'battle ambience upper bound'); assert(Object.isFrozen(campaignPresentationTransitionPolicy), 'transition policy must be frozen'); const soundDirectorSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8'); @@ -334,7 +383,7 @@ try { console.log( `Verified ${arcEntries.length} campaign presentation arcs across ${battleIds.length} battles, ` + - `${battleMusicKeys.size} battle music groups, ${battleAmbienceFallbackCount} quiet ambience fallbacks, ` + + `${battleMusicKeys.size} battle music groups, ${battleAmbienceFallbackCount} loudness-calibrated ambience fallbacks, ` + 'all state policies, palettes, motifs, and crossfade constraints.' ); } finally { diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs index 48eb8e2..367a9e5 100644 --- a/scripts/verify-interaction-ux.mjs +++ b/scripts/verify-interaction-ux.mjs @@ -44,6 +44,7 @@ try { console.log( `Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` + 'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' + + 'delayed combat assets rebuild deployment controls before battle start, ' + 'battle event overlays block edge-scroll and hover feedback, prioritized same-action notices collapse into one disclosed modal and battle log, ' + 'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' + 'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' + @@ -289,9 +290,20 @@ async function verifyBattlePointerFlow(page) { battle?.battleId === battleId && ['deployment', 'idle'].includes(battle.phase) && battle.mapBackgroundReady === true && - battle.combatAssets?.status !== 'loading' + (battle.phase === 'idle' || ['ready', 'degraded'].includes(battle.combatAssets?.status)) ); }, expectedBattleId, { timeout: 90000 }); + const deploymentReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + if (deploymentReadyState?.phase === 'deployment') { + assert( + deploymentReadyState.battleHud?.deploymentPanel?.combatAssetsReady === true && + deploymentReadyState.battleHud.deploymentPanel.startButtonLabel === '전투 시작', + `Deployment controls must rebuild after delayed combat assets settle: ${JSON.stringify({ + combatAssets: deploymentReadyState.combatAssets, + deploymentPanel: deploymentReadyState.battleHud?.deploymentPanel + })}` + ); + } await startDeploymentIfNeeded(page, expectedBattleId); await verifyBattleEventOverlayInputBlock(page); await verifyBattleEventQueueBehavior(page); diff --git a/scripts/verify-narrative-continuity.mjs b/scripts/verify-narrative-continuity.mjs new file mode 100644 index 0000000..99d2fbf --- /dev/null +++ b/scripts/verify-narrative-continuity.mjs @@ -0,0 +1,380 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const criticalSecureObjectiveIds = { + 'twenty-second-battle-red-cliffs-fire': [ + 'fire-ship-landing', + 'wind-signal', + 'chain-fleet' + ], + 'thirty-third-battle-chengdu-surrender': [ + 'outer-village', + 'chengdu-storehouse' + ], + 'forty-sixth-battle-yiling-fire': [ + 'fire-camps', + 'retreat-road' + ], + 'fifty-fourth-battle-meng-huo-final-capture': [ + 'council-road', + 'village-witnesses' + ], + 'sixty-sixth-battle-wuzhang-final': [ + 'preserve-wuzhang-camps', + 'protect-returning-villages' + ] +}; + +const wuzhangBackgroundPattern = /^story-wuzhang(?:-|$)/; +const explicitFinalTransitionIdPattern = /(?:(?:zhuge(?:-liang)?|kongming).*(?:death|dies|passing|passes|farewell|last-breath|seogeo)|(?:death|dies|passing|passes|farewell|last-breath|seogeo).*(?:zhuge(?:-liang)?|kongming)|(?:time|years?)-(?:passes|passage|skip|later|elapsed)|(?:after|post)-wuzhang-(?:time|years?|passage))/i; +const uniqueStoryArtifactPattern = /(?:서찰|군령패|항복 권고문|접선 문서|사절 문서|통행문서|장부|지도|표식|기록|서약)$/; +const repeatableRewardLabels = new Set(); + +const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); +const failures = []; +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +let battleScenarios; +let scenarioModule; +let storyAssetModule; + +try { + ({ battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts')); + scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); + storyAssetModule = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); +} finally { + await server.close(); +} + +validateCriticalVictoryObjectives(); +validateFinalStoryTransition(); +validateWuzhangStoryBackgrounds(); +validateNorthernCampaignTimeline(); +validateImmediateUniqueRewardDuplicates(); + +if (failures.length > 0) { + throw new Error( + `Narrative continuity verification failed with ${failures.length} issue(s):\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ); +} + +const criticalObjectiveCount = Object.values(criticalSecureObjectiveIds) + .reduce((count, objectiveIds) => count + objectiveIds.length, 0); +console.log( + `Verified ${criticalObjectiveCount} narrative-critical secure objectives, ` + + 'the Wuzhang transition and background family, the 55-66 timeline chapter, ' + + 'and immediate unique camp reward continuity.' +); + +function validateCriticalVictoryObjectives() { + for (const [battleId, objectiveIds] of Object.entries(criticalSecureObjectiveIds)) { + const scenario = battleScenarios[battleId]; + assert(scenario, `Missing narrative-critical battle scenario: ${battleId}`); + if (!scenario) { + continue; + } + + for (const objectiveId of objectiveIds) { + const objective = scenario.objectives.find((entry) => entry.id === objectiveId); + assert(objective, `${battleId}: missing narrative-critical objective ${objectiveId}`); + if (!objective) { + continue; + } + assert( + objective.kind === 'secure-terrain', + `${battleId}/${objectiveId}: expected secure-terrain, received ${objective.kind}` + ); + assert( + objective.requiredForVictory === true, + `${battleId}/${objectiveId}: narrative-critical secure objective must set requiredForVictory: true` + ); + } + } +} + +function validateFinalStoryTransition() { + const finalScenario = battleScenarios['sixty-sixth-battle-wuzhang-final']; + const victoryPages = finalScenario?.victoryPages; + const endingPages = scenarioModule.endingEpiloguePages; + + assert(Array.isArray(victoryPages) && victoryPages.length > 0, 'Battle 66 must provide victory pages'); + assert(Array.isArray(endingPages) && endingPages.length > 0, 'Ending epilogue must provide pages'); + if (!Array.isArray(victoryPages) || !Array.isArray(endingPages)) { + return; + } + + const transitionPage = [...victoryPages, ...endingPages] + .find((page) => explicitFinalTransitionIdPattern.test(page.id)); + assert( + transitionPage, + 'Battle 66 aftermath and ending epilogue need an explicit death/time-passage page id ' + + '(for example zhuge-liang-passing or time-passage)' + ); +} + +function validateWuzhangStoryBackgrounds() { + const storyLists = [ + ['sixtySixthBattleIntroPages', scenarioModule.sixtySixthBattleIntroPages], + ['sixtySixthBattleVictoryPages', scenarioModule.sixtySixthBattleVictoryPages] + ]; + + for (const [sourceName, pages] of storyLists) { + assert(Array.isArray(pages) && pages.length > 0, `${sourceName}: expected a non-empty page list`); + if (!Array.isArray(pages)) { + continue; + } + for (const page of pages) { + assert( + wuzhangBackgroundPattern.test(page.background), + `${sourceName}/${page.id}: expected a Wuzhang-specific background family, received ${page.background}` + ); + } + } + + for (const [sourceName, pages, pageId] of [ + ['sixtySixthBattleVictoryPages', scenarioModule.sixtySixthBattleVictoryPages, 'sixty-sixth-zhuge-passes'], + ['endingEpiloguePages', scenarioModule.endingEpiloguePages, 'ending-jiang-wei-inherits-map'] + ]) { + const pageIndex = pages.findIndex((page) => page.id === pageId); + const selectedKeys = storyAssetModule.storyBackgroundKeysForPages(pages); + assert(pageIndex >= 0, `${sourceName}: missing ${pageId}`); + assert( + selectedKeys[pageIndex] === 'story-wuzhang-jiangwei-inheritance', + `${sourceName}/${pageId}: the purpose-built Wuzhang inheritance artwork must be selected at runtime` + ); + } +} + +function validateNorthernCampaignTimeline() { + const chapter = extractObjectContaining(campSceneSource, "id: 'northern-campaign'"); + assert(chapter, 'Camp timeline must define the northern-campaign chapter'); + if (!chapter) { + return; + } + + const title = extractStringProperty(chapter, 'title'); + const period = extractStringProperty(chapter, 'period'); + const battleIds = extractStringArrayProperty(chapter, 'battleIds'); + assert( + battleIds.includes('fifty-fifth-battle-northern-qishan-road') && + battleIds.includes('sixty-sixth-battle-wuzhang-final'), + 'The audited northern-campaign timeline chapter must span battles 55 through 66' + ); + assert(title && title !== '북벌 준비', "The 55-66 timeline title must go beyond the placeholder '북벌 준비'"); + assert(period && period !== '출사표를 향해', "The 55-66 timeline period must go beyond the placeholder '출사표를 향해'"); +} + +function validateImmediateUniqueRewardDuplicates() { + const campBattleIds = extractCampBattleIds(campSceneSource); + const visitsSource = extractAssignedArray(campSceneSource, 'campVisits'); + assert(visitsSource, 'Cannot inspect camp visits for immediate unique reward duplication'); + if (!visitsSource) { + return; + } + + const visitRewardsByBattleId = new Map(); + for (const visitSource of splitTopLevelObjects(visitsSource)) { + const visitId = extractStringProperty(visitSource, 'id') ?? 'unknown-visit'; + const battleIds = extractAvailableBattleIds(visitSource, campBattleIds); + const rewardLabels = extractRewardLabels(visitSource) + .map(normalizeRewardLabel) + .filter(Boolean); + for (const battleId of battleIds) { + const visits = visitRewardsByBattleId.get(battleId) ?? []; + visits.push({ visitId, rewardLabels }); + visitRewardsByBattleId.set(battleId, visits); + } + } + + for (const [battleId, visits] of visitRewardsByBattleId) { + const scenario = battleScenarios[battleId]; + if (!scenario) { + continue; + } + const battleRewardLabels = new Set(scenario.itemRewards.map(normalizeRewardLabel).filter(Boolean)); + for (const visit of visits) { + for (const rewardLabel of visit.rewardLabels) { + if ( + battleRewardLabels.has(rewardLabel) && + uniqueStoryArtifactPattern.test(rewardLabel) && + !repeatableRewardLabels.has(rewardLabel) + ) { + const message = + `${battleId}/${visit.visitId}: unique story reward '${rewardLabel}' is granted by both the battle and its immediate camp visit`; + failures.push(message); + } + } + } + } +} + +function extractCampBattleIds(source) { + const objectSource = extractAssignedObject(source, 'campBattleIds'); + if (!objectSource) { + return new Map(); + } + + const ids = new Map([['first', 'first-battle-zhuo-commandery']]); + for (const match of objectSource.matchAll(/\b(\w+)\s*:\s*'([^']+)'/g)) { + ids.set(match[1], match[2]); + } + return ids; +} + +function extractAvailableBattleIds(visitSource, campBattleIds) { + const availableMatch = /availableAfterBattleIds\s*:\s*\[([^\]]*)\]/.exec(visitSource); + if (!availableMatch) { + return []; + } + + const ids = []; + for (const match of availableMatch[1].matchAll(/campBattleIds\.(\w+)/g)) { + const battleId = campBattleIds.get(match[1]); + if (battleId) { + ids.push(battleId); + } + } + for (const match of availableMatch[1].matchAll(/'([^']+)'/g)) { + ids.push(match[1]); + } + return [...new Set(ids)]; +} + +function extractRewardLabels(source) { + const labels = []; + for (const rewardsMatch of source.matchAll(/itemRewards\s*:\s*\[([^\]]*)\]/g)) { + for (const labelMatch of rewardsMatch[1].matchAll(/'([^']+)'/g)) { + labels.push(labelMatch[1]); + } + } + return labels; +} + +function normalizeRewardLabel(reward) { + const normalized = reward.replace(/\s+/g, ' ').trim(); + const match = /^(.*?)(?:\s*([+xX-]?)\s*(\d+))$/.exec(normalized); + return (match?.[1] ?? normalized).trim(); +} + +function extractAssignedArray(source, name) { + const assignmentIndex = source.indexOf(`const ${name}`); + if (assignmentIndex < 0) { + return undefined; + } + const equalsIndex = source.indexOf('=', assignmentIndex); + const openIndex = source.indexOf('[', equalsIndex); + return extractBalanced(source, openIndex, '[', ']'); +} + +function extractAssignedObject(source, name) { + const assignmentIndex = source.indexOf(`const ${name}`); + if (assignmentIndex < 0) { + return undefined; + } + const equalsIndex = source.indexOf('=', assignmentIndex); + const openIndex = source.indexOf('{', equalsIndex); + return extractBalanced(source, openIndex, '{', '}'); +} + +function extractObjectContaining(source, marker) { + const markerIndex = source.indexOf(marker); + if (markerIndex < 0) { + return undefined; + } + const openIndex = source.lastIndexOf('{', markerIndex); + return extractBalanced(source, openIndex, '{', '}'); +} + +function extractBalanced(source, openIndex, openCharacter, closeCharacter) { + if (openIndex < 0 || source[openIndex] !== openCharacter) { + return undefined; + } + + let depth = 0; + let quote; + let escaped = false; + for (let index = openIndex; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === openCharacter) { + depth += 1; + } else if (character === closeCharacter) { + depth -= 1; + if (depth === 0) { + return source.slice(openIndex, index + 1); + } + } + } + return undefined; +} + +function splitTopLevelObjects(arraySource) { + const objects = []; + let objectStart = -1; + let depth = 0; + let quote; + let escaped = false; + for (let index = 1; index < arraySource.length - 1; index += 1) { + const character = arraySource[index]; + if (quote) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '{') { + if (depth === 0) { + objectStart = index; + } + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0 && objectStart >= 0) { + objects.push(arraySource.slice(objectStart, index + 1)); + objectStart = -1; + } + } + } + return objects; +} + +function extractStringProperty(source, propertyName) { + return new RegExp(`\\b${propertyName}\\s*:\\s*'([^']+)'`).exec(source)?.[1]; +} + +function extractStringArrayProperty(source, propertyName) { + const arrayMatch = new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`).exec(source); + return arrayMatch ? [...arrayMatch[1].matchAll(/'([^']+)'/g)].map((match) => match[1]) : []; +} + +function assert(condition, message) { + if (!condition) { + failures.push(message); + } +} diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 86fedf3..0214776 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -65,7 +65,10 @@ const server = await createServer({ try { const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts'); - const { effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const { ambienceTrackGainCompensation, effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const { movementAudioTerrainForPulse, movementAudioTerrainForTile } = await server.ssrLoadModule( + '/src/game/audio/movementAudio.ts' + ); const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts'); verifyAudioPreferences(audioPreferencesModule); const clock = new FakeClock(); @@ -82,7 +85,8 @@ try { director.registerAmbienceTracks({ 'ambience-a': '/audio/ambience-a.ogg', 'ambience-b': '/audio/ambience-b.ogg', - 'ambience-c': '/audio/ambience-c.ogg' + 'ambience-c': '/audio/ambience-c.ogg', + 'battle-fire': '/audio/battle-fire.ogg' }); director.registerEffectTracks({ 'sword-slash': '/audio/sword-slash.ogg', @@ -249,6 +253,25 @@ try { ['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'], 'battle terrain names should resolve to the four movement sound surfaces' ); + const terrainFixture = [ + ['plain', 'road', 'river'], + ['forest', 'plain', 'plain'] + ]; + assert.equal( + movementAudioTerrainForTile(terrainFixture, 1, 0, false), + 'wet', + 'a passable river-bank/ford tile should make the wet movement pool reachable' + ); + assert.equal( + movementAudioTerrainForTile(terrainFixture, 0, 1, true), + 'wet', + 'rain should wet otherwise dry passable terrain' + ); + assert.deepEqual( + Array.from({ length: 5 }, (_, index) => movementAudioTerrainForPulse(['plain', 'road', 'forest'], index, 5)), + ['plain', 'road', 'road', 'forest', 'forest'], + 'movement pulses should sample the full route instead of repeating only the destination surface' + ); assert.equal( director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }), @@ -265,16 +288,57 @@ try { director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 }); const secondStoneStep = audioHarness.instances.at(-1); assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition'); + assert.equal( + director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }), + false, + 'a third overlapping footstep should be skipped instead of cutting an audible sample' + ); + debug = director.getDebugState(); + assert.equal(firstStoneStep.paused, false, 'voice limiting must let the oldest footstep reach its natural release'); + assert.notEqual(firstStoneStep.src, '', 'voice limiting must not clear a playing footstep source'); + assert.equal(debug.movementVoices.cap, 2, 'movement voice policy should expose the two-voice ceiling'); + assert.equal(debug.movementVoices.foot.activeCount, 2, 'foot movement must never exceed two active voices'); + assert.equal( + new Set(debug.movementVoices.foot.trackKeys).size, + debug.movementVoices.foot.activeCount, + 'active foot movement voices must use distinct source clips' + ); + const activeEffectsBeforeRush = debug.activeEffectCount; + assert.equal( + director.playMeleeRush(false, 'bridge'), + true, + 'melee rush must remain audible while the foot movement voice group is saturated' + ); + debug = director.getDebugState(); + assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools'); + assert.equal( + debug.activeEffectCount, + activeEffectsBeforeRush + 1, + 'melee rush should use a priority effect voice outside the movement-step ceiling' + ); + assert.equal(debug.movementVoices.foot.activeCount, 2, 'melee rush must not consume a movement-step voice'); director.playMovementStep(true, 2, { terrain: 'river', volume: 0.24, stopAfterMs: 140 }); debug = director.getDebugState(); assert.equal(debug.lastMovementStep.key, 'movement-hoof-wet', 'mounted river movement should use the wet hoof pool'); assert.equal(debug.lastMovementStep.surface, 'wet', 'mounted movement should expose the wet surface'); assert(['hoof-wet-1', 'hoof-wet-2'].includes(debug.lastMovementStep.trackKey)); - - director.playMeleeRush(false, 'bridge'); + director.playMovementStep(true, 3, { terrain: 'river', volume: 0.24, stopAfterMs: 140 }); + assert.equal( + director.playMovementStep(true, 4, { terrain: 'river', volume: 0.24, stopAfterMs: 140 }), + false, + 'a third overlapping hoof beat should be skipped instead of cutting an audible sample' + ); debug = director.getDebugState(); - assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools'); + assert.equal(debug.movementVoices.mounted.activeCount, 2, 'mounted movement must never exceed two active voices'); + assert.equal( + new Set(debug.movementVoices.mounted.trackKeys).size, + debug.movementVoices.mounted.activeCount, + 'active mounted movement voices must use distinct source clips' + ); + + firstStoneStep.emit('ended'); + secondStoneStep.emit('ended'); clock.advance(600); assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks'); @@ -431,8 +495,15 @@ try { assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience'); assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music'); - director.playAmbience('ambience-a', 0.03); + director.playAmbience('battle-fire', 0.13); clock.advance(1300); + debug = director.getDebugState(); + assert.equal(debug.ambience.trackGain, ambienceTrackGainCompensation['battle-fire'], 'fire ambience should expose its peak trim'); + assertClose( + audioHarness.byOriginalSrc('/audio/battle-fire.ogg').volume, + 0.13 * ambienceTrackGainCompensation['battle-fire'], + 'fire ambience should apply its track-specific loop gain' + ); const audioCountBeforeLegacyMusic = audioHarness.instances.length; const musicRevisionBeforeLegacyMusic = director.getDebugState().music.transition.revision; director.playMusic('music-c'); @@ -628,8 +699,8 @@ try { console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + - `${debug.ambience.elementRevision} ambience element revisions, gain-balanced no-repeat effect pools, ` + - 'terrain-aware foot and hoof movement, ' + + `${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` + + 'route-sampled terrain movement with bounded foot and hoof voices, ' + 'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.' ); } finally { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index e643922..16f1f51 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -19,6 +19,7 @@ const checks = [ 'scripts/verify-sortie-recommendations.mjs', 'scripts/verify-sortie-roster-history.mjs', 'scripts/verify-battle-scenario-data.mjs', + 'scripts/verify-narrative-continuity.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-victory-reward-acknowledgements.mjs', 'scripts/verify-camp-skin-data.mjs', diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index 24eeea8..01578e0 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -9,7 +9,7 @@ const validDirections = new Set(['south', 'east', 'north', 'west']); const portraitlessActorIds = new Set(['rebel-leader']); const maxAdjacentBackgroundDuplicateRate = 0.02; const maxAdjacentBackgroundRun = 3; -const expectedStoryPageCount = 466; +const expectedStoryPageCount = 468; const server = await createServer({ logLevel: 'error', @@ -553,7 +553,7 @@ function validateStoryBackgroundAdjacencyMetrics(errors, metrics) { function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) { const firstVariantNumber = 33; - const lastVariantNumber = 134; + const lastVariantNumber = 135; const expectedVariantCount = lastVariantNumber - firstVariantNumber + 1; const variantKeys = readdirSync(join('src', 'assets', 'images', 'story'), { withFileTypes: true }) .filter((entry) => entry.isFile()) diff --git a/scripts/verify-story-environment-profiles.mjs b/scripts/verify-story-environment-profiles.mjs index 360ff21..1263a76 100644 --- a/scripts/verify-story-environment-profiles.mjs +++ b/scripts/verify-story-environment-profiles.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createServer } from 'vite'; -const expectedStoryPageCount = 466; +const expectedStoryPageCount = 468; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 3fb9d18..2c5338d 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -1,4 +1,5 @@ import { + ambienceTrackGainCompensation, effectTrackGainCompensation, movementEffectPoolsBySurface, type MovementSurface @@ -47,6 +48,7 @@ type LoopChannel = { outgoing: Set; outgoingStartVolumes: Map; elementBaseVolumes: Map; + elementTrackKeys: Map; fadeTimer?: number; elementRevision: number; transition: LoopChannelTransition; @@ -126,6 +128,13 @@ type MovementStepDebug = { rate?: number; }; +type MovementVoiceGroup = 'foot' | 'mounted'; + +type EffectPlaybackPolicy = { + movementVoiceGroup?: MovementVoiceGroup; + excludedTrackKeys?: ReadonlySet; +}; + export class SoundDirector { private context?: AudioContext; private master?: GainNode; @@ -137,6 +146,12 @@ export class SoundDirector { private readonly lastEffectPoolSelections = new Map(); private readonly activeEffects = new Set(); private readonly effectBaseVolumes = new Map(); + private readonly effectTrackKeys = new Map(); + private readonly movementEffectVoices: Record = { + foot: [], + mounted: [] + }; + private readonly movementVoiceCap = 2; private readonly musicChannel = this.createLoopChannel('music', 0.22, 900); private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200); private readonly categoryMultipliers: AudioCategoryMultipliers = { @@ -372,7 +387,7 @@ export class SoundDirector { playMeleeRush(mounted: boolean, terrain?: MovementTerrain | string) { const surface = movementSurfaceForTerrain(terrain); const key = movementEffectPoolsBySurface[surface][mounted ? 'mounted' : 'foot']; - this.playEffect(key, { + return this.playEffect(key, { volume: 0.2, rate: mounted ? 1.18 : 1.08, stopAfterMs: mounted ? 420 : 380 @@ -389,7 +404,7 @@ export class SoundDirector { ? undefined : Math.max(effectOptions.stopAfterMs, mounted ? 540 : 560) }; - const played = this.playEffect(key, movementEffectOptions); + const played = this.playMovementEffect(key, mounted, movementEffectOptions); this.lastMovementStep = { key, trackKey: played ? this.lastEffect?.trackKey ?? null : null, @@ -572,11 +587,34 @@ export class SoundDirector { } playEffect(key: string, options: PlayEffectOptions = {}) { + return this.playEffectWithPolicy(key, options); + } + + private playMovementEffect(key: string, mounted: boolean, options: PlayEffectOptions = {}) { + const movementVoiceGroup: MovementVoiceGroup = mounted ? 'mounted' : 'foot'; + const activeVoices = this.movementEffectVoices[movementVoiceGroup]; + if (activeVoices.length >= this.movementVoiceCap) { + return false; + } + + const excludedTrackKeys = new Set( + activeVoices + .map((effect) => this.effectTrackKeys.get(effect)) + .filter((trackKey): trackKey is string => Boolean(trackKey)) + ); + return this.playEffectWithPolicy(key, options, { movementVoiceGroup, excludedTrackKeys }); + } + + private playEffectWithPolicy( + key: string, + options: PlayEffectOptions = {}, + policy: EffectPlaybackPolicy = {} + ) { if (!this.started || this.muted) { return false; } - const resolvedTrack = this.resolveEffectTrack(key); + const resolvedTrack = this.resolveEffectTrack(key, policy.excludedTrackKeys); if (!resolvedTrack) { return false; } @@ -591,6 +629,10 @@ export class SoundDirector { effect.muted = this.muted; this.activeEffects.add(effect); this.effectBaseVolumes.set(effect, compensatedBaseVolume); + this.effectTrackKeys.set(effect, resolvedTrack.trackKey); + if (policy.movementVoiceGroup) { + this.movementEffectVoices[policy.movementVoiceGroup].push(effect); + } const playedEffect = { key, trackKey: resolvedTrack.trackKey, @@ -699,6 +741,11 @@ export class SoundDirector { ), effectTrackGainCompensation: { ...effectTrackGainCompensation }, activeEffectCount: this.activeEffects.size, + movementVoices: { + cap: this.movementVoiceCap, + foot: this.movementVoiceDebugState('foot'), + mounted: this.movementVoiceDebugState('mounted') + }, lastEffect: this.lastEffect ?? null, recentEffects: this.recentEffects.map((effect) => ({ ...effect })), lastMovementStep: this.lastMovementStep ?? null @@ -819,6 +866,7 @@ export class SoundDirector { outgoing: new Set(), outgoingStartVolumes: new Map(), elementBaseVolumes: new Map(), + elementTrackKeys: new Map(), elementRevision: 0, transition: { revision: 0, @@ -901,6 +949,7 @@ export class SoundDirector { next.loop = true; next.preload = 'auto'; next.muted = this.muted; + channel.elementTrackKeys.set(next, key); this.setLoopElementBaseVolume(channel, next, 0); channel.current = next; channel.currentKey = key; @@ -969,6 +1018,7 @@ export class SoundDirector { element.pause(); element.src = ''; channel.elementBaseVolumes.delete(element); + channel.elementTrackKeys.delete(element); } private loopElements() { @@ -990,9 +1040,10 @@ export class SoundDirector { elementRevision: channel.elementRevision, activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size, targetVolume: channel.targetVolume, + trackGain: this.loopTrackGain(channel, channel.currentKey), categoryMultiplier: this.categoryMultipliers[channel.name], duckMultiplier: channel.name === 'music' ? this.musicDuck.multiplier : 1, - effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume), + effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume, channel.currentKey), transition: { revision: channel.transition.revision, completedRevision: channel.transition.completedRevision, @@ -1002,16 +1053,18 @@ export class SoundDirector { }; } - private resolveEffectTrack(key: string) { + private resolveEffectTrack(key: string, excludedTrackKeys: ReadonlySet = new Set()) { const pool = this.effectPools[key]; if (pool?.length) { const availableTrackKeys = pool.filter((trackKey) => Boolean(this.effectTracks[trackKey])); if (availableTrackKeys.length) { const lastSelection = this.lastEffectPoolSelections.get(key); - const candidates = - availableTrackKeys.length > 1 - ? availableTrackKeys.filter((trackKey) => trackKey !== lastSelection) - : availableTrackKeys; + const inactiveTrackKeys = availableTrackKeys.filter((trackKey) => !excludedTrackKeys.has(trackKey)); + if (!inactiveTrackKeys.length) { + return undefined; + } + const noRepeatTrackKeys = inactiveTrackKeys.filter((trackKey) => trackKey !== lastSelection); + const candidates = noRepeatTrackKeys.length ? noRepeatTrackKeys : inactiveTrackKeys; const randomIndex = Math.floor(Math.random() * candidates.length); const trackKey = candidates[randomIndex] ?? candidates[0]; this.lastEffectPoolSelections.set(key, trackKey); @@ -1046,12 +1099,24 @@ export class SoundDirector { effect.src = ''; this.activeEffects.delete(effect); this.effectBaseVolumes.delete(effect); + this.effectTrackKeys.delete(effect); + (['foot', 'mounted'] as const).forEach((group) => { + const voices = this.movementEffectVoices[group]; + const index = voices.indexOf(effect); + if (index >= 0) { + voices.splice(index, 1); + } + }); } private setLoopElementBaseVolume(channel: LoopChannel, element: HTMLAudioElement, baseVolume: number) { const normalizedBaseVolume = this.normalizeUnitInterval(baseVolume, 0); channel.elementBaseVolumes.set(element, normalizedBaseVolume); - element.volume = this.loopOutputVolume(channel, normalizedBaseVolume); + element.volume = this.loopOutputVolume( + channel, + normalizedBaseVolume, + channel.elementTrackKeys.get(element) + ); } private refreshLoopChannelVolumes(channel: LoopChannel) { @@ -1064,13 +1129,34 @@ export class SoundDirector { private refreshLoopElementVolume(channel: LoopChannel, element: HTMLAudioElement) { const baseVolume = channel.elementBaseVolumes.get(element); if (baseVolume !== undefined) { - element.volume = this.loopOutputVolume(channel, baseVolume); + element.volume = this.loopOutputVolume(channel, baseVolume, channel.elementTrackKeys.get(element)); } } - private loopOutputVolume(channel: LoopChannel, baseVolume: number) { + private loopOutputVolume(channel: LoopChannel, baseVolume: number, trackKey?: string) { const duckMultiplier = channel.name === 'music' ? this.musicDuck.multiplier : 1; - return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers[channel.name] * duckMultiplier, 0); + return this.normalizeUnitInterval( + baseVolume * this.loopTrackGain(channel, trackKey) * this.categoryMultipliers[channel.name] * duckMultiplier, + 0 + ); + } + + private loopTrackGain(channel: LoopChannel, trackKey?: string) { + if (channel.name !== 'ambience' || !trackKey) { + return 1; + } + const gain = ambienceTrackGainCompensation[trackKey as keyof typeof ambienceTrackGainCompensation]; + return Number.isFinite(gain) ? Math.min(2, Math.max(0.5, gain)) : 1; + } + + private movementVoiceDebugState(group: MovementVoiceGroup) { + const voices = this.movementEffectVoices[group]; + return { + activeCount: voices.length, + trackKeys: voices + .map((effect) => this.effectTrackKeys.get(effect)) + .filter((trackKey): trackKey is string => Boolean(trackKey)) + }; } private setMusicDuckMultiplier(multiplier: number) { diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index d4faac1..e9dc9f0 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -81,6 +81,49 @@ export const ambienceTracks = { 'nanzhong-night-ambience': nanzhongNightAmbienceUrl } as const; +/** + * Per-track loop trims keep high-crest ambience transients below the music + * while presentation data remains responsible for the desired bed level. + * Neutral tracks stay at unity; fire crackles and exposed mountain gusts are + * deliberately attenuated because their peaks read much louder than their + * integrated level suggests. + */ +export const ambienceTrackGainCompensation = { + 'battle-rain': 0.9, + 'battle-fire': 0.72, + 'campfire-ambience': 1, + 'river-night-ambience': 1, + 'mountain-wind-ambience': 0.88, + 'nanzhong-night-ambience': 1 +} as const satisfies Record; + +/** + * Integrated loudness measured from the encoded loop assets with ffmpeg + * loudnorm. Presentation mixing uses these values to keep different music and + * ambience pairs at one stable perceptual offset instead of assuming that the + * files share the same source level. + */ +export const musicTrackIntegratedLoudnessLufs = { + 'title-theme': -16.81, + 'story-dark': -15.43, + 'oath-theme': -13.44, + 'militia-theme': -11.01, + 'battle-prep': -11.41, + 'camp-rally': -16, + 'camp-wayfarer': -16.04, + 'camp-grand-strategy': -15.88, + 'camp-frontier': -15.99 +} as const satisfies Record; + +export const ambienceTrackIntegratedLoudnessLufs = { + 'battle-rain': -24.8, + 'battle-fire': -26.11, + 'campfire-ambience': -24.58, + 'river-night-ambience': -25.2, + 'mountain-wind-ambience': -24.9, + 'nanzhong-night-ambience': -24.43 +} as const satisfies Record; + export const effectTracks = { 'ui-select': uiSelectUrl, 'ally-turn': allyTurnUrl, diff --git a/src/game/audio/movementAudio.ts b/src/game/audio/movementAudio.ts new file mode 100644 index 0000000..f3d0fd3 --- /dev/null +++ b/src/game/audio/movementAudio.ts @@ -0,0 +1,51 @@ +import type { TerrainType } from '../data/battleRules'; +import type { MovementTerrain } from './SoundDirector'; + +const cardinalOffsets = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } +] as const; + +/** + * Resolves the audible surface without changing tactical passability. Rain + * wets every traversable tile, while river banks and road fords borrow the wet + * pool even though river cells themselves remain impassable. + */ +export function movementAudioTerrainForTile( + terrainMap: readonly (readonly TerrainType[])[], + x: number, + y: number, + raining = false +): MovementTerrain { + const terrain = terrainMap[y]?.[x] ?? 'plain'; + if (raining || terrain === 'river') { + return 'wet'; + } + + const besideRiver = cardinalOffsets.some(({ x: offsetX, y: offsetY }) => ( + terrainMap[y + offsetY]?.[x + offsetX] === 'river' + )); + return besideRiver ? 'wet' : terrain; +} + +export function movementAudioTerrainForPulse( + terrainPath: readonly MovementTerrain[], + pulseIndex: number, + pulseCount: number +): MovementTerrain { + if (!terrainPath.length) { + return 'plain'; + } + if (terrainPath.length === 1 || pulseCount <= 1) { + return terrainPath[0]; + } + + const normalizedIndex = Math.min(1, Math.max(0, pulseIndex / (pulseCount - 1))); + const pathIndex = Math.min( + terrainPath.length - 1, + Math.max(0, Math.round(normalizedIndex * (terrainPath.length - 1))) + ); + return terrainPath[pathIndex]; +} diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index c968504..120e660 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -2497,7 +2497,7 @@ export const seventeenthBattleScenario: BattleScenarioDefinition = { } ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], - itemRewards: ['콩 6', '상처약 5', '탁주 2', '와룡의 서찰 +1'], + itemRewards: ['콩 6', '상처약 5', '탁주 2', '와룡의 서찰 1'], campaignReward: { supplies: ['콩 6', '상처약 5', '탁주 2'], equipment: ['와룡의 서찰 1'], @@ -3069,7 +3069,7 @@ export const twentyFirstBattleScenario: BattleScenarioDefinition = { export const twentySecondBattleScenario: BattleScenarioDefinition = { id: 'twenty-second-battle-red-cliffs-fire', title: '적벽 화공전', - victoryConditionLabel: '조조 본선 퇴각', + victoryConditionLabel: '화공 3거점 확보 후 조조 본선 퇴각', defeatConditionLabel: '유비 또는 제갈량 퇴각', openingObjectiveLines: [ '배다리 거점과 강동 포구가 열리자 황개의 거짓 항복선이 조조 함대로 향합니다. 감시선이 화선을 알아차리면 적벽의 불길은 시작되기도 전에 꺼집니다.', @@ -3176,7 +3176,8 @@ export const twentySecondBattleScenario: BattleScenarioDefinition = { rewardGold: 980, terrain: 'village', targetTile: { x: 32, y: 18, radius: 12 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'wind-signal', @@ -3185,7 +3186,8 @@ export const twentySecondBattleScenario: BattleScenarioDefinition = { rewardGold: 880, terrain: 'village', targetTile: { x: 36, y: 15, radius: 9 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'chain-fleet', @@ -3194,7 +3196,8 @@ export const twentySecondBattleScenario: BattleScenarioDefinition = { rewardGold: 1040, terrain: 'fort', targetTile: { x: 37, y: 14, radius: 5 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'quick', @@ -4875,7 +4878,7 @@ export const thirtySecondBattleScenario: BattleScenarioDefinition = { export const thirtyThirdBattleScenario: BattleScenarioDefinition = { id: 'thirty-third-battle-chengdu-surrender', title: '성도 항복 권고전', - victoryConditionLabel: '황권 방어선 격파', + victoryConditionLabel: '외곽 마을·창고 보전 후 황권 방어선 격파', defeatConditionLabel: '유비 또는 이엄 퇴각', openingObjectiveLines: [ '성도 외곽의 마지막 방어선입니다. 황권을 물리치면 유장의 항복 권고가 실제 선택지로 바뀝니다.', @@ -4985,7 +4988,8 @@ export const thirtyThirdBattleScenario: BattleScenarioDefinition = { rewardGold: 1440, terrain: 'village', targetTile: { x: 47, y: 29, radius: 20 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'south-gate-archers', @@ -5012,7 +5016,8 @@ export const thirtyThirdBattleScenario: BattleScenarioDefinition = { rewardGold: 1660, terrain: 'village', targetTile: { x: 59, y: 32, radius: 18 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'quick', @@ -6730,7 +6735,7 @@ export const fortyFifthBattleScenario: BattleScenarioDefinition = { export const fortySixthBattleScenario: BattleScenarioDefinition = { id: 'forty-sixth-battle-yiling-fire', title: '이릉 화공전', - victoryConditionLabel: '오군 화공망 돌파', + victoryConditionLabel: '중앙 군막·서쪽 퇴로 확보 후 화공망 돌파', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ '육손은 긴 촉한 진영이 마른 숲과 강변 군막 사이로 늘어진 순간을 기다렸습니다. 오군의 화공이 시작되며 진영 곳곳이 끊어집니다.', @@ -6842,7 +6847,8 @@ export const fortySixthBattleScenario: BattleScenarioDefinition = { rewardGold: 3300, terrain: 'camp', targetTile: { x: 49, y: 42, radius: 5 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'retreat-road', @@ -6851,7 +6857,8 @@ export const fortySixthBattleScenario: BattleScenarioDefinition = { rewardGold: 2600, terrain: 'village', targetTile: { x: 14, y: 55, radius: 4 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'quick', @@ -8053,7 +8060,8 @@ export const fiftyFourthBattleScenario: BattleScenarioDefinition = { rewardGold: 4820, terrain: 'road', targetTile: { x: 84, y: 58, radius: 6 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'village-witnesses', @@ -8062,7 +8070,8 @@ export const fiftyFourthBattleScenario: BattleScenarioDefinition = { rewardGold: 4480, terrain: 'village', targetTile: { x: 56, y: 58, radius: 5 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'final-remnant-forts', @@ -10007,11 +10016,12 @@ export const sixtySixthBattleScenario: BattleScenarioDefinition = { { id: 'preserve-wuzhang-camps', kind: 'secure-terrain', - label: '오장원 본영 확보', + label: '오장원 본영·귀환 장부 확보', rewardGold: 7600, terrain: 'camp', targetTile: { x: 38, y: 96, radius: 9 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'protect-returning-villages', @@ -10020,7 +10030,8 @@ export const sixtySixthBattleScenario: BattleScenarioDefinition = { rewardGold: 7200, terrain: 'village', targetTile: { x: 25, y: 101, radius: 8 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'quick', diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 28c354f..e699af7 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -920,7 +920,7 @@ const sortieFlows: Record = { eyebrow: '최종 에필로그', title: '북벌의 끝과 남은 뜻', description: - '오장원 본영을 지켜 낸 뒤 촉한군은 북벌의 마지막 장부를 닫습니다. 강유는 이어 받은 지도를 품고, 남은 장수들은 병사들이 돌아갈 길을 정리합니다.', + '오장원 본영을 지켜 낸 뒤 제갈량은 전군 귀환과 장부 계승을 마지막으로 명하고 숨을 거둡니다. 강유는 이어 받은 지도를 품고, 남은 장수들은 병사들이 돌아갈 길을 정리합니다.', rewardHint: '최종 보상: 오장원 에필로그 / 엔딩 개방', campaignStep: 'ending-complete', pages: endingEpiloguePages, diff --git a/src/game/data/campaignPresentationProfiles.ts b/src/game/data/campaignPresentationProfiles.ts index aa6b4ba..9d4f561 100644 --- a/src/game/data/campaignPresentationProfiles.ts +++ b/src/game/data/campaignPresentationProfiles.ts @@ -1,4 +1,10 @@ -import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets'; +import { + ambienceTrackGainCompensation, + ambienceTrackIntegratedLoudnessLufs, + musicTrackIntegratedLoudnessLufs, + type AmbienceTrackKey, + type MusicTrackKey +} from '../audio/audioAssets'; import { battleEnvironmentProfileFor } from './battleEnvironmentProfiles'; import { battleScenarios, type BattleScenarioId } from './battles'; import { @@ -113,9 +119,12 @@ export const campaignPresentationTransitionPolicy = Object.freeze({ musicCrossfadeMs: 900, ambienceCrossfadeMs: 1200, battleAmbienceBehavior: 'specific-profile-then-arc-fallback' as const, - battleAmbienceFallbackScale: 0.58, - battleAmbienceFallbackMinimum: 0.05, - battleAmbienceFallbackMaximum: 0.065 + battleAmbienceFallbackScale: 1, + battleAmbienceFallbackMinimum: 0.08, + battleAmbienceFallbackMaximum: 0.2, + battleAmbienceTargetRelativeLoudnessLu: -16, + battleAmbienceRelativeLoudnessMinimumLu: -18, + battleAmbienceRelativeLoudnessMaximumLu: -14 }); export const campaignPresentationStatePolicies: Readonly< @@ -356,7 +365,12 @@ export function resolveCampaignPresentationSoundscape({ const environment = battleEnvironmentProfileFor(profile.battleId); ambienceKey = environment?.soundscape.ambienceKey ?? arc.audio.defaultAmbienceKey; ambienceVolume = environment?.soundscape.ambienceVolume ?? - campaignBattleAmbienceFallbackVolume(arc.audio.defaultAmbienceVolume); + campaignBattleAmbienceFallbackVolume( + arc.audio.defaultAmbienceVolume, + arc.audio.defaultAmbienceKey, + musicKey, + arc.audio.anchorMusicVolume + ); } return { @@ -372,17 +386,50 @@ export function resolveCampaignPresentationSoundscape({ }; } -export function campaignBattleAmbienceFallbackVolume(defaultVolume?: number) { +export function campaignBattleAmbienceFallbackVolume( + defaultVolume?: number, + ambienceKey?: AmbienceTrackKey, + musicKey?: MusicTrackKey, + musicVolume?: number +) { + const ambienceLoudness = ambienceKey ? ambienceTrackIntegratedLoudnessLufs[ambienceKey] : undefined; + const musicLoudness = musicKey ? musicTrackIntegratedLoudnessLufs[musicKey] : undefined; + const ambienceGain = ambienceKey ? ambienceTrackGainCompensation[ambienceKey] : undefined; + if ( + Number.isFinite(ambienceLoudness) && + Number.isFinite(musicLoudness) && + Number.isFinite(ambienceGain) && + Number.isFinite(musicVolume) && + musicVolume! > 0 + ) { + const effectiveMusicLoudness = musicLoudness! + linearGainDb(musicVolume!); + const desiredAmbienceLoudness = + effectiveMusicLoudness + campaignPresentationTransitionPolicy.battleAmbienceTargetRelativeLoudnessLu; + const calibratedVolume = dbToLinearGain(desiredAmbienceLoudness - ambienceLoudness!) / ambienceGain!; + return roundedBattleAmbienceVolume(calibratedVolume); + } const sourceVolume = Number.isFinite(defaultVolume) ? defaultVolume! : 0.08; const scaledVolume = sourceVolume * campaignPresentationTransitionPolicy.battleAmbienceFallbackScale; + return roundedBattleAmbienceVolume(scaledVolume); +} + +function roundedBattleAmbienceVolume(volume: number) { return Math.round( Math.min( campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, - Math.max(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, scaledVolume) + Math.max(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, volume) ) * 1000 ) / 1000; } +function linearGainDb(gain: number) { + return 20 * Math.log10(Math.max(0.0001, gain)); +} + +function dbToLinearGain(decibels: number) { + return 10 ** (decibels / 20); +} + function presentationMusicKeyForStage( audio: CampaignPresentationAudioFamily, rule: CampaignPresentationStateRule, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 4df8358..ea6d9fa 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -4439,7 +4439,7 @@ export const sixtySixthBattleIntroPages: StoryPage[] = [ id: 'sixty-sixth-wuzhang-council', bgm: 'story-dark', chapter: '오장원의 밤', - background: 'story-weishui-northbank', + background: 'story-wuzhang-jiangwei-inheritance', speaker: '제갈량', text: '위수 북안의 장부를 정리하자 오장원의 등불이 마지막 작전도를 비추었습니다. 더 멀리 밀어붙이는 전공보다, 병사들이 살아 돌아갈 길과 다음 세대가 이어 받을 질서가 먼저였습니다.' }, @@ -4447,41 +4447,57 @@ export const sixtySixthBattleIntroPages: StoryPage[] = [ id: 'sixty-sixth-inherited-ledger', bgm: 'battle-prep', chapter: '이어 받을 장부', - background: 'story-northern', + background: 'story-wuzhang-jiangwei-inheritance', speaker: '강유', - text: '강유는 새로 고친 진군 장부를 품에 넣었습니다. 오늘의 북벌은 끝을 향하지만 촉한의 뜻은 여기서 끊기지 않습니다. 그는 이 싸움이 승리보다 더 무거운 계승의 시험임을 알았습니다.' + text: '새로 고친 진군 장부를 품에 넣었습니다. 오늘의 북벌은 끝을 향하지만 촉한의 뜻은 여기서 끊기지 않습니다. 이 싸움이 승리보다 더 무거운 계승의 시험임을 잊지 않겠습니다.' }, { id: 'sixty-sixth-final-orders', bgm: 'story-dark', chapter: '마지막 명령', - background: 'story-hanzhong-rain', + background: 'story-wuzhang-jiangwei-inheritance', speaker: '왕평', - text: '왕평은 퇴로의 북을 낮게 울리고, 마대와 위연은 측면의 기병을 다시 묶었습니다. 사마의가 마지막으로 압박해 오기 전에 본영과 장부, 병사들의 귀로를 함께 지켜야 합니다.' + text: '퇴로의 북을 낮게 울리겠습니다. 마대와 위연은 측면의 기병을 다시 묶어 주십시오. 사마의가 마지막으로 압박해 오기 전에 본영과 장부, 병사들의 귀로를 함께 지켜야 합니다.' } ]; export const sixtySixthBattleVictoryPages: StoryPage[] = [ { id: 'sixty-sixth-victory-wuzhang-held', - bgm: 'militia-theme', + bgm: 'story-dark', chapter: '지켜 낸 오장원', - background: 'story-weishui-northbank', + background: 'story-wuzhang-jiangwei-inheritance', text: '촉한군은 오장원 본영을 끝까지 지켜 냈습니다. 위군의 추격 깃발은 강가에서 멈추었고, 제갈량의 장부는 불타지 않은 채 다음 손으로 건너갔습니다.' }, + { + id: 'sixty-sixth-zhuge-last-order', + bgm: 'oath-theme', + chapter: '승상의 마지막 명령', + background: 'story-wuzhang-jiangwei-inheritance', + speaker: '제갈량', + text: '전투 중에는 쓰러질 수 없었습니다. 이제 숨이 다하더라도 먼저 군을 돌려보내야 합니다. 강유는 작전도를, 왕평은 귀로를 맡고, 모든 부대가 돌아갈 순서를 장부에 남기십시오.' + }, + { + id: 'sixty-sixth-zhuge-passes', + bgm: 'story-dark', + chapter: '꺼진 등불', + background: 'story-wuzhang-jiangwei-inheritance', + speaker: '내레이션', + text: '사마의의 압박이 멎고 귀환 행렬이 움직이기 시작한 밤, 오래 병을 앓던 제갈량은 마지막 명령을 확인한 뒤 오장원 본영에서 숨을 거두었습니다. 전투에서 지켜 낸 생존은 철수를 시작할 짧은 시간을 남겼고, 빈 의자는 이제 계승의 책임을 뜻했습니다.' + }, { id: 'sixty-sixth-victory-jiang-wei-oath', bgm: 'battle-prep', chapter: '새 길의 맹세', - background: 'story-northern', + background: 'story-wuzhang-jiangwei-inheritance', speaker: '강유', - text: '강유는 퇴각하는 병사들의 뒤에서 마지막으로 북쪽을 바라보았습니다. 오늘은 물러나지만, 백성의 이름으로 세운 나라가 스스로를 잊지 않는 한 북벌의 뜻도 사라지지 않을 것입니다.' + text: '승상께 받은 지도를 품고 퇴각하는 병사들의 뒤에서 마지막으로 북쪽을 바라보겠습니다. 오늘은 물러나지만, 백성의 이름으로 세운 나라가 스스로를 잊지 않는 한 북벌의 뜻도 사라지지 않을 것입니다.' }, { id: 'sixty-sixth-victory-returning-banners', bgm: 'story-dark', chapter: '돌아가는 깃발', - background: 'story-hanzhong-rain', + background: 'story-wuzhang-jiangwei-inheritance', speaker: '내레이션', text: '오장원의 불빛은 하나씩 꺼졌습니다. 하지만 도원에서 시작된 맹세, 형주와 익주를 지나 촉한에 이른 사람들의 이름은 마지막 행군 속에서도 서로를 붙들었습니다.' } @@ -4540,6 +4556,7 @@ function applyLateCampaignStoryCutscenes(pages: StoryPage[]) { fortyFourthBattleIntroPages, baidiEntrustmentPages, fiftyFourthBattleVictoryPages, + sixtySixthBattleVictoryPages, endingEpiloguePages ].forEach(applyLateCampaignStoryCutscenes); diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts index ab10a62..5f8e990 100644 --- a/src/game/data/storyAssets.ts +++ b/src/game/data/storyAssets.ts @@ -32,11 +32,17 @@ const storyAssetAliases: Record = { // without making the runtime depend on file-system order. const storyAssetCandidateAliases: Record = { 'story-chengdu-surrender': ['story-chengdu-outer-gate-surrender'], - 'story-yiling-fire-attack': ['story-yiling-fire-forest-corridor'] + 'story-yiling-fire-attack': ['story-yiling-fire-forest-corridor'], + 'story-wuzhang-jiangwei-inheritance': [ + 'story-hanzhong-rain-mountain-shrine', + 'story-qishan-renewed-snow-relay', + 'story-weishui-camps-frozen-watch', + 'story-weishui-northbank-frozen-ferry' + ] }; // Hashing provides a stable default. These sparse assignments cover single-page -// families and hash collisions so every purpose-built 33-134 scene variant is +// families and hash collisions so every purpose-built 33-135 scene variant is // actually shown somewhere in the campaign; the static verifier guards that // coverage as story pages evolve. const storyBackgroundPageAssignments: Record = { @@ -52,7 +58,10 @@ const storyBackgroundPageAssignments: Record = { 'forty-seventh-southern-unrest': 'story-nanzhong-forest-council', 'fifty-sixth-tianshui-rumor': 'story-tianshui-front-market-command', 'sixty-second-renewed-qishan-council': 'story-qishan-renewed-frozen-scout-line', - 'sixty-third-zhanghe-rear-raid': 'story-lucheng-pursuit-ravine-signal' + 'sixty-third-zhanghe-rear-raid': 'story-lucheng-pursuit-ravine-signal', + 'sixty-sixth-wuzhang-council': 'story-hanzhong-rain-mountain-shrine', + 'sixty-sixth-zhuge-passes': 'story-wuzhang-jiangwei-inheritance', + 'ending-jiang-wei-inherits-map': 'story-wuzhang-jiangwei-inheritance' }; function storyKeyFromPath(path: string) { diff --git a/src/game/data/storyCutscenes.ts b/src/game/data/storyCutscenes.ts index 48ee0ab..ec68221 100644 --- a/src/game/data/storyCutscenes.ts +++ b/src/game/data/storyCutscenes.ts @@ -361,6 +361,53 @@ const lateCampaignStoryCutscenes: Readonly> = { { label: '다음: 북벌 준비', tone: 'next' } ] }, + 'sixty-sixth-zhuge-last-order': { + kind: 'muster', + title: '승상이 남긴 귀환 명령', + subtitle: '전투에서 지켜 낸 시간이 병사들의 귀환과 다음 세대의 계승으로 이어집니다.', + actors: [ + { unitId: 'zhuge-liang', x: 0.34, y: 0.42, direction: 'east', label: '마지막 명령 · 제갈량' }, + { unitId: 'jiang-wei', x: 0.58, y: 0.48, direction: 'west', label: '작전도를 받은 강유' }, + { unitId: 'wang-ping', x: 0.7, y: 0.68, direction: 'west', label: '귀로를 맡은 왕평' } + ], + markers: [ + { x: 0.34, y: 0.42, label: '오장원 본영', side: 'ally' }, + { x: 0.58, y: 0.5, label: '이어 줄 작전도', side: 'objective' }, + { x: 0.72, y: 0.7, label: '한중 귀환로', side: 'objective' } + ], + briefing: { + title: '살아 돌아갈 순서', + lines: ['강유에게 다음 작전도를 맡긴다.', '왕평에게 후위와 귀환로를 맡긴다.', '전공보다 먼저 돌아갈 부대의 이름을 장부에 남긴다.'] + }, + rewards: [ + { label: '마지막 명령: 전군 귀환', tone: 'reward' }, + { label: '계승: 강유의 작전도', tone: 'bond' }, + { label: '다음: 꺼진 등불', tone: 'next' } + ] + }, + 'sixty-sixth-zhuge-passes': { + kind: 'muster', + title: '오장원의 빈 의자', + subtitle: '승상의 마지막 숨이 멎은 뒤 강유와 왕평이 조용히 귀환 명령을 이어 받습니다.', + actors: [ + { unitId: 'jiang-wei', x: 0.58, y: 0.46, direction: 'west', label: '작전도를 품은 강유' }, + { unitId: 'wang-ping', x: 0.72, y: 0.66, direction: 'west', label: '후위를 지킬 왕평' } + ], + markers: [ + { x: 0.34, y: 0.4, label: '불 꺼진 자리', side: 'ally' }, + { x: 0.58, y: 0.48, label: '봉한 작전도', side: 'objective' }, + { x: 0.74, y: 0.68, label: '낮아진 귀환 북', side: 'ally' } + ], + briefing: { + title: '꺼진 등불 뒤', + lines: ['승상의 빈 자리를 말없이 지킨다.', '작전도와 귀환 장부를 봉한다.', '병사들이 동요하지 않게 후위의 북을 낮춘다.'] + }, + rewards: [ + { label: '유탁: 전군 귀환', tone: 'reward' }, + { label: '계승: 강유 · 왕평', tone: 'bond' }, + { label: '다음: 돌아가는 깃발', tone: 'next' } + ] + }, 'ending-jiang-wei-inherits-map': { kind: 'muster', title: '오장원에서 이어진 지도', diff --git a/src/game/data/storyEnvironmentProfiles.ts b/src/game/data/storyEnvironmentProfiles.ts index 90f8814..d94f33b 100644 --- a/src/game/data/storyEnvironmentProfiles.ts +++ b/src/game/data/storyEnvironmentProfiles.ts @@ -206,6 +206,7 @@ const storyEnvironmentProfileByBackgroundFamily: Readonly; restoreButtonBounds: { x: number; y: number; width: number; height: number }; startButtonBounds: { x: number; y: number; width: number; height: number }; + startButtonLabel: string; + combatAssetsReady: boolean; }; type UnitDetailPanelLayout = { @@ -4430,6 +4436,12 @@ export class BattleScene extends Phaser.Scene { return; } this.deploymentNotice = '준비 중 배치가 바뀌었습니다. 현재 배치를 확인한 뒤 전투 시작을 다시 눌러주세요.'; + this.renderSortieOrderHud(); + this.renderDeploymentPanel(); + return; + } + if (this.phase === 'deployment') { + this.renderSortieOrderHud(); this.renderDeploymentPanel(); } }, watchdogMs, generation); @@ -8807,6 +8819,13 @@ export class BattleScene extends Phaser.Scene { const buttonHeight = this.battleUiLength(30); const deploymentChangeCount = this.deploymentChangeCount(); const recommendedDeployment = deploymentChangeCount === 0; + const combatAssetsReady = + this.scenarioCombatAssetStatus === 'ready' || this.scenarioCombatAssetStatus === 'degraded'; + const startButtonLabel = this.pendingDeploymentConfirmation + ? '준비 후 자동 시작' + : combatAssetsReady + ? '전투 시작' + : '준비 중 · 누르면 자동 시작'; const header = this.trackSideObject(this.add.rectangle(left, top, width, headerHeight, 0x101820, 0.96)); header.setOrigin(0); @@ -8920,7 +8939,7 @@ export class BattleScene extends Phaser.Scene { left, startButtonTop, width, - this.pendingDeploymentConfirmation ? '전투 준비 중' : '전투 시작', + startButtonLabel, palette.gold, () => this.confirmPreBattleDeployment() ); @@ -8941,7 +8960,9 @@ export class BattleScene extends Phaser.Scene { noticeTextBounds: this.gameObjectBoundsDebug(noticeText)!, roleBounds, restoreButtonBounds: { x: left, y: restoreButtonTop, width, height: buttonHeight }, - startButtonBounds: { x: left, y: startButtonTop, width, height: buttonHeight } + startButtonBounds: { x: left, y: startButtonTop, width, height: buttonHeight }, + startButtonLabel, + combatAssetsReady }; } @@ -13175,7 +13196,7 @@ export class BattleScene extends Phaser.Scene { if (unmetVictoryObjectives.length > 0) { this.triggerBattleEvent('victory-gate-pending', '작전 목표가 남아 있습니다', [ `남은 필수 목표 · ${unmetVictoryObjectives.map((objective) => objective.label).join(' · ')}`, - '적장을 쓰러뜨렸지만 약속한 접선과 진입로를 확보해야 전투를 마칠 수 있습니다.' + '적장을 쓰러뜨렸지만 표시된 필수 목표를 모두 확보해야 전투를 마칠 수 있습니다.' ], { priority: 'critical' }); return undefined; } @@ -13668,7 +13689,9 @@ export class BattleScene extends Phaser.Scene { const allyHolding = this.isObjectiveTileHeld(objective, terrain, 1); const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); const threateningEnemies = this.enemiesThreateningObjective(objective, terrain, 2); - const secured = (allyHolding || (!objective.requiredForVictory && !enemiesAlive)) && threateningEnemies.length === 0; + const previouslySecured = this.triggeredBattleEvents.has(this.objectiveEventKey(objective.id, 'achieved')); + const securedNow = (allyHolding || (!objective.requiredForVictory && !enemiesAlive)) && threateningEnemies.length === 0; + const secured = previouslySecured || securedNow; const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; const summary = secured ? '확보' : allyHolding ? '교전 중' : '미확보'; @@ -17560,8 +17583,9 @@ export class BattleScene extends Phaser.Scene { } const event = battleScenario.tacticalGuide?.events?.[key]; const reaction = this.tacticalEventReaction(key); + const authoredLines = this.deployedTacticalEventLines(event?.lines ?? fallbackLines); const lines = [ - ...(event?.lines ?? fallbackLines), + ...(authoredLines.length > 0 ? authoredLines : fallbackLines), ...(reaction ? [reaction.line] : []) ]; if (reaction) { @@ -17576,6 +17600,24 @@ export class BattleScene extends Phaser.Scene { this.triggerBattleEvent(key, event?.title ?? fallbackTitle, lines, { priority }); } + private deployedTacticalEventLines(lines: string[]) { + const alliedSpeakerNames = new Set( + battleScenario.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.name) + ); + const activeAlliedSpeakerNames = new Set( + battleUnits + .filter((unit) => unit.faction === 'ally' && unit.hp > 0) + .map((unit) => unit.name) + ); + + return lines.filter((line) => { + const speakerName = line.match(/^([^::]+)\s*[::]/u)?.[1]?.trim(); + return !speakerName || !alliedSpeakerNames.has(speakerName) || activeAlliedSpeakerNames.has(speakerName); + }); + } + private tacticalEventReaction(key: BattleTacticalGuideEventKey): TacticalEventReactionSnapshot | undefined { const candidate = tacticalEventReactionCandidates[key]?.find(({ unitId }) => ( battleUnits.some((unit) => unit.id === unitId && unit.faction === 'ally' && unit.hp > 0) @@ -23248,7 +23290,12 @@ export class BattleScene extends Phaser.Scene { this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); this.setUnitRoleSealPosition(view, startX, startY); - this.playMovementSound(unit, movementDuration, movementDistance, battleMap.terrain[y]?.[x]); + this.playMovementSound( + unit, + movementDuration, + movementDistance, + this.movementAudioTerrainPath(unit, fromX, fromY, x, y) + ); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], @@ -24272,7 +24319,12 @@ export class BattleScene extends Phaser.Scene { this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); this.setUnitRoleSealPosition(view, startX, startY); - this.playMovementSound(unit, movementDuration, movementDistance, battleMap.terrain[y]?.[x]); + this.playMovementSound( + unit, + movementDuration, + movementDistance, + this.movementAudioTerrainPath(unit, fromX, fromY, x, y) + ); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], @@ -24397,7 +24449,95 @@ export class BattleScene extends Phaser.Scene { return this.scaledBattleDuration(duration, 110); } - private playMovementSound(unit: UnitData, duration: number, tileDistance = 1, terrain?: TerrainType) { + private movementAudioTerrainPath( + unit: UnitData, + fromX: number, + fromY: number, + toX: number, + toY: number + ): MovementTerrain[] { + const tiles = this.movementPathTiles(unit, fromX, fromY, toX, toY); + const raining = this.battleEnvironmentProfile?.particles?.kind === 'rain'; + return tiles.map(({ x, y }) => movementAudioTerrainForTile(battleMap.terrain, x, y, raining)); + } + + private movementPathTiles(unit: UnitData, fromX: number, fromY: number, toX: number, toY: number) { + const start = { x: fromX, y: fromY }; + if (!this.isInBounds(fromX, fromY) || !this.isInBounds(toX, toY)) { + return [start]; + } + if (fromX === toX && fromY === toY) { + return [start]; + } + + const directions = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + const startKey = this.tileKey(fromX, fromY); + const targetKey = this.tileKey(toX, toY); + const costs = new Map([[startKey, 0]]); + const previous = new Map(); + const queue = [{ ...start, cost: 0 }]; + + while (queue.length > 0) { + queue.sort((a, b) => a.cost - b.cost); + const current = queue.shift(); + if (!current) { + break; + } + const currentKey = this.tileKey(current.x, current.y); + if (currentKey === targetKey) { + break; + } + + directions.forEach(({ x: offsetX, y: offsetY }) => { + const nextX = current.x + offsetX; + const nextY = current.y + offsetY; + if (!this.isInBounds(nextX, nextY) || this.isMovementBlocked(unit, nextX, nextY)) { + return; + } + const stepCost = this.movementCost(unit, battleMap.terrain[nextY][nextX]); + if (!Number.isFinite(stepCost)) { + return; + } + const nextCost = current.cost + stepCost; + const nextKey = this.tileKey(nextX, nextY); + if (nextCost >= (costs.get(nextKey) ?? Number.POSITIVE_INFINITY)) { + return; + } + costs.set(nextKey, nextCost); + previous.set(nextKey, currentKey); + queue.push({ x: nextX, y: nextY, cost: nextCost }); + }); + } + + if (!costs.has(targetKey)) { + return [start, { x: toX, y: toY }]; + } + + const path = [{ x: toX, y: toY }]; + let cursorKey = targetKey; + while (cursorKey !== startKey) { + const previousKey = previous.get(cursorKey); + if (!previousKey) { + return [start, { x: toX, y: toY }]; + } + const [x, y] = previousKey.split(',').map(Number); + path.push({ x, y }); + cursorKey = previousKey; + } + return path.reverse(); + } + + private playMovementSound( + unit: UnitData, + duration: number, + tileDistance = 1, + terrainPath: readonly MovementTerrain[] = ['plain'] + ) { const isMounted = this.isMountedUnit(unit); const desiredPulseCount = isMounted ? Math.max(3, Math.min(10, tileDistance * 2 + 1)) @@ -24409,7 +24549,7 @@ export class BattleScene extends Phaser.Scene { for (let index = 0; index < pulseCount; index += 1) { this.time.delayedCall(Math.round(index * interval), () => { soundDirector.playMovementStep(isMounted, index, { - terrain, + terrain: movementAudioTerrainForPulse(terrainPath, index, pulseCount), volume: isMounted ? 0.24 : 0.18, rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12 }); @@ -28296,7 +28436,9 @@ export class BattleScene extends Phaser.Scene { noticeTextBounds: { ...this.deploymentPanelLayout.noticeTextBounds }, roleBounds: this.deploymentPanelLayout.roleBounds.map((bounds) => ({ ...bounds })), restoreButtonBounds: { ...this.deploymentPanelLayout.restoreButtonBounds }, - startButtonBounds: { ...this.deploymentPanelLayout.startButtonBounds } + startButtonBounds: { ...this.deploymentPanelLayout.startButtonBounds }, + startButtonLabel: this.deploymentPanelLayout.startButtonLabel, + combatAssetsReady: this.deploymentPanelLayout.combatAssetsReady } : null; const unitDetailPanel = this.unitDetailPanelLayout diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 518abaa..4ec234c 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1030,9 +1030,9 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ }, { id: 'northern-campaign', - title: '북벌 준비', - period: '출사표를 향해', - description: '남중 평정으로 뒤를 안정시킨 뒤, 한중 창고와 출전 무장 편성을 정비해 첫 북벌을 준비하는 장입니다.', + title: '북벌과 오장원의 계승', + period: '기산에서 오장원까지', + description: '남중 평정 뒤 첫 북벌에 나서 기산·천수·가정·진창·위수 전선을 지나고, 오장원에서 제갈량의 뜻과 귀환 장부를 다음 세대에 잇는 장입니다.', battleIds: [ 'fifty-fifth-battle-northern-qishan-road', 'fifty-sixth-battle-tianshui-advance', @@ -2669,21 +2669,20 @@ const sortieRulesByBattleId: Partial