From 8cf0886d7d81c80f1b09e618194a4359e6991bbf Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 23 Jul 2026 10:57:07 +0900 Subject: [PATCH] feat: deepen audiovisual story immersion --- docs/audio-sources.md | 17 + scripts/verify-audio-asset-data.mjs | 271 ++++++++++++++- scripts/verify-audiovisual-feedback.mjs | 19 + scripts/verify-battle-event-queue.mjs | 75 ++++ scripts/verify-battle-save-normalization.mjs | 44 ++- scripts/verify-battle-scenario-data.mjs | 40 ++- .../verify-campaign-presentation-profiles.mjs | 35 +- .../verify-campaign-save-normalization.mjs | 107 +++++- scripts/verify-interaction-ux.mjs | 231 ++++++++++++- scripts/verify-sound-director.mjs | 95 ++++- scripts/verify-static-data.mjs | 1 + src/assets/audio/sfx/footstep-earth-1.mp3 | Bin 0 -> 5574 bytes src/assets/audio/sfx/footstep-earth-2.mp3 | Bin 0 -> 5574 bytes src/assets/audio/sfx/footstep-stone-1.mp3 | Bin 0 -> 5286 bytes src/assets/audio/sfx/footstep-stone-2.mp3 | Bin 0 -> 5286 bytes src/assets/audio/sfx/footstep-wet-1.mp3 | Bin 0 -> 6148 bytes src/assets/audio/sfx/footstep-wet-2.mp3 | Bin 0 -> 6148 bytes src/assets/audio/sfx/footstep-wood-1.mp3 | Bin 0 -> 5573 bytes src/assets/audio/sfx/footstep-wood-2.mp3 | Bin 0 -> 5573 bytes src/assets/audio/sfx/hoof-earth-1.mp3 | Bin 0 -> 6113 bytes src/assets/audio/sfx/hoof-earth-2.mp3 | Bin 0 -> 6113 bytes src/assets/audio/sfx/hoof-stone-1.mp3 | Bin 0 -> 5537 bytes src/assets/audio/sfx/hoof-stone-2.mp3 | Bin 0 -> 5537 bytes src/assets/audio/sfx/hoof-wet-1.mp3 | Bin 0 -> 6399 bytes src/assets/audio/sfx/hoof-wet-2.mp3 | Bin 0 -> 6399 bytes src/assets/audio/sfx/hoof-wood-1.mp3 | Bin 0 -> 6112 bytes src/assets/audio/sfx/hoof-wood-2.mp3 | Bin 0 -> 6112 bytes src/game/audio/SoundDirector.ts | 122 ++++++- src/game/audio/audioAssets.ts | 77 ++++- src/game/data/battles.ts | 9 +- src/game/data/campaignPresentationProfiles.ts | 21 +- src/game/data/scenario.ts | 23 +- src/game/data/storyCutscenes.ts | 298 ++++++++++++++++ src/game/scenes/BattleScene.ts | 325 +++++++++++++++--- src/game/scenes/CampScene.ts | 148 ++++++-- src/game/scenes/StoryScene.ts | 2 +- src/game/state/battleSaveState.ts | 70 +++- src/game/state/campaignState.ts | 151 +++++++- 38 files changed, 2037 insertions(+), 144 deletions(-) create mode 100644 scripts/verify-battle-event-queue.mjs create mode 100644 src/assets/audio/sfx/footstep-earth-1.mp3 create mode 100644 src/assets/audio/sfx/footstep-earth-2.mp3 create mode 100644 src/assets/audio/sfx/footstep-stone-1.mp3 create mode 100644 src/assets/audio/sfx/footstep-stone-2.mp3 create mode 100644 src/assets/audio/sfx/footstep-wet-1.mp3 create mode 100644 src/assets/audio/sfx/footstep-wet-2.mp3 create mode 100644 src/assets/audio/sfx/footstep-wood-1.mp3 create mode 100644 src/assets/audio/sfx/footstep-wood-2.mp3 create mode 100644 src/assets/audio/sfx/hoof-earth-1.mp3 create mode 100644 src/assets/audio/sfx/hoof-earth-2.mp3 create mode 100644 src/assets/audio/sfx/hoof-stone-1.mp3 create mode 100644 src/assets/audio/sfx/hoof-stone-2.mp3 create mode 100644 src/assets/audio/sfx/hoof-wet-1.mp3 create mode 100644 src/assets/audio/sfx/hoof-wet-2.mp3 create mode 100644 src/assets/audio/sfx/hoof-wood-1.mp3 create mode 100644 src/assets/audio/sfx/hoof-wood-2.mp3 diff --git a/docs/audio-sources.md b/docs/audio-sources.md index 307c940..c384957 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -67,6 +67,23 @@ The source files in this section were downloaded from their official Pixabay det All three cues were silence-trimmed, resampled to 48 kHz stereo MP3, fade-in/out processed, and high-pass and low-pass filtered. Recovery was reduced to approximately 1.9 seconds, reward reveal to 1.4 seconds, and burn damage to 0.7 seconds so feedback begins promptly and does not mask the next action. +## Derived Terrain Movement One-shots + +The following short movement clips were derived in-project on 2026-07-23 from the two Pixabay-licensed recordings already listed under Sound Effects. No additional recording was introduced. Each derivative retains the original creator and Pixabay detail-page URL in its MP3 artist/comment metadata, as well as in this document. + +| Project files | Terrain role | Parent project file | Original creator | Original source | +| --- | --- | --- | --- | --- | +| `src/assets/audio/sfx/footstep-earth-{1,2}.mp3` | Foot movement on earth, plain, hill, or camp | `src/assets/audio/sfx/footstep-walk.mp3` | vgraham1 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/ | +| `src/assets/audio/sfx/footstep-stone-{1,2}.mp3` | Foot movement on road, stone, fort, village, or cliff | `src/assets/audio/sfx/footstep-walk.mp3` | vgraham1 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/ | +| `src/assets/audio/sfx/footstep-wood-{1,2}.mp3` | Foot movement through forest or across a wooden bridge | `src/assets/audio/sfx/footstep-walk.mp3` | vgraham1 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/ | +| `src/assets/audio/sfx/footstep-wet-{1,2}.mp3` | Foot movement through river, water, marsh, or swamp | `src/assets/audio/sfx/footstep-walk.mp3` | vgraham1 (Freesound) | https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/ | +| `src/assets/audio/sfx/hoof-earth-{1,2}.mp3` | Mounted movement on earth, plain, hill, or camp | `src/assets/audio/sfx/horse-gallop.mp3` | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/ | +| `src/assets/audio/sfx/hoof-stone-{1,2}.mp3` | Mounted movement on road, stone, fort, village, or cliff | `src/assets/audio/sfx/horse-gallop.mp3` | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/ | +| `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. + ## Sound Effects | Project file | Pixabay title | Creator | Source | diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index f57cadd..585837c 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -1,5 +1,6 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, sep } from 'node:path'; +import { spawnSync } from 'node:child_process'; import { createServer } from 'vite'; const audioRoot = join('src', 'assets', 'audio'); @@ -21,6 +22,20 @@ const requiredTacticalCueKeys = [ ]; const requiredNarrativeCueKeys = ['story-page-turn']; const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick']; +const movementPoolSourceRecords = { + 'movement-foot-earth': movementSourceRecord('footstep', 'earth', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), + 'movement-foot-stone': movementSourceRecord('footstep', 'stone', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), + 'movement-foot-wood': movementSourceRecord('footstep', 'wood', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), + 'movement-foot-wet': movementSourceRecord('footstep', 'wet', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), + 'movement-hoof-earth': movementSourceRecord('hoof', 'earth', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), + 'movement-hoof-stone': movementSourceRecord('hoof', 'stone', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), + 'movement-hoof-wood': movementSourceRecord('hoof', 'wood', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), + 'movement-hoof-wet': movementSourceRecord('hoof', 'wet', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/') +}; +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 requiredBattleSceneAudioMethods = [ 'playSoundscape', 'playAllyTurnCue', @@ -91,7 +106,13 @@ const server = await createServer({ try { const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); - const { musicTracks, ambienceTracks, effectTracks } = audioAssets; + const { + musicTracks, + ambienceTracks, + effectTracks, + effectTrackGainCompensation = {}, + movementEffectPoolsBySurface = {} + } = audioAssets; const effectPools = audioAssets.effectPools ?? {}; const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); @@ -107,8 +128,16 @@ try { validateBattlefieldSourceDocumentation(errors); validateNarrativeSourceDocumentation(errors); validateSemanticFeedbackSourceDocumentation(errors); + validateMovementSourceDocumentation(errors); validateBattleSceneAudioIntegration(errors); validateEffectPools(errors, effectPools, effectTracks); + validateMovementEffectPools( + errors, + effectPools, + effectTracks, + movementEffectPoolsBySurface + ); + validateEffectPoolMixBalance(errors, effectPools, effectTracks, effectTrackGainCompensation); validateEffectPoolRegistration(errors, effectPools); validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); @@ -125,6 +154,7 @@ try { console.log( `Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` + `${Object.keys(effectTracks).length} effect tracks, ${Object.keys(effectPools).length} effect pools, ` + + `${Object.keys(movementPoolSourceRecords).length} terrain movement pools, balanced randomized effects, ` + `${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.` ); } @@ -156,6 +186,115 @@ function validateEffectPools(errors, effectPools, effectTracks) { }); } +function validateMovementEffectPools(errors, effectPools, effectTracks, movementEffectPoolsBySurface) { + const expectedSurfaceEntries = { + earth: { foot: 'movement-foot-earth', mounted: 'movement-hoof-earth' }, + stone: { foot: 'movement-foot-stone', mounted: 'movement-hoof-stone' }, + wood: { foot: 'movement-foot-wood', mounted: 'movement-hoof-wood' }, + wet: { foot: 'movement-foot-wet', mounted: 'movement-hoof-wet' } + }; + + if (JSON.stringify(movementEffectPoolsBySurface) !== JSON.stringify(expectedSurfaceEntries)) { + errors.push('movementEffectPoolsBySurface must map earth/stone/wood/wet to their foot and mounted pools'); + } + + if (!audioInspectionToolsAvailable(errors)) { + return; + } + + Object.entries(movementPoolSourceRecords).forEach(([poolKey, record]) => { + const trackKeys = effectPools[poolKey]; + if (JSON.stringify(trackKeys) !== JSON.stringify(record.trackKeys)) { + errors.push(`${poolKey} must contain exactly ${record.trackKeys.join(', ')}`); + return; + } + + const meanVolumes = []; + trackKeys.forEach((trackKey) => { + const url = effectTracks[trackKey]; + const path = url ? assetUrlToPath(url) : undefined; + if (!path) { + errors.push(`${poolKey} references movement track "${trackKey}" without a readable project path`); + return; + } + + const probe = probeAudio(path, errors, trackKey); + const volume = measureAudioVolume(path, errors, trackKey); + if (!probe || !volume) { + return; + } + + if (probe.duration < 0.3 || probe.duration > 0.52) { + errors.push(`${trackKey} must remain a 0.30-0.52 second one-shot; received ${probe.duration.toFixed(3)} seconds`); + } + if (probe.sampleRate !== 48_000 || probe.channels !== 2) { + errors.push(`${trackKey} must be 48 kHz stereo; received ${probe.sampleRate} Hz/${probe.channels} channels`); + } + if (!probe.artist.includes(record.creator)) { + errors.push(`${trackKey} metadata must retain original creator "${record.creator}"`); + } + if (!probe.comment.includes(record.parentFile) || !probe.comment.includes(record.source)) { + errors.push(`${trackKey} metadata must retain parent file and Pixabay source lineage`); + } + if (volume.meanDb < -28 || volume.meanDb > -20) { + errors.push(`${trackKey} mean level ${volume.meanDb.toFixed(1)} dB is outside the quiet movement mix`); + } + if (volume.peakDb > -2 || volume.peakDb < -15) { + errors.push(`${trackKey} peak ${volume.peakDb.toFixed(1)} dB is outside the -15..-2 dB safety window`); + } + meanVolumes.push(volume.meanDb); + }); + + if (meanVolumes.length === record.trackKeys.length) { + const spread = Math.max(...meanVolumes) - Math.min(...meanVolumes); + if (spread > 5) { + errors.push(`${poolKey} one-shot mean-level spread ${spread.toFixed(1)} dB exceeds 5 dB`); + } + } + }); +} + +function validateEffectPoolMixBalance(errors, effectPools, effectTracks, gainCompensation) { + Object.entries(gainCompensation).forEach(([trackKey, gain]) => { + if (!(trackKey in effectTracks)) { + errors.push(`effect gain compensation references unknown track "${trackKey}"`); + } + if (!Number.isFinite(gain) || gain < 0.5 || gain > 2) { + errors.push(`effect gain compensation for "${trackKey}" must stay within 0.5..2`); + } + }); + + if (!audioInspectionToolsAvailable(errors)) { + return; + } + + compensatedPoolKeys.forEach((poolKey) => { + const adjustedLoudness = []; + (effectPools[poolKey] ?? []).forEach((trackKey) => { + const gain = gainCompensation[trackKey]; + if (!Number.isFinite(gain)) { + errors.push(`${poolKey}/${trackKey} needs explicit playback gain compensation`); + return; + } + const path = assetUrlToPath(effectTracks[trackKey] ?? ''); + if (!path) { + return; + } + const loudness = measureIntegratedLoudness(path, errors, trackKey); + if (loudness !== undefined) { + adjustedLoudness.push(loudness + 20 * Math.log10(gain)); + } + }); + + if (adjustedLoudness.length === (effectPools[poolKey]?.length ?? 0)) { + const spread = Math.max(...adjustedLoudness) - Math.min(...adjustedLoudness); + if (spread > 0.8) { + errors.push(`${poolKey} compensated integrated-loudness spread ${spread.toFixed(2)} LU exceeds 0.8 LU`); + } + } + }); +} + function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) { requiredKeys.forEach((key) => { if (!(key in tracks)) { @@ -230,6 +369,38 @@ function validateSemanticFeedbackSourceDocumentation(errors) { }); } +function validateMovementSourceDocumentation(errors) { + const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); + const sectionHeading = '## Derived Terrain Movement One-shots'; + const sectionStart = docs.indexOf(sectionHeading); + if (sectionStart < 0) { + errors.push('docs/audio-sources.md must include the Derived Terrain Movement One-shots section'); + return; + } + const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length); + const movementDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart); + [ + '2026-07-23', + '48 kHz stereo MP3', + '8 ms attack', + '55 ms release', + 'terrain-specific EQ', + 'per-track gain compensation' + ].forEach((detail) => { + if (!movementDocs.includes(detail)) { + errors.push(`docs/audio-sources.md is missing movement processing detail "${detail}"`); + } + }); + + Object.values(movementPoolSourceRecords).forEach((record) => { + [record.projectPattern, record.parentProjectFile, record.creator, record.source].forEach((detail) => { + if (!movementDocs.includes(detail)) { + errors.push(`docs/audio-sources.md is missing movement source detail "${detail}"`); + } + }); + }); +} + function validateBattleSceneAudioIntegration(errors) { const path = join('src', 'game', 'scenes', 'BattleScene.ts'); const source = readFileSync(path, 'utf8'); @@ -450,6 +621,104 @@ function firstArgumentEnd(source, startIndex) { return source.length; } +function movementSourceRecord(prefix, surface, parentFile, creator, source) { + return { + trackKeys: [`${prefix}-${surface}-1`, `${prefix}-${surface}-2`], + projectPattern: `src/assets/audio/sfx/${prefix}-${surface}-{1,2}.mp3`, + parentFile, + parentProjectFile: `src/assets/audio/sfx/${parentFile}`, + creator, + source + }; +} + +function audioInspectionToolsAvailable(errors) { + if (!audioInspectionToolStatus) { + const ffmpeg = spawnSync(ffmpegCommand, ['-version'], { encoding: 'utf8', windowsHide: true }); + const ffprobe = spawnSync(ffprobeCommand, ['-version'], { encoding: 'utf8', windowsHide: true }); + audioInspectionToolStatus = { + available: ffmpeg.status === 0 && ffprobe.status === 0, + reported: false, + detail: [ffmpeg.error?.message, ffprobe.error?.message].filter(Boolean).join('; ') + }; + } + + if (!audioInspectionToolStatus.available && !audioInspectionToolStatus.reported) { + errors.push( + `ffmpeg and ffprobe are required for movement and pool loudness verification` + + (audioInspectionToolStatus.detail ? `: ${audioInspectionToolStatus.detail}` : '') + ); + audioInspectionToolStatus.reported = true; + } + return audioInspectionToolStatus.available; +} + +function probeAudio(path, errors, context) { + const result = spawnSync( + ffprobeCommand, + [ + '-v', 'error', + '-select_streams', 'a:0', + '-show_entries', 'format=duration:stream=sample_rate,channels:format_tags=artist,comment', + '-of', 'json', + path + ], + { encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true } + ); + if (result.status !== 0) { + errors.push(`${context}: ffprobe failed: ${(result.stderr || result.error?.message || '').trim()}`); + return undefined; + } + + try { + const parsed = JSON.parse(result.stdout); + const stream = parsed.streams?.[0] ?? {}; + const tags = parsed.format?.tags ?? {}; + return { + duration: Number(parsed.format?.duration), + sampleRate: Number(stream.sample_rate), + channels: Number(stream.channels), + artist: String(tags.artist ?? ''), + comment: String(tags.comment ?? '') + }; + } catch (error) { + errors.push(`${context}: unreadable ffprobe JSON: ${error.message}`); + return undefined; + } +} + +function measureAudioVolume(path, errors, context) { + const result = spawnSync( + ffmpegCommand, + ['-hide_banner', '-nostats', '-i', path, '-af', 'volumedetect', '-f', 'null', '-'], + { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true } + ); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const meanMatch = [...output.matchAll(/mean_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1); + const peakMatch = [...output.matchAll(/max_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1); + if (result.status !== 0 || !meanMatch || !peakMatch) { + errors.push(`${context}: ffmpeg could not measure mean/peak volume`); + return undefined; + } + return { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) }; +} + +function measureIntegratedLoudness(path, errors, context) { + const result = spawnSync( + ffmpegCommand, + ['-hide_banner', '-nostats', '-i', path, '-filter_complex', 'ebur128=peak=true', '-f', 'null', '-'], + { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true } + ); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const matches = [...output.matchAll(/\bI:\s*(-?\d+(?:\.\d+)?)\s*LUFS/g)]; + const loudness = Number(matches.at(-1)?.[1]); + if (result.status !== 0 || !Number.isFinite(loudness) || loudness <= -70) { + errors.push(`${context}: ffmpeg could not measure integrated loudness`); + return undefined; + } + return loudness; +} + function assetUrlToPath(url) { const marker = 'src/assets/audio/'; const index = url.indexOf(marker); diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index 3ebdb15..ca7cddd 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -63,6 +63,13 @@ assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/); const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement'); assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once'); +const progressChapterDetail = privateMethodBody(campSource, 'renderProgressChapterDetail'); +assert.match(progressChapterDetail, /const battleTitleWidth = 116/); +assert.match(progressChapterDetail, /const objectiveX = battleTitleX \+ battleTitleWidth \+ 10/); +assert.match(progressChapterDetail, /compactText\(battle\.title, 10\)[\s\S]*fixedWidth: battleTitleWidth/); +assert.match(progressChapterDetail, /compactText\(battle\.victoryConditionLabel, 15\)[\s\S]*fixedWidth: objectiveWidth/); +assert.match(progressChapterDetail, /setData\('timelineField', 'battle-title'\)[\s\S]*setData\('timelineField', 'victory-condition'\)/); + const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment'); assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/); assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue'); @@ -72,6 +79,18 @@ assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]* const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent'); assert.match(triggerBattleEvent, /options\.playCue !== false/); +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.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), + 2, + 'both synchronous and asynchronous movement paths must pass destination terrain to movement audio' +); + const mapCombat = privateMethodBody(battleSource, 'playCombatMapPresentation'); assert.match(mapCombat, /contactDelayMs = Math\.max\(70, Math\.round\(duration \* 0\.42\)\)/); assert.match(mapCombat, /await this\.waitSceneDuration\(contactDelayMs\);\s*this\.playCombatContactSound\(result\)/s); diff --git a/scripts/verify-battle-event-queue.mjs b/scripts/verify-battle-event-queue.mjs new file mode 100644 index 0000000..c2f017f --- /dev/null +++ b/scripts/verify-battle-event-queue.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); +const saveSource = readFileSync('src/game/state/battleSaveState.ts', 'utf8'); + +const triggerEvent = privateMethodBody(battleSource, 'triggerBattleEvent'); +assert.match(triggerEvent, /this\.isBattleEventKnown\(key\)/, 'event detection must reject completed, active, and queued duplicates'); +assert.match(triggerEvent, /battleEventPriorityRank/, 'event insertion must compare explicit priorities'); +assert.match(triggerEvent, /this\.battleEventQueue\.splice\(insertionIndex, 0, event\)/, 'higher-priority events must enter ahead of lower priorities'); +assert.match(triggerEvent, /this\.battleEventQueue\.push\(event\)/, 'equal priorities must retain FIFO order'); +assert.match(triggerEvent, /if \(!this\.deferBattleEventPresentation\)/, 'batched event detection must defer presentation until priority sorting finishes'); +assert.doesNotMatch(triggerEvent, /triggeredBattleEvents\.add/, 'queued events must not complete before presentation'); + +const showNextEvent = privateMethodBody(battleSource, 'showNextBattleEvent'); +assert.match(showNextEvent, /this\.battleEventQueue\.shift\(\)/, 'presentation must consume the queue head'); +assert.match(showNextEvent, /this\.activeBattleEvent = event/, 'presentation must retain the active event until dismissal'); +assert.match(showNextEvent, /queuedEvent\.presented = true/, 'lower-priority notices in the same batch must be recorded without opening more modals'); +assert.match(showNextEvent, /title: `\$\{event\.title\} · 외 \$\{groupedEvents\.length\}건`/, 'the visible notice must disclose how many grouped updates were logged'); +assert.match(showNextEvent, /this\.showBattleEventBanner\(/, 'the queue head must be rendered'); + +const completeEvent = privateMethodBody(battleSource, 'completeActiveBattleEvent'); +assert.match(completeEvent, /this\.triggeredBattleEvents\.add\(completedEvent\.key\)/, 'an event may complete only after its banner closes'); +assert.doesNotMatch(completeEvent, /showNextBattleEvent/, 'closing one banner must not chain every queued notice into a modal burst'); +assert.match(completeEvent, /this\.battleEventQueue\.splice\(0\)/, 'closing the grouped banner must drain the same-action notice batch'); +assert.match(completeEvent, /this\.triggeredBattleEvents\.add\(groupedEvent\.key\)/, 'grouped notices recorded in the battle log must complete with the visible banner'); + +const checkEvents = privateMethodBody(battleSource, 'checkBattleEvents'); +assert.match(checkEvents, /this\.deferBattleEventPresentation = true/, 'one action must collect every newly satisfied event before choosing what to show'); +assert.match(checkEvents, /this\.collectBattleEvents\(\)/, 'one action must collect event candidates as a batch'); +assert.match(checkEvents, /this\.deferBattleEventPresentation = false/, 'event presentation deferral must always be released'); +assert.match(checkEvents, /this\.showNextBattleEvent\(\)/, 'one action may present the highest-priority queued notice after collection'); + +const clearEvents = privateMethodBody(battleSource, 'clearBattleEvents'); +assert.match(clearEvents, /this\.battleEventQueue = \[\]/, 'battle shutdown must discard pending events'); +assert.match(clearEvents, /this\.activeBattleEvent = undefined/, 'battle shutdown must discard the active event'); + +const createSave = privateMethodBody(battleSource, 'createBattleSaveState'); +const applySave = privateMethodBody(battleSource, 'applyBattleSaveState'); +assert.match(createSave, /pendingBattleEvents: this\.pendingBattleEventsForSave\(\)/, 'battle saves must retain active and queued events'); +assert.match(applySave, /state\.pendingBattleEvents \?\? \[\]/, 'battle loads must restore pending events'); +assert.match(applySave, /showNextBattleEvent\(\)/, 'battle loads must resume presentation'); +assert.match(saveSource, /pendingBattleEvents\?: BattleSavePendingEvent\[\]/, 'pending event saves must remain an optional version-1 field'); + +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 triggerTacticalEvent = privateMethodBody(battleSource, 'triggerTacticalEvent'); +const tacticalReaction = privateMethodBody(battleSource, 'tacticalEventReaction'); +assert.match(triggerTacticalEvent, /reaction \? \[reaction\.line\] : \[\]/, 'a valid deployed officer reaction must be appended to tactical event lines'); +assert.match(tacticalReaction, /unit\.faction === 'ally' && unit\.hp > 0/, 'reaction candidates must be deployed, allied, and alive'); +assert.match(battleSource, /tacticalEventReactions: this\.tacticalEventReactionHistory\.map/, 'debug state must expose the selected tactical speakers'); +assert.match(battleSource, /const maxBodyLines = 3/, 'the tactical reaction line must remain visible in the event banner'); +const reactionTable = sourceBlock(battleSource, 'const tacticalEventReactionCandidates', '\n};'); +const reactionUnitIds = new Set(Array.from(reactionTable.matchAll(/unitId: '([^']+)'/g), (match) => match[1])); +assert.equal(reactionUnitIds.size, 8, 'the focused tactical reaction table should use exactly eight representative officers'); + +console.log('Verified prioritized FIFO battle-event presentation, one-modal grouped pacing, completion timing, save resume, cleanup, narrative victory gates, and deployed-officer reactions.'); + +function privateMethodBody(source, name) { + const marker = new RegExp(`^ private (?:async )?${name}\\b`, 'm').exec(source); + assert(marker, `missing private method: ${name}`); + const start = marker.index; + const next = source.indexOf('\n private ', start + marker[0].length); + return source.slice(start, next > start ? next : source.length); +} + +function sourceBlock(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + assert(start >= 0, `missing source marker: ${startMarker}`); + const end = source.indexOf(endMarker, start); + assert(end > start, `missing source end marker after: ${startMarker}`); + return source.slice(start, end + endMarker.length); +} diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 0c5b624..1edcc29 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -71,6 +71,34 @@ try { isValidBattleSaveState(legacyStateWithoutSortieOrder, options), 'Expected legacy battle saves without a sortie order to remain valid.' ); + const validPendingBattleEventState = { + ...validState, + pendingBattleEvents: [ + { + key: 'leader-wavering', + title: '두령 동요', + lines: ['두령의 기세가 꺾였습니다.', '전열을 몰아붙이십시오.'], + priority: 'high', + playCue: true, + presented: true + }, + { + key: 'objective-village-approach', + title: '마을 접근', + lines: ['마을 주변의 방어 병력을 확인했습니다.'], + priority: 'low', + playCue: true + } + ] + }; + const parsedPendingBattleEvents = parseBattleSaveState(JSON.stringify(validPendingBattleEventState), options)?.pendingBattleEvents; + assert( + isValidBattleSaveState(validPendingBattleEventState, options) && + parsedPendingBattleEvents?.length === 2 && + parsedPendingBattleEvents[0]?.presented === true && + parsedPendingBattleEvents !== validPendingBattleEventState.pendingBattleEvents, + 'Expected active and queued battle events to round-trip without changing save version 1.' + ); const validRecommendationState = { ...validState, sortieRecommendation: { @@ -444,7 +472,21 @@ try { ['invalid triggered events', { triggeredBattleEvents: [1] }], ['unknown triggered event', { triggeredBattleEvents: ['phantom-event'] }], ['duplicate triggered event', { triggeredBattleEvents: ['opening', 'opening'] }], - ['too long triggered event', { triggeredBattleEvents: ['x'.repeat(97)] }] + ['too long triggered event', { triggeredBattleEvents: ['x'.repeat(97)] }], + ['invalid pending events shape', { pendingBattleEvents: [1] }], + ['unknown pending event', { pendingBattleEvents: [{ key: 'phantom-event', title: 'Unknown', lines: ['Unknown'], priority: 'normal', playCue: true }] }], + ['duplicate pending event', { pendingBattleEvents: [ + { key: 'leader-wavering', title: 'First', lines: ['First'], priority: 'high', playCue: true }, + { key: 'leader-wavering', title: 'Second', lines: ['Second'], priority: 'normal', playCue: false } + ] }], + ['completed event cannot remain pending', { pendingBattleEvents: [{ key: 'opening', title: 'Opening', lines: ['Opening'], priority: 'critical', playCue: true }] }], + ['invalid pending event priority', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'urgent', playCue: true }] }], + ['invalid pending event cue', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: 'yes' }] }], + ['empty pending event lines', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: [], priority: 'high', playCue: true }] }], + ['presented pending event must be first', { pendingBattleEvents: [ + { key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: true }, + { key: 'objective-village-approach', title: 'Village', lines: ['Village'], priority: 'low', playCue: true, presented: true } + ] }] ]; rejectedCases.forEach(([label, patch]) => { diff --git a/scripts/verify-battle-scenario-data.mjs b/scripts/verify-battle-scenario-data.mjs index 8a6508c..ec7a715 100644 --- a/scripts/verify-battle-scenario-data.mjs +++ b/scripts/verify-battle-scenario-data.mjs @@ -35,6 +35,9 @@ try { ...(campaignRecruitUnits ?? []).map((unit) => unit.id) ]); const errors = []; + const expectedRequiredVictoryObjectives = new Map([ + ['seventeenth-battle-wolong-visit-road', new Set(['scholar-rendezvous', 'longzhong-cottage'])] + ]); scenarioEntries.forEach(([scenarioId, scenario]) => { const context = scenarioId; @@ -49,6 +52,7 @@ try { validateMap(errors, scenario, terrainKeys, context); validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context); validateObjectives(errors, scenario, terrainKeys, context); + validateRequiredVictoryObjectives(errors, scenario, expectedRequiredVictoryObjectives.get(scenarioId), context); validateTacticalGuide(errors, scenario, tacticalGuideEventKeys, context); validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context); validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context); @@ -79,8 +83,12 @@ try { total + scenario.objectives.filter((objective) => objective.kind === 'secure-terrain').length, 0 ); + const requiredVictoryObjectiveCount = scenarioEntries.reduce( + (total, [, scenario]) => total + scenario.objectives.filter((objective) => objective.requiredForVictory).length, + 0 + ); console.log( - `Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives (${secureTerrainObjectiveCount} secure-terrain centers, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.` + `Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives (${secureTerrainObjectiveCount} secure-terrain centers, ${requiredVictoryObjectiveCount} required victory gates, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.` ); } } finally { @@ -206,6 +214,12 @@ function validateObjectives(errors, scenario, terrainKeys, context) { if (!Number.isFinite(objective.rewardGold) || objective.rewardGold < 0) { errors.push(`${context}/${objective.id}: invalid rewardGold ${objective.rewardGold}`); } + if (objective.requiredForVictory !== undefined && typeof objective.requiredForVictory !== 'boolean') { + errors.push(`${context}/${objective.id}: requiredForVictory must be a boolean`); + } + if (objective.requiredForVictory && objective.kind !== 'secure-terrain') { + errors.push(`${context}/${objective.id}: only secure-terrain objectives may gate victory`); + } if (objective.unitId && !unitIds.has(objective.unitId)) { errors.push(`${context}/${objective.id}: references missing unit "${objective.unitId}"`); } @@ -229,6 +243,30 @@ function validateObjectives(errors, scenario, terrainKeys, context) { }); } +function validateRequiredVictoryObjectives(errors, scenario, expectedObjectiveIds, context) { + const requiredObjectiveIds = scenario.objectives + .filter((objective) => objective.requiredForVictory) + .map((objective) => objective.id); + if (!expectedObjectiveIds) { + if (requiredObjectiveIds.length > 0) { + errors.push(`${context}: unexpected required victory objectives ${requiredObjectiveIds.join(', ')}`); + } + return; + } + + const actualIds = new Set(requiredObjectiveIds); + expectedObjectiveIds.forEach((objectiveId) => { + if (!actualIds.has(objectiveId)) { + errors.push(`${context}: required victory objective "${objectiveId}" is missing`); + } + }); + actualIds.forEach((objectiveId) => { + if (!expectedObjectiveIds.has(objectiveId)) { + errors.push(`${context}: unexpected required victory objective "${objectiveId}"`); + } + }); +} + function validateSecureTerrainOverrides(errors, battleScenarios, overridesByBattle, terrainKeys) { if (!overridesByBattle || typeof overridesByBattle !== 'object' || Array.isArray(overridesByBattle)) { errors.push('secureTerrainCenterOverrides must be an object keyed by battle id'); diff --git a/scripts/verify-campaign-presentation-profiles.mjs b/scripts/verify-campaign-presentation-profiles.mjs index 62143a0..1150b73 100644 --- a/scripts/verify-campaign-presentation-profiles.mjs +++ b/scripts/verify-campaign-presentation-profiles.mjs @@ -65,6 +65,7 @@ try { campaignBattlePresentationProfiles, campaignPresentationStatePolicies, campaignPresentationTransitionPolicy, + campaignBattleAmbienceFallbackVolume, campaignPresentationArcForOrdinal, campaignPresentationProfileFor, campaignPresentationStateRuleFor, @@ -76,6 +77,7 @@ try { const arcEntries = Object.entries(campaignArcPresentationProfiles); const arcIds = Object.keys(expectedArcRanges); const battleMusicKeys = new Set(); + let battleAmbienceFallbackCount = 0; assertEqual(battleIds.length, 66, 'campaign battle count'); assertEqual(new Set(battleIds).size, 66, 'canonical campaign route uniqueness'); @@ -193,11 +195,28 @@ try { const battlePlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' }); const environment = battleEnvironmentProfiles[battleId]; + const expectedAmbienceKey = environment?.soundscape.ambienceKey ?? profile?.arc.audio.defaultAmbienceKey; + const expectedAmbienceVolume = environment?.soundscape.ambienceVolume ?? + campaignBattleAmbienceFallbackVolume(profile?.arc.audio.defaultAmbienceVolume); assertEqual(battlePlan?.musicKey, profile?.arc.audio.battleMusicKey, `${battleId}: battle music plan`); - assertEqual(battlePlan?.ambienceKey, environment?.soundscape.ambienceKey, `${battleId}: battle ambience plan`); - assertEqual(battlePlan?.ambienceVolume, environment?.soundscape.ambienceVolume, `${battleId}: battle ambience volume`); + assertEqual(battlePlan?.ambienceKey, expectedAmbienceKey, `${battleId}: battle ambience plan`); + assertEqual(battlePlan?.ambienceVolume, expectedAmbienceVolume, `${battleId}: battle ambience volume`); + assert(Boolean(battlePlan?.ambienceKey), `${battleId}: every battle must retain a quiet ambience bed`); + if (!environment) { + battleAmbienceFallbackCount += 1; + assert( + battlePlan.ambienceVolume >= campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum && + battlePlan.ambienceVolume <= campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, + `${battleId}: fallback ambience must stay inside the quiet battle mix` + ); + } assert(battlePlan?.transition === campaignPresentationTransitionPolicy, `${battleId}: transition policy identity`); }); + assertEqual( + battleAmbienceFallbackCount, + battleIds.length - Object.keys(battleEnvironmentProfiles).length, + 'battle ambience fallback count' + ); const preservedStoryPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[0], @@ -250,7 +269,14 @@ try { assertEqual(campaignPresentationTransitionPolicy.sameTrackBehavior, 'keep-current-loop', 'same-track behavior'); assertEqual(campaignPresentationTransitionPolicy.musicCrossfadeMs, 900, 'music crossfade duration'); assertEqual(campaignPresentationTransitionPolicy.ambienceCrossfadeMs, 1200, 'ambience crossfade duration'); - assertEqual(campaignPresentationTransitionPolicy.battleAmbienceBehavior, 'specific-profile-only', 'battle ambience behavior'); + assertEqual( + campaignPresentationTransitionPolicy.battleAmbienceBehavior, + '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'); assert(Object.isFrozen(campaignPresentationTransitionPolicy), 'transition policy must be frozen'); const soundDirectorSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8'); @@ -308,7 +334,8 @@ try { console.log( `Verified ${arcEntries.length} campaign presentation arcs across ${battleIds.length} battles, ` + - `${battleMusicKeys.size} battle music groups, all state policies, palettes, motifs, and crossfade constraints.` + `${battleMusicKeys.size} battle music groups, ${battleAmbienceFallbackCount} quiet ambience fallbacks, ` + + 'all state policies, palettes, motifs, and crossfade constraints.' ); } finally { await server.close(); diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index af76a0e..5d80494 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -1164,16 +1164,23 @@ try { revisedSettlement.inventory['Iron Armor'] === 1, `Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}` ); - applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }); - applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }); + applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left'); + applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right'); const repeatedVisit = getCampaignState(); assert( repeatedVisit.gold === 150 && repeatedVisit.inventory.Bean === 5 && repeatedVisit.completedCampVisits.includes('repeat-visit') && - repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit'), + repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') && + repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' && + JSON.parse(storage.get(campaignStorageKey)).campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left', `Expected repeated camp visit rewards to be applied once: ${JSON.stringify(repeatedVisit)}` ); + repeatedVisit.campVisitChoiceIds['repeat-visit'] = 'mutated-outside-state'; + assert( + getCampaignState().campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left', + 'Expected returned camp visit choice histories to be deep clones.' + ); const visitDesynced = getCampaignState(); visitDesynced.completedCampVisits = []; visitDesynced.firstBattleReport.completedCampVisits = ['repeat-visit']; @@ -1197,9 +1204,9 @@ try { `Expected malformed reward labels to be ignored while valid rewards apply: ${JSON.stringify(dirtyRewardVisit)}` ); const repeatBondId = firstScenario.bonds[0].id; - applyCampBondExp('repeat-dialogue', repeatBondId, 10); + applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-listen'); const repeatedDialogueOnce = getCampaignState(); - applyCampBondExp('repeat-dialogue', repeatBondId, 10); + applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-boast'); const repeatedDialogueTwice = getCampaignState(); const onceBond = repeatedDialogueOnce.bonds.find((bond) => bond.id === repeatBondId); const twiceBond = repeatedDialogueTwice.bonds.find((bond) => bond.id === repeatBondId); @@ -1207,9 +1214,16 @@ try { onceBond?.exp === twiceBond?.exp && onceBond?.battleExp === twiceBond?.battleExp && repeatedDialogueTwice.completedCampDialogues.includes('repeat-dialogue') && - repeatedDialogueTwice.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue'), + repeatedDialogueTwice.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue') && + repeatedDialogueTwice.campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen' && + JSON.parse(storage.get(campaignStorageKey)).campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen', `Expected repeated camp dialogue rewards to be applied once: ${JSON.stringify(repeatedDialogueTwice)}` ); + repeatedDialogueTwice.campDialogueChoiceIds['repeat-dialogue'] = 'mutated-outside-state'; + assert( + getCampaignState().campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen', + 'Expected returned camp dialogue choice histories to be deep clones.' + ); const dialogueDesynced = getCampaignState(); dialogueDesynced.completedCampDialogues = []; dialogueDesynced.firstBattleReport.completedCampDialogues = ['repeat-dialogue']; @@ -1224,6 +1238,41 @@ try { dialogueResynced.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue'), `Expected report-only camp dialogue completion to resync without duplicate bond exp: ${JSON.stringify(dialogueResynced)}` ); + dialogueResynced.completedCampDialogues.push('legacy-dialogue-without-choice'); + dialogueResynced.completedCampVisits.push('legacy-visit-without-choice'); + setCampaignState(dialogueResynced); + assert(dialogueResynced.firstBattleReport, 'Expected a first battle report before replay reconciliation checks.'); + setFirstBattleReport({ + ...dialogueResynced.firstBattleReport, + completedCampDialogues: [], + completedCampVisits: [] + }); + const replayReconciled = getCampaignState(); + const replayBondBeforeRepeat = replayReconciled.bonds.find((bond) => bond.id === repeatBondId); + assert( + replayReconciled.completedCampDialogues.includes('repeat-dialogue') && + replayReconciled.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue') && + replayReconciled.completedCampVisits.includes('repeat-visit') && + replayReconciled.firstBattleReport?.completedCampVisits.includes('repeat-visit') && + replayReconciled.completedCampDialogues.includes('legacy-dialogue-without-choice') && + replayReconciled.firstBattleReport?.completedCampDialogues.includes('legacy-dialogue-without-choice') && + replayReconciled.completedCampVisits.includes('legacy-visit-without-choice') && + replayReconciled.firstBattleReport?.completedCampVisits.includes('legacy-visit-without-choice'), + `Expected choice histories and legacy completion flags to survive first-battle recalculation: ${JSON.stringify(replayReconciled)}` + ); + applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right'); + applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-boast'); + const replayRepeatBlocked = getCampaignState(); + const replayBondAfterRepeat = replayRepeatBlocked.bonds.find((bond) => bond.id === repeatBondId); + assert( + replayRepeatBlocked.gold === replayReconciled.gold && + JSON.stringify(replayRepeatBlocked.inventory) === JSON.stringify(replayReconciled.inventory) && + replayBondAfterRepeat?.exp === replayBondBeforeRepeat?.exp && + replayBondAfterRepeat?.battleExp === replayBondBeforeRepeat?.battleExp && + replayRepeatBlocked.campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen' && + replayRepeatBlocked.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left', + `Expected first-battle recalculation to preserve first choices and block duplicate camp rewards: ${JSON.stringify(replayRepeatBlocked)}` + ); const maxedBondState = getCampaignState(); const maxedCampaignBond = maxedBondState.bonds.find((bond) => bond.id === repeatBondId); const maxedReportBond = maxedBondState.firstBattleReport?.bonds.find((bond) => bond.id === repeatBondId); @@ -1624,6 +1673,10 @@ try { ); assert( Array.isArray(legacy.selectedSortieUnitIds) && + typeof legacy.campDialogueChoiceIds === 'object' && + Object.keys(legacy.campDialogueChoiceIds).length === 0 && + typeof legacy.campVisitChoiceIds === 'object' && + Object.keys(legacy.campVisitChoiceIds).length === 0 && typeof legacy.sortieFormationAssignments === 'object' && typeof legacy.sortieItemAssignments === 'object' && typeof legacy.sortieFormationPresets === 'object' && @@ -1657,6 +1710,14 @@ try { ], completedCampDialogues: ['dialogue-a', 17, 'dialogue-a', '', 'x'.repeat(97)], completedCampVisits: 'visit-a', + campDialogueChoiceIds: { + 'dialogue-a': 'choice-a', + 'dialogue-choice-only': 'choice-only', + 'bad-choice-type': 17, + ['x'.repeat(97)]: 'choice-too-long-key', + 'dialogue-long-choice': 'x'.repeat(97) + }, + campVisitChoiceIds: ['visit-a', 'choice-a'], inventory: { bean: '3', ' bean ': '4', @@ -1699,10 +1760,16 @@ try { `Expected malformed bond entries to be filtered while valid bonds normalize: ${JSON.stringify(malformed)}` ); assert( - malformed.completedCampDialogues.length === 1 && + malformed.completedCampDialogues.length === 2 && malformed.completedCampDialogues[0] === 'dialogue-a' && - malformed.completedCampVisits.length === 0, - `Expected malformed completion lists to keep unique strings only: ${JSON.stringify(malformed)}` + malformed.completedCampDialogues[1] === 'dialogue-choice-only' && + malformed.completedCampVisits.length === 0 && + JSON.stringify(malformed.campDialogueChoiceIds) === JSON.stringify({ + 'dialogue-a': 'choice-a', + 'dialogue-choice-only': 'choice-only' + }) && + Object.keys(malformed.campVisitChoiceIds).length === 0, + `Expected malformed completion lists to keep unique strings and choice-only completions: ${JSON.stringify(malformed)}` ); assert( malformed.inventory.bean === 7 && @@ -1741,16 +1808,28 @@ try { updatedAt: '2026-07-03T12:30:00.000Z', step: 'third-camp', activeSaveSlot: 1, - completedCampDialogues: Array.from({ length: 140 }, (_, index) => `dialogue-${index}`), - completedCampVisits: ['visit-a', 'x'.repeat(97), 'visit-a'] + completedCampDialogues: Array.from({ length: 300 }, (_, index) => `dialogue-${index}`), + completedCampVisits: ['visit-a', 'x'.repeat(97), 'visit-a'], + campDialogueChoiceIds: Object.fromEntries( + Array.from({ length: 300 }, (_, index) => [`dialogue-${index}`, `choice-${index}`]) + ), + campVisitChoiceIds: { + 'visit-a': 'choice-a', + 'visit-b': 23, + 'visit-c': 'x'.repeat(97) + } }) ); const cappedStringLists = loadCampaignState(); assert( - cappedStringLists.completedCampDialogues.length === 128 && + cappedStringLists.completedCampDialogues.length === 256 && cappedStringLists.completedCampDialogues[0] === 'dialogue-0' && - cappedStringLists.completedCampDialogues[127] === 'dialogue-127' && - cappedStringLists.completedCampVisits.length === 1, + cappedStringLists.completedCampDialogues[255] === 'dialogue-255' && + cappedStringLists.completedCampVisits.length === 1 && + Object.keys(cappedStringLists.campDialogueChoiceIds).length === 256 && + cappedStringLists.campDialogueChoiceIds['dialogue-255'] === 'choice-255' && + cappedStringLists.campDialogueChoiceIds['dialogue-256'] === undefined && + JSON.stringify(cappedStringLists.campVisitChoiceIds) === JSON.stringify({ 'visit-a': 'choice-a' }), `Expected campaign string lists to filter long entries and cap excessive saves: ${JSON.stringify(cappedStringLists)}` ); diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs index 088cfae..48eb8e2 100644 --- a/scripts/verify-interaction-ux.mjs +++ b/scripts/verify-interaction-ux.mjs @@ -29,6 +29,8 @@ try { await verifyCampModalAndNavigation(page); await verifyBattlePointerFlow(page); + await verifyWolongNarrativeVictoryGate(page); + await verifyCampTimelineRowLayout(page); if (pageErrors.length > 0) { throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`); @@ -42,7 +44,9 @@ 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, ' + - 'battle event overlays block edge-scroll and hover feedback, ' + + '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, ' + 'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' + 'the persistent turn-end action reopens it, and the right-click menu follows the pointer.' ); @@ -290,6 +294,7 @@ async function verifyBattlePointerFlow(page) { }, expectedBattleId, { timeout: 90000 }); await startDeploymentIfNeeded(page, expectedBattleId); await verifyBattleEventOverlayInputBlock(page); + await verifyBattleEventQueueBehavior(page); const lastAllySetup = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -614,6 +619,230 @@ async function verifyBattleEventOverlayInputBlock(page) { await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y); } +async function verifyBattleEventQueueBehavior(page) { + const initial = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return null; + } + scene.hideBattleEventBanner(); + scene.clearBattleEvents(); + ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d', 'qa-event-interrupted'].forEach((key) => scene.triggeredBattleEvents.delete(key)); + scene.deferBattleEventPresentation = true; + scene.triggerBattleEvent('qa-event-a', '먼저 감지된 사건', ['같은 전투 흐름에서 먼저 표시됩니다.'], { playCue: false }); + scene.triggerBattleEvent('qa-event-b', '낮은 우선순위', ['마지막에 표시되어야 합니다.'], { playCue: false, priority: 'low' }); + scene.triggerBattleEvent('qa-event-c', '긴급 사건 하나', ['가장 높은 우선순위로 표시되어야 합니다.'], { playCue: false, priority: 'critical' }); + scene.triggerBattleEvent('qa-event-d', '긴급 사건 둘', ['같은 우선순위의 발생 순서를 지켜야 합니다.'], { playCue: false, priority: 'critical' }); + scene.deferBattleEventPresentation = false; + scene.showNextBattleEvent(); + return { + active: scene.activeBattleEvent?.key ?? null, + queued: scene.battleEventQueue.map((event) => event.key), + completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)), + objectCount: scene.battleEventObjects.length, + bannerText: scene.battleEventObjects + .map((object) => typeof object.text === 'string' ? object.text : '') + .filter(Boolean) + }; + }); + assert( + initial?.active === 'qa-event-c' && + JSON.stringify(initial.queued) === JSON.stringify(['qa-event-d', 'qa-event-a', 'qa-event-b']) && + initial.completed.length === 0 && + initial.objectCount > 0 && + initial.bannerText.some((text) => text.includes('외 3건')), + `Battle event grouping did not preserve priority/FIFO or disclose grouped notices: ${JSON.stringify(initial)}` + ); + + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.hideBattleEventBanner(); + }); + await page.waitForFunction(() => ( + window.__HEROS_GAME__?.scene.getScene('BattleScene')?.activeBattleEvent === undefined + )); + const afterFirstDismissal = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return scene + ? { + active: scene.activeBattleEvent?.key ?? null, + queued: scene.battleEventQueue.map((event) => event.key), + completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)), + objectCount: scene.battleEventObjects.length + } + : null; + }); + assert( + afterFirstDismissal?.active === null && + afterFirstDismissal.queued.length === 0 && + afterFirstDismissal.completed.length === 4 && + afterFirstDismissal.objectCount === 0, + `Battle event dismissal did not complete the grouped one-modal batch: ${JSON.stringify(afterFirstDismissal)}` + ); + + const cleanup = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.triggerBattleEvent('qa-event-interrupted', '중단될 사건', ['정리 시 완료 처리되면 안 됩니다.'], { playCue: false }); + scene?.clearBattleEvents(); + return scene + ? { + active: scene.activeBattleEvent?.key ?? null, + queued: scene.battleEventQueue.map((event) => event.key), + objectCount: scene.battleEventObjects.length, + interruptedCompleted: scene.triggeredBattleEvents.has('qa-event-interrupted') + } + : null; + }); + assert( + cleanup?.active === null && + cleanup.queued.length === 0 && + cleanup.objectCount === 0 && + cleanup.interruptedCompleted === false, + `Battle event cleanup retained or falsely completed interrupted events: ${JSON.stringify(cleanup)}` + ); + + const reactionProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const units = scene?.debugBattleUnits?.() ?? []; + const liuBei = units.find((unit) => unit.id === 'liu-bei'); + if (!scene || !liuBei) { + return null; + } + const livingReaction = scene.tacticalEventReaction('ally-danger'); + const previousHp = liuBei.hp; + liuBei.hp = 0; + const defeatedReaction = scene.tacticalEventReaction('ally-danger'); + liuBei.hp = previousHp; + return { + deployedIds: units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id), + livingSpeaker: livingReaction?.unitId ?? null, + defeatedSpeaker: defeatedReaction?.unitId ?? null + }; + }); + assert( + reactionProbe?.livingSpeaker === 'liu-bei' && + reactionProbe.defeatedSpeaker === null && + !reactionProbe.deployedIds.includes('zhao-yun'), + `Tactical event reaction used an undeployed or defeated officer: ${JSON.stringify(reactionProbe)}` + ); +} + +async function verifyWolongNarrativeVictoryGate(page) { + const battleId = 'seventeenth-battle-wolong-visit-road'; + await page.evaluate((nextBattleId) => window.__HEROS_DEBUG__?.goToBattle(nextBattleId), battleId); + await page.waitForFunction((expectedId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.battleId === expectedId && ['deployment', 'idle'].includes(battle.phase); + }, battleId, { timeout: 90000 }); + await startDeploymentIfNeeded(page, battleId); + + const probe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const units = scene?.debugBattleUnits?.() ?? []; + const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); + const enemies = units.filter((unit) => unit.faction === 'enemy'); + const visitor = allies.find((unit) => unit.id === 'liu-bei') ?? allies[0]; + if (!scene || !visitor || enemies.length === 0) { + return null; + } + + scene.clearBattleEvents(); + enemies.forEach((enemy) => { + enemy.hp = 0; + }); + const blockedOutcome = scene.pendingBattleOutcome() ?? null; + const before = scene.requiredVictoryObjectiveStates().map((objective) => ({ + id: objective.id, + category: objective.category, + achieved: objective.achieved + })); + const gateEventKnown = scene.isBattleEventKnown('victory-gate-pending'); + visitor.x = 29; + visitor.y = 16; + const after = scene.requiredVictoryObjectiveStates().map((objective) => ({ + id: objective.id, + category: objective.category, + achieved: objective.achieved + })); + const completedOutcome = scene.pendingBattleOutcome() ?? null; + return { blockedOutcome, completedOutcome, before, after, gateEventKnown }; + }); + assert( + probe?.blockedOutcome === null && + probe.completedOutcome === 'victory' && + probe.gateEventKnown === true && + JSON.stringify(probe.before.map((objective) => objective.id)) === JSON.stringify(['scholar-rendezvous', 'longzhong-cottage']) && + probe.before.every((objective) => objective.category === 'required' && objective.achieved === false) && + probe.after.every((objective) => objective.category === 'required' && objective.achieved === true), + `Wolong battle ignored or misreported its narrative victory gate: ${JSON.stringify(probe)}` + ); +} + +async function verifyCampTimelineRowLayout(page) { + await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); + await page.waitForFunction(() => { + const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return scenes.includes('CampScene') && window.__HEROS_GAME__?.scene.getScene('CampScene')?.scene?.isActive(); + }, undefined, { timeout: 90000 }); + + const rows = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene) { + return null; + } + scene.hideSortiePrep(false); + scene.clearContent(); + scene.renderProgressChapterDetail( + { + id: 'qa-long-timeline-fields', + title: '긴 연표 필드 검증', + period: '1920×1080', + description: '긴 전투명과 승리 조건이 서로 침범하지 않는지 확인합니다.', + battleIds: [ + 'seventeenth-battle-wolong-visit-road', + 'fifty-fifth-battle-northern-qishan-road', + 'fifty-seventh-battle-jieting-crisis', + 'fifty-eighth-battle-qishan-retreat' + ], + nextHints: [] + }, + 0, + 804, + 262, + 390, + 310, + new Set() + ); + const fields = scene.children.list + .filter((object) => object.type === 'Text' && object.active && object.getData('timelineField')) + .map((object) => { + const bounds = object.getBounds(); + return { + field: object.getData('timelineField'), + text: object.text, + x: bounds.x, + y: bounds.y, + right: bounds.right, + width: bounds.width + }; + }); + const titles = fields.filter((field) => field.field === 'battle-title').sort((left, right) => left.y - right.y); + const objectives = fields.filter((field) => field.field === 'victory-condition').sort((left, right) => left.y - right.y); + return titles.map((title, index) => ({ title, objective: objectives[index] ?? null })); + }); + assert( + rows?.length === 4 && + rows.every(({ title, objective }) => ( + objective && + title.right < objective.x && + title.width > 0 && + objective.width > 0 && + objective.text.endsWith('…') + )), + `Camp timeline detail fields overlap or fail to truncate at 1920x1080: ${JSON.stringify(rows)}` + ); +} + async function startDeploymentIfNeeded(page, battleId) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (state?.phase === 'idle') { diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 08411f3..86fedf3 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -64,7 +64,8 @@ const server = await createServer({ }); try { - const { SoundDirector } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts'); + const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts'); + const { effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts'); verifyAudioPreferences(audioPreferencesModule); const clock = new FakeClock(); @@ -107,7 +108,23 @@ try { 'burn-tick': '/audio/burn-tick.ogg', 'reward-reveal': '/audio/reward-reveal.ogg', 'strategy-cast': '/audio/strategy-cast.ogg', - 'ui-select': '/audio/ui-select.ogg' + 'ui-select': '/audio/ui-select.ogg', + 'footstep-earth-1': '/audio/footstep-earth-1.ogg', + 'footstep-earth-2': '/audio/footstep-earth-2.ogg', + 'footstep-stone-1': '/audio/footstep-stone-1.ogg', + 'footstep-stone-2': '/audio/footstep-stone-2.ogg', + 'footstep-wood-1': '/audio/footstep-wood-1.ogg', + 'footstep-wood-2': '/audio/footstep-wood-2.ogg', + 'footstep-wet-1': '/audio/footstep-wet-1.ogg', + 'footstep-wet-2': '/audio/footstep-wet-2.ogg', + 'hoof-earth-1': '/audio/hoof-earth-1.ogg', + 'hoof-earth-2': '/audio/hoof-earth-2.ogg', + 'hoof-stone-1': '/audio/hoof-stone-1.ogg', + 'hoof-stone-2': '/audio/hoof-stone-2.ogg', + 'hoof-wood-1': '/audio/hoof-wood-1.ogg', + 'hoof-wood-2': '/audio/hoof-wood-2.ogg', + 'hoof-wet-1': '/audio/hoof-wet-1.ogg', + 'hoof-wet-2': '/audio/hoof-wet-2.ogg' }); director.registerEffectPools({ impact: ['sword-slash', 'combat-impact', 'sword-slash'], @@ -115,6 +132,14 @@ try { 'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'], 'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'], 'arrow-miss': ['arrow-swish'], + 'movement-foot-earth': ['footstep-earth-1', 'footstep-earth-2'], + 'movement-foot-stone': ['footstep-stone-1', 'footstep-stone-2'], + 'movement-foot-wood': ['footstep-wood-1', 'footstep-wood-2'], + 'movement-foot-wet': ['footstep-wet-1', 'footstep-wet-2'], + 'movement-hoof-earth': ['hoof-earth-1', 'hoof-earth-2'], + 'movement-hoof-stone': ['hoof-stone-1', 'hoof-stone-2'], + 'movement-hoof-wood': ['hoof-wood-1', 'hoof-wood-2'], + 'movement-hoof-wet': ['hoof-wet-1', 'hoof-wet-2'], fallback: ['missing-track'] }); @@ -171,9 +196,25 @@ try { ); const secondImpact = audioHarness.instances.at(-1); assert.notEqual(firstImpact.originalSrc, secondImpact.originalSrc, 'effect pools should avoid immediate track repetition'); - assertClose(firstImpact.volume, 0.2, 'effect category multiplier on first variation'); - assertClose(secondImpact.volume, 0.2, 'effect category multiplier on second variation'); debug = director.getDebugState(); + const [firstImpactMix, secondImpactMix] = debug.recentEffects.slice(-2); + assertClose( + firstImpact.volume, + firstImpactMix.compensatedBaseVolume * 0.5, + 'effect category multiplier on first gain-balanced variation' + ); + assertClose( + secondImpact.volume, + secondImpactMix.compensatedBaseVolume * 0.5, + 'effect category multiplier on second gain-balanced variation' + ); + [firstImpactMix, secondImpactMix].forEach((mix) => { + assert.equal( + mix.trackGain, + effectTrackGainCompensation[mix.trackKey], + `${mix.trackKey} should expose its measured pool compensation` + ); + }); assert.deepEqual( debug.effectPools.impact, ['sword-slash', 'combat-impact'], @@ -188,10 +229,11 @@ try { assert.equal(debug.activeEffectCount, 2, 'active pooled effects should be tracked'); director.setCategoryMultiplier('effects', 0.25); - assertClose(firstImpact.volume, 0.1, 'active effect should follow category multiplier changes'); - assertClose(secondImpact.volume, 0.1, 'all active effects should follow category multiplier changes'); + assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume * 0.25, 'active effect should follow category multiplier changes'); + assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume * 0.25, 'all active effects should follow category multiplier changes'); director.setCategoryMultipliers({ music: 1, effects: 1, ambience: 1 }); - assertClose(firstImpact.volume, 0.4, 'active effect should restore its base volume'); + assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume, 'active effect should restore its compensated volume'); + assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume, 'all active effects should restore their compensated volume'); assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'restored music category multiplier'); assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'restored ambience category multiplier'); clock.advance(120); @@ -200,6 +242,42 @@ try { assertRetired(firstImpact, 'first pooled impact cleanup'); assertRetired(secondImpact, 'second pooled impact cleanup'); + assert.deepEqual( + ['plain', 'road', 'forest', 'bridge', 'river', 'marsh', 'unknown'].map((terrain) => + movementSurfaceForTerrain(terrain) + ), + ['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'], + 'battle terrain names should resolve to the four movement sound surfaces' + ); + + assert.equal( + director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }), + true, + 'foot movement should accept a terrain without breaking the legacy argument order' + ); + const firstStoneStep = audioHarness.instances.at(-1); + debug = director.getDebugState(); + assert.equal(debug.lastMovementStep.key, 'movement-foot-stone', 'road movement should use the stone foot pool'); + assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface'); + assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain'); + assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey)); + + 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'); + + 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'); + debug = director.getDebugState(); + assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools'); + clock.advance(600); + assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks'); + director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 }); clock.advance(64); debug = director.getDebugState(); @@ -550,7 +628,8 @@ try { console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + - `${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` + + `${debug.ambience.elementRevision} ambience element revisions, gain-balanced no-repeat effect pools, ` + + 'terrain-aware foot and hoof movement, ' + '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 ae45402..e643922 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -12,6 +12,7 @@ const checks = [ 'scripts/verify-visual-motion-settings.mjs', 'scripts/verify-battle-forecast.mjs', 'scripts/verify-battle-save-normalization.mjs', + 'scripts/verify-battle-event-queue.mjs', 'scripts/verify-battle-save-resume-routing.mjs', 'scripts/verify-battle-usables.mjs', 'scripts/verify-sortie-synergy.mjs', diff --git a/src/assets/audio/sfx/footstep-earth-1.mp3 b/src/assets/audio/sfx/footstep-earth-1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..dd8d8081e6a0eb4bed91d46fc658def4db8ee378 GIT binary patch literal 5574 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 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/footstep-earth-2.mp3 b/src/assets/audio/sfx/footstep-earth-2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a27a0fac4624b0498a58f2d4a65913610629ef4b GIT binary patch literal 5574 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 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/footstep-stone-1.mp3 b/src/assets/audio/sfx/footstep-stone-1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0de59aa5a39978cb9fe5390229d667616eb045dd GIT binary patch literal 5286 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 Pu?~|F$G?}|zaRNOGkJR; literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/footstep-wet-1.mp3 b/src/assets/audio/sfx/footstep-wet-1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..25a3759b88021ac81590ccb4a2e1c7b71c372114 GIT binary patch literal 6148 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 Y!v5wD^%D@0%8EKX=s*9laHxU*7k!BSxc~qF literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/footstep-wood-1.mp3 b/src/assets/audio/sfx/footstep-wood-1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c40783078891a2514892a9b4063c9bddc71289f4 GIT binary patch literal 5573 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 z za_HxbVP$xQK9!2H;mzsI=C|O|S7G?tvax3n_FIfM@Zz*pQ-^B9Ggs=0@#Z&m_3Z== z{_92gPJi+BxKgS3?WWf=XFRx51`IFAwA{3QM!kRGw=T{=F5k3S?epk#VQSTO^;mc$ zZL5H~morg!(P!n6treAOo#1Q_WdWTofs5ODP82qPK*1L7n0gpElVXA4YrR7S8AO^g zG6vek>kM?lhRP$U)ZZ|%l4^EVEo!up0g5lW*7a&lf?nhXmAVTXqUcA^7Qy@Zb!ekY zZ_Hs4cEE?TMbsvzGl~WT6%z$O{#hvhv%B-(cBb?B6~(A+IVg4RpZ$$i0AT6V{%}bZ RV5aejrSbW1WAZ=y`!5Dc8(IJW literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/footstep-wood-2.mp3 b/src/assets/audio/sfx/footstep-wood-2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fd42f84f00b0be4c5dc08533a007e0ffe8b82b3f GIT binary patch literal 5573 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 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/hoof-earth-2.mp3 b/src/assets/audio/sfx/hoof-earth-2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..307f1962ffcaf887668c2b202f4880559df25a54 GIT binary patch literal 6113 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 Yq${~ZdlLr>%)xdx@}F(z|EH(_0@|zBQvd(} literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/hoof-wet-2.mp3 b/src/assets/audio/sfx/hoof-wet-2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3c46b1650f0caa0c3bc115526efd1856dd561915 GIT binary patch literal 6399 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 literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/hoof-wood-1.mp3 b/src/assets/audio/sfx/hoof-wood-1.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..de5a62845e8cd0856eb0702c72604cccf9600a68 GIT binary patch literal 6112 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 A761SM literal 0 HcmV?d00001 diff --git a/src/assets/audio/sfx/hoof-wood-2.mp3 b/src/assets/audio/sfx/hoof-wood-2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f393d5cf5bd171b51fa2c674b1c980080bd9ddc9 GIT binary patch literal 6112 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 r#a`V3_H<=L0|2PK_P0k@gbo((S9!20rtE6H-`; export type AudioCategory = 'music' | 'effects' | 'ambience'; @@ -52,6 +58,26 @@ export type PlayEffectOptions = { stopAfterMs?: number; }; +export type MovementTerrain = + | MovementSurface + | 'plain' + | 'road' + | 'forest' + | 'hill' + | 'village' + | 'fort' + | 'camp' + | 'river' + | 'cliff' + | 'bridge' + | 'water' + | 'marsh' + | 'swamp'; + +export type PlayMovementEffectOptions = PlayEffectOptions & { + terrain?: MovementTerrain | string; +}; + export type MusicDuckOptions = { multiplier?: number; attackMs?: number; @@ -82,10 +108,24 @@ type PlayedEffectDebug = { poolKey: string | null; at: number; baseVolume: number; + trackGain: number; + compensatedBaseVolume: number; volume: number; rate: number; }; +type MovementStepDebug = { + key: string; + trackKey: string | null; + mounted: boolean; + stepIndex: number; + terrain: string | null; + surface: MovementSurface; + at: number; + volume?: number; + rate?: number; +}; + export class SoundDirector { private context?: AudioContext; private master?: GainNode; @@ -130,7 +170,7 @@ export class SoundDirector { }; private lastEffect?: PlayedEffectDebug; private readonly recentEffects: PlayedEffectDebug[] = []; - private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number }; + private lastMovementStep?: MovementStepDebug; private readonly effectVolume = 0.42; registerMusicTracks(tracks: AudioTrackMap) { @@ -329,19 +369,38 @@ export class SoundDirector { window.setTimeout(() => this.playTone(980, 0.12, 0.034, 'sine'), 168); } - playMeleeRush(mounted: boolean) { - if (mounted) { - this.playEffect('horse-gallop', { volume: 0.2, rate: 1.18, stopAfterMs: 360 }); - return; - } - - this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 }); + playMeleeRush(mounted: boolean, terrain?: MovementTerrain | string) { + const surface = movementSurfaceForTerrain(terrain); + const key = movementEffectPoolsBySurface[surface][mounted ? 'mounted' : 'foot']; + this.playEffect(key, { + volume: 0.2, + rate: mounted ? 1.18 : 1.08, + stopAfterMs: mounted ? 420 : 380 + }); } - playMovementStep(mounted: boolean, stepIndex: number, options: PlayEffectOptions = {}) { - const key = mounted ? 'horse-gallop' : 'footstep-walk'; - this.lastMovementStep = { key, mounted, stepIndex, at: performance.now(), volume: options.volume, rate: options.rate }; - const played = this.playEffect(key, options); + playMovementStep(mounted: boolean, stepIndex: number, options: PlayMovementEffectOptions = {}) { + const { terrain, ...effectOptions } = options; + const surface = movementSurfaceForTerrain(terrain); + const key = movementEffectPoolsBySurface[surface][mounted ? 'mounted' : 'foot']; + const movementEffectOptions = { + ...effectOptions, + stopAfterMs: effectOptions.stopAfterMs === undefined + ? undefined + : Math.max(effectOptions.stopAfterMs, mounted ? 540 : 560) + }; + const played = this.playEffect(key, movementEffectOptions); + this.lastMovementStep = { + key, + trackKey: played ? this.lastEffect?.trackKey ?? null : null, + mounted, + stepIndex, + terrain: terrain ?? null, + surface, + at: performance.now(), + volume: effectOptions.volume, + rate: effectOptions.rate + }; this.playStepPulse(mounted, stepIndex, options.volume); return played; } @@ -524,18 +583,22 @@ export class SoundDirector { const effect = new Audio(resolvedTrack.src); const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume); + const trackGain = this.effectTrackGain(resolvedTrack.trackKey); + const compensatedBaseVolume = this.normalizeUnitInterval(baseVolume * trackGain, baseVolume); effect.preload = 'auto'; - effect.volume = this.effectOutputVolume(baseVolume); + effect.volume = this.effectOutputVolume(compensatedBaseVolume); effect.playbackRate = options.rate ?? 1; effect.muted = this.muted; this.activeEffects.add(effect); - this.effectBaseVolumes.set(effect, baseVolume); + this.effectBaseVolumes.set(effect, compensatedBaseVolume); const playedEffect = { key, trackKey: resolvedTrack.trackKey, poolKey: resolvedTrack.poolKey, at: performance.now(), baseVolume, + trackGain, + compensatedBaseVolume, volume: effect.volume, rate: effect.playbackRate }; @@ -634,6 +697,7 @@ export class SoundDirector { effectPools: Object.fromEntries( Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]]) ), + effectTrackGainCompensation: { ...effectTrackGainCompensation }, activeEffectCount: this.activeEffects.size, lastEffect: this.lastEffect ?? null, recentEffects: this.recentEffects.map((effect) => ({ ...effect })), @@ -972,6 +1036,11 @@ export class SoundDirector { return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers.effects, 0); } + private effectTrackGain(trackKey: string) { + const gain = effectTrackGainCompensation[trackKey as keyof typeof effectTrackGainCompensation]; + return Number.isFinite(gain) ? Math.min(2, Math.max(0.5, gain)) : 1; + } + private retireEffectElement(effect: HTMLAudioElement) { effect.pause(); effect.src = ''; @@ -1037,6 +1106,31 @@ export class SoundDirector { } } +export function movementSurfaceForTerrain(terrain?: MovementTerrain | string): MovementSurface { + switch (terrain?.trim().toLowerCase()) { + case 'road': + case 'fort': + case 'village': + case 'cliff': + case 'stone': + case 'paved': + return 'stone'; + case 'forest': + case 'bridge': + case 'wood': + case 'wooden': + return 'wood'; + case 'river': + case 'water': + case 'wet': + case 'marsh': + case 'swamp': + return 'wet'; + default: + return 'earth'; + } +} + declare global { interface Window { webkitAudioContext?: typeof AudioContext; diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index 4f3ca77..d4faac1 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -22,7 +22,23 @@ import criticalImpactUrl from '../../assets/audio/sfx/critical-impact.mp3'; import defeatStingUrl from '../../assets/audio/sfx/defeat-sting.wav'; import enemyTurnUrl from '../../assets/audio/sfx/enemy-turn.mp3'; import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3'; +import footstepEarth1Url from '../../assets/audio/sfx/footstep-earth-1.mp3'; +import footstepEarth2Url from '../../assets/audio/sfx/footstep-earth-2.mp3'; +import footstepStone1Url from '../../assets/audio/sfx/footstep-stone-1.mp3'; +import footstepStone2Url from '../../assets/audio/sfx/footstep-stone-2.mp3'; +import footstepWet1Url from '../../assets/audio/sfx/footstep-wet-1.mp3'; +import footstepWet2Url from '../../assets/audio/sfx/footstep-wet-2.mp3'; import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3'; +import footstepWood1Url from '../../assets/audio/sfx/footstep-wood-1.mp3'; +import footstepWood2Url from '../../assets/audio/sfx/footstep-wood-2.mp3'; +import hoofEarth1Url from '../../assets/audio/sfx/hoof-earth-1.mp3'; +import hoofEarth2Url from '../../assets/audio/sfx/hoof-earth-2.mp3'; +import hoofStone1Url from '../../assets/audio/sfx/hoof-stone-1.mp3'; +import hoofStone2Url from '../../assets/audio/sfx/hoof-stone-2.mp3'; +import hoofWet1Url from '../../assets/audio/sfx/hoof-wet-1.mp3'; +import hoofWet2Url from '../../assets/audio/sfx/hoof-wet-2.mp3'; +import hoofWood1Url from '../../assets/audio/sfx/hoof-wood-1.mp3'; +import hoofWood2Url from '../../assets/audio/sfx/hoof-wood-2.mp3'; import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3'; import levelUpUrl from '../../assets/audio/sfx/level-up.wav'; import objectiveFailureUrl from '../../assets/audio/sfx/objective-failure.mp3'; @@ -94,6 +110,22 @@ export const effectTracks = { 'bond-resonance': bondResonanceUrl, 'victory-fanfare': victoryFanfareUrl, 'defeat-sting': defeatStingUrl, + 'footstep-earth-1': footstepEarth1Url, + 'footstep-earth-2': footstepEarth2Url, + 'footstep-stone-1': footstepStone1Url, + 'footstep-stone-2': footstepStone2Url, + 'footstep-wood-1': footstepWood1Url, + 'footstep-wood-2': footstepWood2Url, + 'footstep-wet-1': footstepWet1Url, + 'footstep-wet-2': footstepWet2Url, + 'hoof-earth-1': hoofEarth1Url, + 'hoof-earth-2': hoofEarth2Url, + 'hoof-stone-1': hoofStone1Url, + 'hoof-stone-2': hoofStone2Url, + 'hoof-wood-1': hoofWood1Url, + 'hoof-wood-2': hoofWood2Url, + 'hoof-wet-1': hoofWet1Url, + 'hoof-wet-2': hoofWet2Url, 'footstep-walk': footstepWalkUrl, 'horse-gallop': horseGallopUrl } as const; @@ -102,10 +134,53 @@ export const effectPools = { 'melee-swing': ['sword-slash', 'sword-swing-variant'], 'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'], 'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'], - 'arrow-miss': ['arrow-swish'] + 'arrow-miss': ['arrow-swish'], + 'movement-foot-earth': ['footstep-earth-1', 'footstep-earth-2'], + 'movement-foot-stone': ['footstep-stone-1', 'footstep-stone-2'], + 'movement-foot-wood': ['footstep-wood-1', 'footstep-wood-2'], + 'movement-foot-wet': ['footstep-wet-1', 'footstep-wet-2'], + 'movement-hoof-earth': ['hoof-earth-1', 'hoof-earth-2'], + 'movement-hoof-stone': ['hoof-stone-1', 'hoof-stone-2'], + 'movement-hoof-wood': ['hoof-wood-1', 'hoof-wood-2'], + 'movement-hoof-wet': ['hoof-wet-1', 'hoof-wet-2'] } as const; +/** + * Browser volume compensation for source clips that share a random pool. + * Values were derived from integrated loudness measurements and deliberately + * stay below the point where the loudest gameplay request would clip. + */ +export const effectTrackGainCompensation = { + 'sword-slash': 0.64, + 'sword-swing-variant': 1.7, + 'combat-impact': 0.72, + 'armor-impact': 0.86, + 'shield-block': 0.96, + 'arrow-body-impact': 0.82, + 'arrow-wood-impact': 0.82 +} as const satisfies Partial>; + +export const movementEffectPoolsBySurface = { + earth: { + foot: 'movement-foot-earth', + mounted: 'movement-hoof-earth' + }, + stone: { + foot: 'movement-foot-stone', + mounted: 'movement-hoof-stone' + }, + wood: { + foot: 'movement-foot-wood', + mounted: 'movement-hoof-wood' + }, + wet: { + foot: 'movement-foot-wet', + mounted: 'movement-hoof-wet' + } +} as const satisfies Record; + export type MusicTrackKey = keyof typeof musicTracks; export type AmbienceTrackKey = keyof typeof ambienceTracks; export type EffectTrackKey = keyof typeof effectTracks; export type EffectPoolKey = keyof typeof effectPools; +export type MovementSurface = 'earth' | 'stone' | 'wood' | 'wet'; diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 8dbd3e2..c968504 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -352,6 +352,7 @@ export type BattleObjectiveDefinition = { targetTile?: { x: number; y: number; radius?: number }; threatRadius?: number; maxTurn?: number; + requiredForVictory?: boolean; }; export type BattleDefeatConditionDefinition = { @@ -2365,7 +2366,7 @@ export const sixteenthBattleScenario: BattleScenarioDefinition = { export const seventeenthBattleScenario: BattleScenarioDefinition = { id: 'seventeenth-battle-wolong-visit-road', title: '융중 방문로', - victoryConditionLabel: '채순 격파', + victoryConditionLabel: '문사 접선·융중 초가 확보 후 채순 격파', defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ '형주 객장에 머물던 유비군은 선비들의 말을 모아 융중의 와룡을 찾아갑니다. 그러나 채씨 가병이 산길과 초가 앞을 막아 유비의 방문을 견제합니다.', @@ -2474,7 +2475,8 @@ export const seventeenthBattleScenario: BattleScenarioDefinition = { rewardGold: 600, terrain: 'village', targetTile: { x: 22, y: 17, radius: 15 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'longzhong-cottage', @@ -2483,7 +2485,8 @@ export const seventeenthBattleScenario: BattleScenarioDefinition = { rewardGold: 720, terrain: 'village', targetTile: { x: 29, y: 16, radius: 7 }, - threatRadius: 0 + threatRadius: 0, + requiredForVictory: true }, { id: 'quick', diff --git a/src/game/data/campaignPresentationProfiles.ts b/src/game/data/campaignPresentationProfiles.ts index 8992ce2..aa6b4ba 100644 --- a/src/game/data/campaignPresentationProfiles.ts +++ b/src/game/data/campaignPresentationProfiles.ts @@ -112,7 +112,10 @@ export const campaignPresentationTransitionPolicy = Object.freeze({ sameTrackBehavior: 'keep-current-loop' as const, musicCrossfadeMs: 900, ambienceCrossfadeMs: 1200, - battleAmbienceBehavior: 'specific-profile-only' as const + battleAmbienceBehavior: 'specific-profile-then-arc-fallback' as const, + battleAmbienceFallbackScale: 0.58, + battleAmbienceFallbackMinimum: 0.05, + battleAmbienceFallbackMaximum: 0.065 }); export const campaignPresentationStatePolicies: Readonly< @@ -351,8 +354,9 @@ export function resolveCampaignPresentationSoundscape({ ambienceVolume = arc.audio.defaultAmbienceVolume; } else if (rule.ambienceMode === 'battle-environment') { const environment = battleEnvironmentProfileFor(profile.battleId); - ambienceKey = environment?.soundscape.ambienceKey; - ambienceVolume = environment?.soundscape.ambienceVolume; + ambienceKey = environment?.soundscape.ambienceKey ?? arc.audio.defaultAmbienceKey; + ambienceVolume = environment?.soundscape.ambienceVolume ?? + campaignBattleAmbienceFallbackVolume(arc.audio.defaultAmbienceVolume); } return { @@ -368,6 +372,17 @@ export function resolveCampaignPresentationSoundscape({ }; } +export function campaignBattleAmbienceFallbackVolume(defaultVolume?: number) { + const sourceVolume = Number.isFinite(defaultVolume) ? defaultVolume! : 0.08; + const scaledVolume = sourceVolume * campaignPresentationTransitionPolicy.battleAmbienceFallbackScale; + return Math.round( + Math.min( + campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, + Math.max(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, scaledVolume) + ) * 1000 + ) / 1000; +} + function presentationMusicKeyForStage( audio: CampaignPresentationAudioFamily, rule: CampaignPresentationStateRule, diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 16abedf..4df8358 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,6 +1,6 @@ import type { TerrainType, UnitClassKey } from './battleRules'; import type { EquipmentSet } from './battleItems'; -import type { StoryCutscene } from './storyCutscenes'; +import { lateCampaignStoryCutsceneForPageId, type StoryCutscene } from './storyCutscenes'; export type PortraitKey = string; @@ -4522,6 +4522,27 @@ export const endingEpiloguePages: StoryPage[] = [ } ]; +function applyLateCampaignStoryCutscenes(pages: StoryPage[]) { + pages.forEach((page) => { + const cutscene = lateCampaignStoryCutsceneForPageId(page.id); + if (cutscene) { + page.cutscene = cutscene; + } + }); +} + +[ + seventeenthBattleVictoryPages, + nineteenthBattleIntroPages, + twentySecondBattleVictoryPages, + thirtyThirdBattleVictoryPages, + shuHanFoundationPages, + fortyFourthBattleIntroPages, + baidiEntrustmentPages, + fiftyFourthBattleVictoryPages, + endingEpiloguePages +].forEach(applyLateCampaignStoryCutscenes); + export const firstBattleMap: BattleMap = { width: 20, height: 18, diff --git a/src/game/data/storyCutscenes.ts b/src/game/data/storyCutscenes.ts index d0accde..48ee0ab 100644 --- a/src/game/data/storyCutscenes.ts +++ b/src/game/data/storyCutscenes.ts @@ -83,6 +83,78 @@ const storyCutsceneActorProfiles: Record = { portraitKey: 'jian-yong', color: 0x7ea6b8 }, + 'zhao-yun': { + unitId: 'zhao-yun', + name: '조운', + roleLabel: '피난로를 여는 선봉', + unitTextureKey: 'unit-zhao-yun', + portraitKey: 'zhaoYun', + color: 0x8fb7ce + }, + 'zhuge-liang': { + unitId: 'zhuge-liang', + name: '제갈량', + roleLabel: '촉한의 군사', + unitTextureKey: 'unit-zhuge-liang', + portraitKey: 'zhugeLiang', + color: 0x83b6a7 + }, + 'zhou-yu': { + unitId: 'zhou-yu', + name: '주유', + roleLabel: '강동 수군 도독', + unitTextureKey: 'unit-wu-strategist-fire', + portraitKey: 'zhou-yu', + color: 0xd87564 + }, + 'cao-cao': { + unitId: 'cao-cao', + name: '조조', + roleLabel: '위군 총지휘관', + unitTextureKey: 'unit-cao-cao', + portraitKey: 'cao-cao', + color: 0x7e91af + }, + 'huang-quan': { + unitId: 'huang-quan', + name: '황권', + roleLabel: '익주의 장부와 군율', + unitTextureKey: 'unit-huang-quan', + portraitKey: 'huang-quan', + color: 0xb5966f + }, + 'ma-liang': { + unitId: 'ma-liang', + name: '마량', + roleLabel: '민심과 외교의 문사', + unitTextureKey: 'unit-ma-liang', + portraitKey: 'ma-liang', + color: 0xb9b2d6 + }, + 'meng-huo': { + unitId: 'meng-huo', + name: '맹획', + roleLabel: '남중의 맹주', + unitTextureKey: 'unit-meng-huo', + portraitKey: 'meng-huo', + color: 0xc88452 + }, + 'jiang-wei': { + unitId: 'jiang-wei', + name: '강유', + roleLabel: '뜻을 잇는 후계 장수', + unitTextureKey: 'unit-jiang-wei', + portraitKey: 'jiangWei', + color: 0x78a8cf + }, + 'wang-ping': { + unitId: 'wang-ping', + name: '왕평', + roleLabel: '귀로를 지키는 장수', + unitTextureKey: 'unit-wang-ping', + portraitKey: 'wang-ping', + color: 0x83a777 + }, 'rebel-leader': { unitId: 'rebel-leader', name: '황건 두령', @@ -93,10 +165,236 @@ const storyCutsceneActorProfiles: Record = { } }; +const lateCampaignStoryCutscenes: Readonly> = { + 'seventeenth-zhuge-liang-counsel': { + kind: 'muster', + title: '융중 초가의 세 번째 방문', + subtitle: '유비의 예와 제갈량의 천하삼분책이 한 길에서 만납니다.', + actors: [ + { unitId: 'liu-bei', x: 0.28, y: 0.62, direction: 'east', label: '뜻을 묻는 유비' }, + { unitId: 'zhuge-liang', x: 0.64, y: 0.38, direction: 'west', label: '와룡 제갈량' }, + { unitId: 'guan-yu', x: 0.38, y: 0.7, direction: 'east', label: '예를 지키는 관우' } + ], + markers: [ + { x: 0.24, y: 0.72, label: '융중 산길', side: 'ally' }, + { x: 0.63, y: 0.36, label: '와룡의 초가', side: 'objective' }, + { x: 0.76, y: 0.22, label: '형주와 익주', side: 'objective' } + ], + briefing: { + title: '예로 연 천하의 길', + lines: ['군세를 초가 밖에 물린다.', '세 번째 방문의 뜻을 직접 전한다.', '형주와 익주를 잇는 큰 계책을 받아 든다.'] + }, + rewards: [ + { label: '신규 무장: 제갈량', tone: 'reward' }, + { label: '새 방침: 천하삼분', tone: 'bond' }, + { label: '다음: 박망파 첫 계책', tone: 'next' } + ] + }, + 'nineteenth-changban-sortie': { + kind: 'operation', + title: '장판파 피난로', + subtitle: '백성과 깃발을 함께 살리기 위해 세 갈래 역할을 나눕니다.', + actors: [ + { unitId: 'liu-bei', x: 0.22, y: 0.68, direction: 'east', label: '피난 행렬 지휘' }, + { unitId: 'zhao-yun', x: 0.48, y: 0.43, direction: 'north', label: '조운 · 마을 구원' }, + { unitId: 'zhang-fei', x: 0.67, y: 0.58, direction: 'west', label: '장비 · 장판교 방어' } + ], + markers: [ + { x: 0.2, y: 0.72, label: '남쪽 피난로', side: 'objective' }, + { x: 0.48, y: 0.44, label: '피난민 마을', side: 'objective' }, + { x: 0.67, y: 0.58, label: '장판교', side: 'ally' }, + { x: 0.8, y: 0.3, label: '조조군 추격대', side: 'enemy' } + ], + briefing: { + title: '승리보다 먼저 지킬 것', + lines: ['조운이 흩어진 백성을 모은다.', '장비가 다리목에서 추격대를 늦춘다.', '유비는 남쪽 피난로를 끝까지 유지한다.'] + }, + rewards: [ + { label: '핵심 목표: 피난민 마을', tone: 'reward' }, + { label: '공명: 조운 · 장비', tone: 'bond' }, + { label: '다음: 강동 사절로', tone: 'next' } + ] + }, + 'twenty-second-victory-fire-rises': { + kind: 'victory', + title: '적벽에 오른 불길', + subtitle: '동남풍과 화선이 만나 조조의 연환선을 갈라놓습니다.', + actors: [ + { unitId: 'zhou-yu', x: 0.3, y: 0.62, direction: 'east', label: '화공 지휘 · 주유' }, + { unitId: 'zhuge-liang', x: 0.47, y: 0.42, direction: 'east', label: '바람의 때 · 제갈량' }, + { unitId: 'cao-cao', x: 0.78, y: 0.32, direction: 'west', label: '퇴각하는 조조' } + ], + markers: [ + { x: 0.28, y: 0.65, label: '강동 화선', side: 'ally' }, + { x: 0.48, y: 0.43, label: '동남풍 신호', side: 'objective' }, + { x: 0.67, y: 0.36, label: '불붙은 연환선', side: 'enemy' }, + { x: 0.82, y: 0.24, label: '조조군 퇴로', side: 'enemy' } + ], + briefing: { + title: '손유 동맹의 결실', + lines: ['강동 수군이 화선을 밀어 넣는다.', '유비군이 강안의 감시선을 붙든다.', '조조의 대군이 장강 북쪽으로 물러난다.'] + }, + rewards: [ + { label: '대승: 적벽 화공', tone: 'reward' }, + { label: '동맹 신뢰: 손유 연합', tone: 'bond' }, + { label: '다음: 형주 남부 확보', tone: 'next' } + ] + }, + 'thirty-third-victory-chengdu-yields': { + kind: 'victory', + title: '칼보다 먼저 열린 성도', + subtitle: '보전한 마을과 창고가 항복 권고의 가장 강한 증거가 됩니다.', + actors: [ + { unitId: 'liu-bei', x: 0.3, y: 0.62, direction: 'east', label: '보전을 약속한 유비' }, + { unitId: 'zhuge-liang', x: 0.47, y: 0.48, direction: 'east', label: '항복 조건 정리' }, + { unitId: 'huang-quan', x: 0.7, y: 0.38, direction: 'west', label: '성도의 뜻 · 황권' } + ], + markers: [ + { x: 0.28, y: 0.68, label: '보전된 외곽 마을', side: 'ally' }, + { x: 0.47, y: 0.5, label: '남은 군량 창고', side: 'objective' }, + { x: 0.72, y: 0.32, label: '열리는 성도', side: 'objective' } + ], + briefing: { + title: '익주를 얻는 방식', + lines: ['약속한 군율을 끝까지 지킨다.', '장수의 체면과 백성의 삶을 함께 보전한다.', '떠돌던 군대가 지킬 땅과 책임을 얻는다.'] + }, + rewards: [ + { label: '거점 확보: 성도 · 익주', tone: 'reward' }, + { label: '신규 무장: 황권', tone: 'bond' }, + { label: '다음: 한중 북문', tone: 'next' } + ] + }, + 'shu-han-foundation-oath': { + kind: 'muster', + title: '촉한의 이름 아래', + subtitle: '도원의 뜻이 사람과 땅을 지킬 나라의 약속으로 이어집니다.', + actors: [ + { unitId: 'liu-bei', x: 0.48, y: 0.38, direction: 'south', label: '한중왕 유비' }, + { unitId: 'zhuge-liang', x: 0.3, y: 0.62, direction: 'east', label: '나라의 기틀 · 제갈량' }, + { unitId: 'guan-yu', x: 0.68, y: 0.62, direction: 'west', label: '형주의 깃발 · 관우' } + ], + markers: [ + { x: 0.48, y: 0.38, label: '촉한의 대기', side: 'objective' }, + { x: 0.28, y: 0.66, label: '익주와 한중', side: 'ally' }, + { x: 0.7, y: 0.66, label: '형주 전선', side: 'ally' } + ], + briefing: { + title: '나라가 된 맹세', + lines: ['한실을 잇는 이름을 밝힌다.', '백성을 지키는 군율을 나라의 약속으로 세운다.', '각지의 장수에게 새 책임을 맡긴다.'] + }, + rewards: [ + { label: '국호: 촉한', tone: 'reward' }, + { label: '도원에서 이어진 맹세', tone: 'bond' }, + { label: '다음: 형주의 먹구름', tone: 'next' } + ] + }, + 'forty-fourth-sortie': { + kind: 'operation', + title: '맥성의 마지막 퇴로', + subtitle: '성보다 사람을 살리기 위해 북문과 갈대 물목을 함께 엽니다.', + actors: [ + { unitId: 'guan-yu', x: 0.63, y: 0.42, direction: 'west', label: '고립된 관우' }, + { unitId: 'ma-liang', x: 0.42, y: 0.58, direction: 'east', label: '퇴로의 전령 · 마량' }, + { unitId: 'liu-bei', x: 0.22, y: 0.7, direction: 'east', label: '익주의 구원 명령' } + ], + markers: [ + { x: 0.63, y: 0.42, label: '맥성 북문', side: 'ally' }, + { x: 0.48, y: 0.62, label: '갈대 물목', side: 'objective' }, + { x: 0.74, y: 0.28, label: '강동 포위망', side: 'enemy' }, + { x: 0.24, y: 0.72, label: '익주 귀환로', side: 'objective' } + ], + briefing: { + title: '남은 깃발을 살려라', + lines: ['북문 밖의 좁은 길을 먼저 연다.', '갈대 물목으로 흩어진 병사를 모은다.', '전공보다 생존과 귀환을 우선한다.'] + }, + rewards: [ + { label: '핵심 목표: 맥성 퇴로', tone: 'reward' }, + { label: '회수: 흩어진 형주군', tone: 'bond' }, + { label: '다음: 형주 상실의 비보', tone: 'next' } + ] + }, + 'baidi-entrustment-liu-bei': { + kind: 'muster', + title: '백제성의 유탁', + subtitle: '유비가 남은 나라와 백성의 내일을 제갈량에게 맡깁니다.', + actors: [ + { unitId: 'liu-bei', x: 0.35, y: 0.5, direction: 'east', label: '마지막 뜻 · 유비' }, + { unitId: 'zhuge-liang', x: 0.58, y: 0.48, direction: 'west', label: '뜻을 받든 제갈량' }, + { unitId: 'zhao-yun', x: 0.7, y: 0.68, direction: 'west', label: '남은 장수의 군례' } + ], + markers: [ + { x: 0.34, y: 0.5, label: '불탄 황제의 깃발', side: 'ally' }, + { x: 0.58, y: 0.48, label: '맡겨진 국정 장부', side: 'objective' }, + { x: 0.72, y: 0.7, label: '다시 모인 장수들', side: 'ally' } + ], + briefing: { + title: '분노 뒤에 남은 책임', + lines: ['이릉 패전의 상처를 숨기지 않는다.', '제갈량을 중심으로 조정과 군영을 다시 묶는다.', '유비의 뜻을 백성을 살리는 약속으로 잇는다.'] + }, + rewards: [ + { label: '계승: 촉한의 국정', tone: 'bond' }, + { label: '재편: 제갈량 중심 군영', tone: 'reward' }, + { label: '다음: 남중의 불안', tone: 'next' } + ] + }, + 'fifty-fourth-victory-meng-huo-yields': { + kind: 'victory', + title: '일곱 번 뒤의 진심', + subtitle: '힘으로 눌러 얻은 침묵이 아니라 지킨 약속으로 항복을 얻습니다.', + actors: [ + { unitId: 'zhuge-liang', x: 0.3, y: 0.56, direction: 'east', label: '일곱 번 놓아준 제갈량' }, + { unitId: 'meng-huo', x: 0.68, y: 0.46, direction: 'west', label: '뜻을 돌린 맹획' }, + { unitId: 'ma-liang', x: 0.45, y: 0.7, direction: 'east', label: '약속을 적은 마량' } + ], + markers: [ + { x: 0.3, y: 0.58, label: '촉한군 회유진', side: 'ally' }, + { x: 0.48, y: 0.72, label: '촌장 약속 장부', side: 'objective' }, + { x: 0.69, y: 0.44, label: '맹획의 항복', side: 'objective' } + ], + briefing: { + title: '칠종칠금의 결말', + lines: ['일곱 번의 생포에서도 마을을 짓밟지 않는다.', '맹획의 체면과 남중의 자치를 함께 남긴다.', '남쪽의 신뢰를 북벌의 든든한 뒤로 삼는다.'] + }, + rewards: [ + { label: '귀순: 맹획과 남중', tone: 'reward' }, + { label: '신뢰: 일곱 번 지킨 약속', tone: 'bond' }, + { label: '다음: 북벌 준비', tone: 'next' } + ] + }, + 'ending-jiang-wei-inherits-map': { + kind: 'muster', + title: '오장원에서 이어진 지도', + subtitle: '꺼진 등불 뒤에도 장부와 귀환로, 다음 세대의 맹세가 남습니다.', + actors: [ + { unitId: 'zhuge-liang', x: 0.35, y: 0.4, direction: 'east', label: '남겨진 승상의 뜻' }, + { unitId: 'jiang-wei', x: 0.58, y: 0.46, direction: 'west', label: '지도를 받은 강유' }, + { unitId: 'wang-ping', x: 0.7, y: 0.68, direction: 'west', label: '귀로를 지킨 왕평' } + ], + markers: [ + { x: 0.34, y: 0.4, label: '불 꺼진 본영', side: 'ally' }, + { x: 0.58, y: 0.48, label: '이어 받은 지도', side: 'objective' }, + { x: 0.72, y: 0.7, label: '한중 귀환로', side: 'ally' } + ], + briefing: { + title: '끝나지 않은 뜻', + lines: ['병사와 장부를 무사히 한중으로 돌려보낸다.', '승리와 실패를 다음 세대가 읽을 기록으로 남긴다.', '도원의 뜻을 한 사람 아닌 촉한의 기억으로 잇는다.'] + }, + rewards: [ + { label: '계승: 강유의 북벌 지도', tone: 'bond' }, + { label: '보전: 오장원 작전 장부', tone: 'reward' }, + { label: '후일담: 촉한의 뜻', tone: 'next' } + ] + } +}; + export function storyCutsceneActorProfileFor(unitId: string) { return storyCutsceneActorProfiles[unitId]; } +export function lateCampaignStoryCutsceneForPageId(pageId: string) { + return lateCampaignStoryCutscenes[pageId]; +} + export function storyCutsceneActorTextureKeys(cutscene?: StoryCutscene) { return Array.from( new Set( diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index f460e71..fb429ba 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -149,6 +149,8 @@ import { import { normalizeBattleSaveSlot, type BattleSaveCoreResonanceStats, + type BattleSaveEventPriority, + type BattleSavePendingEvent, type BattleSaveState, type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, @@ -1479,6 +1481,47 @@ type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'load-confirm' | 'save-overwrite-confirm'; +const battleEventPriorityRank: Record = { + critical: 3, + high: 2, + normal: 1, + low: 0 +}; + +type TacticalEventReactionSnapshot = { + eventKey: BattleTacticalGuideEventKey; + unitId: string; + unitName: string; + line: string; +}; + +const tacticalEventReactionCandidates: Partial>> = { + 'ally-danger': [ + { unitId: 'zhao-yun', line: '전열을 갈라 구하겠습니다. 다친 이를 먼저 뒤로 물리십시오.' }, + { unitId: 'liu-bei', line: '한 사람도 버리지 않겠다. 다친 전우 곁으로 전열을 모아라.' } + ], + 'leader-wavering': [ + { unitId: 'huang-zhong', line: '적장의 호흡이 흐트러졌소. 지금이면 단숨에 기세를 꺾을 수 있소.' }, + { unitId: 'guan-yu', line: '적장의 뜻이 흔들렸다. 전열을 가지런히 해 끝을 보자.' } + ], + 'cavalry-approach': [ + { unitId: 'zhao-yun', line: '기병의 선두를 제가 끊겠습니다. 보병은 지형을 등지고 서십시오.' }, + { unitId: 'zhang-fei', line: '말발굽이 아무리 빨라도 내 앞을 지나가진 못한다!' } + ], + 'enemy-posture': [ + { unitId: 'fa-zheng', line: '저 움직임은 유인입니다. 빈 곳을 쫓지 말고 지휘선을 먼저 끊으십시오.' }, + { unitId: 'zhuge-liang', line: '적의 움직임에는 반드시 빈틈이 생깁니다. 한 걸음 늦춰 그 틈을 기다리십시오.' }, + { unitId: 'pang-tong', line: '진형은 그럴듯하나 서로 기대지 못하는군요. 한쪽을 흔들면 연달아 무너질 겁니다.' } + ], + 'vanguard-broken': [ + { unitId: 'zhang-fei', line: '앞줄이 무너졌다! 숨 돌릴 틈을 주지 말고 밀어붙이자!' }, + { unitId: 'guan-yu', line: '선봉이 꺾였으니 성급히 흩어지지 말고 본진을 압박하자.' } + ] +}; + const sideQuickTabDefinitions: ReadonlyArray<{ id: SideQuickTabId; label: string; @@ -3684,6 +3727,12 @@ export class BattleScene extends Phaser.Scene { private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private battleEventObjects: Phaser.GameObjects.GameObject[] = []; + private battleEventQueue: BattleSavePendingEvent[] = []; + private activeBattleEvent?: BattleSavePendingEvent; + private battleEventDismissTween?: Phaser.Tweens.Tween; + private battleEventPresentationCount = 0; + private deferBattleEventPresentation = false; + private tacticalEventReactionHistory: TacticalEventReactionSnapshot[] = []; private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private turnPromptMode?: TurnPromptMode; private turnPromptPrimaryAction?: () => void; @@ -3957,6 +4006,7 @@ export class BattleScene extends Phaser.Scene { this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); this.clearBattleEnvironment(); + this.clearBattleEvents(); this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); this.mapResultPopupObjects = []; this.hideBattleSoundCaption(); @@ -3996,7 +4046,10 @@ export class BattleScene extends Phaser.Scene { this.battleStats = this.createBattleStats(); this.applyDebugTacticalInitiativeSetup(); this.enemyUsableUseKeys.clear(); + this.clearBattleEvents(); this.triggeredBattleEvents.clear(); + this.battleEventPresentationCount = 0; + this.tacticalEventReactionHistory = []; this.battleLog = []; this.actedUnitIds.clear(); this.attackIntents = []; @@ -8317,7 +8370,7 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); this.hideMapMenu(); this.hideTurnEndPrompt(); - this.hideBattleEventBanner(); + this.clearBattleEvents(); const center = this.deploymentCameraCenter(); this.centerCameraOnTile(center.x, center.y); this.renderDeploymentMap(); @@ -13116,12 +13169,16 @@ export class BattleScene extends Phaser.Scene { } const leader = battleUnits.find((unit) => unit.id === leaderUnitId); - if (leader && leader.hp <= 0) { - return 'victory'; - } - const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); - if (!enemiesAlive) { + if ((leader && leader.hp <= 0) || !enemiesAlive) { + const unmetVictoryObjectives = this.requiredVictoryObjectiveStates().filter((objective) => !objective.achieved); + if (unmetVictoryObjectives.length > 0) { + this.triggerBattleEvent('victory-gate-pending', '작전 목표가 남아 있습니다', [ + `남은 필수 목표 · ${unmetVictoryObjectives.map((objective) => objective.label).join(' · ')}`, + '적장을 쓰러뜨렸지만 약속한 접선과 진입로를 확보해야 전투를 마칠 수 있습니다.' + ], { priority: 'critical' }); + return undefined; + } return 'victory'; } @@ -13149,7 +13206,7 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; this.hideBattleAlert(); - this.hideBattleEventBanner(); + this.clearBattleEvents(); this.refreshUnitLegibilityStyles(); this.checkObjectiveOutcomeEvents(outcome); this.updateObjectiveTracker(); @@ -13526,7 +13583,7 @@ export class BattleScene extends Phaser.Scene { if (objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader') { return 'primary'; } - if (objective.kind === 'keep-unit-alive') { + if (objective.kind === 'keep-unit-alive' || objective.requiredForVictory) { return 'required'; } return 'bonus'; @@ -13561,6 +13618,12 @@ export class BattleScene extends Phaser.Scene { return battleScenario.objectives.map((objective) => this.objectiveState(objective, outcome)); } + private requiredVictoryObjectiveStates() { + return battleScenario.objectives + .filter((objective) => objective.requiredForVictory) + .map((objective) => this.objectiveState(objective)); + } + private objectiveState(objective: BattleObjectiveDefinition, outcome?: BattleOutcome): BattleObjectiveState { const category = this.objectiveCategory(objective); @@ -13605,7 +13668,7 @@ 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 || !enemiesAlive) && threateningEnemies.length === 0; + const secured = (allyHolding || (!objective.requiredForVictory && !enemiesAlive)) && threateningEnemies.length === 0; const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; const summary = secured ? '확보' : allyHolding ? '교전 중' : '미확보'; @@ -17485,41 +17548,140 @@ export class BattleScene extends Phaser.Scene { ? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택'] : []), `작전 목표 · ${objectiveLines[0]}` - ]); + ], { priority: 'critical' }); return; } - this.triggerBattleEvent('opening', '작전 목표', objectiveLines); + this.triggerBattleEvent('opening', '작전 목표', objectiveLines, { priority: 'critical' }); } private triggerTacticalEvent(key: BattleTacticalGuideEventKey, fallbackTitle: string, fallbackLines: string[]) { + if (this.isBattleEventKnown(key) || this.battleOutcome) { + return; + } const event = battleScenario.tacticalGuide?.events?.[key]; - this.triggerBattleEvent(key, event?.title ?? fallbackTitle, event?.lines ?? fallbackLines); + const reaction = this.tacticalEventReaction(key); + const lines = [ + ...(event?.lines ?? fallbackLines), + ...(reaction ? [reaction.line] : []) + ]; + if (reaction) { + this.tacticalEventReactionHistory.push(reaction); + this.tacticalEventReactionHistory = this.tacticalEventReactionHistory.slice(-12); + } + const priority: BattleSaveEventPriority = key === 'ally-danger' + ? 'critical' + : key === 'leader-wavering' || key === 'vanguard-broken' + ? 'high' + : 'normal'; + this.triggerBattleEvent(key, event?.title ?? fallbackTitle, lines, { priority }); } - private triggerBattleEvent(key: string, title: string, lines: string[], options: { playCue?: boolean } = {}) { - if (this.triggeredBattleEvents.has(key) || this.battleOutcome) { + private tacticalEventReaction(key: BattleTacticalGuideEventKey): TacticalEventReactionSnapshot | undefined { + const candidate = tacticalEventReactionCandidates[key]?.find(({ unitId }) => ( + battleUnits.some((unit) => unit.id === unitId && unit.faction === 'ally' && unit.hp > 0) + )); + if (!candidate) { + return undefined; + } + + const unit = battleUnits.find((entry) => entry.id === candidate.unitId && entry.faction === 'ally' && entry.hp > 0); + if (!unit) { + return undefined; + } + return { + eventKey: key, + unitId: unit.id, + unitName: unit.name, + line: `${unit.name} · ${candidate.line}` + }; + } + + private triggerBattleEvent( + key: string, + title: string, + lines: string[], + options: { playCue?: boolean; priority?: BattleSaveEventPriority } = {} + ) { + if (this.isBattleEventKnown(key) || this.battleOutcome) { return; } - this.triggeredBattleEvents.add(key); - if (options.playCue !== false) { + const event: BattleSavePendingEvent = { + key, + title, + lines: [...lines], + priority: options.priority ?? 'normal', + playCue: options.playCue !== false + }; + const priority = battleEventPriorityRank[event.priority]; + const insertionIndex = this.battleEventQueue.findIndex( + (queuedEvent) => battleEventPriorityRank[queuedEvent.priority] < priority + ); + if (insertionIndex < 0) { + this.battleEventQueue.push(event); + } else { + this.battleEventQueue.splice(insertionIndex, 0, event); + } + if (!this.deferBattleEventPresentation) { + this.showNextBattleEvent(); + } + } + + private isBattleEventKnown(key: string) { + return ( + this.triggeredBattleEvents.has(key) || + this.activeBattleEvent?.key === key || + this.battleEventQueue.some((event) => event.key === key) + ); + } + + private showNextBattleEvent() { + if (this.battleOutcome || this.activeBattleEvent || this.battleEventObjects.length > 0) { + return; + } + + const event = this.battleEventQueue.shift(); + if (!event) { + return; + } + if (this.triggeredBattleEvents.has(event.key)) { + this.showNextBattleEvent(); + return; + } + + const firstPresentation = event.presented !== true; + event.presented = true; + this.activeBattleEvent = event; + this.battleEventPresentationCount += 1; + if (event.playCue && firstPresentation) { soundDirector.playOperationAlert(); } - const message = `${title}\n${lines.join('\n')}`; - this.pushBattleLog(message); + const message = `${event.title}\n${event.lines.join('\n')}`; + if (firstPresentation) { + this.pushBattleLog(message); + } + const groupedEvents = this.battleEventQueue.filter((queuedEvent) => !this.triggeredBattleEvents.has(queuedEvent.key)); + groupedEvents.forEach((queuedEvent) => { + if (queuedEvent.presented !== true) { + queuedEvent.presented = true; + this.pushBattleLog(`${queuedEvent.title}\n${queuedEvent.lines.join('\n')}`); + } + }); this.renderSituationPanel(message); - this.showBattleEventBanner(title, lines); + this.showBattleEventBanner(groupedEvents.length > 0 + ? { ...event, title: `${event.title} · 외 ${groupedEvents.length}건` } + : event); } - private showBattleEventBanner(title: string, lines: string[]) { - this.hideBattleEventBanner(); + private showBattleEventBanner(event: BattleSavePendingEvent) { + const { title, lines } = event; if (title.startsWith('출진 군세')) { - this.showSortieDoctrineBanner(title, lines); + this.showSortieDoctrineBanner(event); return; } - const maxBodyLines = 2; + const maxBodyLines = 3; const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42)); const width = 540; const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126); @@ -17559,25 +17721,63 @@ export class BattleScene extends Phaser.Scene { this.battleEventObjects.push(body); const eventObjects = [...this.battleEventObjects]; - this.tweens.add({ + this.battleEventDismissTween = this.tweens.add({ targets: eventObjects, alpha: 0, delay: 2600, duration: 420, ease: 'Sine.easeIn', - onComplete: () => { - eventObjects.forEach((object) => object.destroy()); - this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); - } + onComplete: () => this.completeActiveBattleEvent(event.key, eventObjects) }); } private hideBattleEventBanner() { - this.battleEventObjects.forEach((object) => object.destroy()); + this.completeActiveBattleEvent(this.activeBattleEvent?.key, [...this.battleEventObjects]); + } + + private completeActiveBattleEvent(key: string | undefined, eventObjects: Phaser.GameObjects.GameObject[]) { + if (key && this.activeBattleEvent?.key !== key) { + return; + } + + this.battleEventDismissTween?.remove(); + this.battleEventDismissTween = undefined; + eventObjects.forEach((object) => object.active && object.destroy()); + this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); + const completedEvent = this.activeBattleEvent; + this.activeBattleEvent = undefined; + if (completedEvent) { + this.triggeredBattleEvents.add(completedEvent.key); + } + this.battleEventQueue.splice(0).forEach((groupedEvent) => { + if (groupedEvent.presented !== true) { + this.pushBattleLog(`${groupedEvent.title}\n${groupedEvent.lines.join('\n')}`); + } + this.triggeredBattleEvents.add(groupedEvent.key); + }); + } + + private clearBattleEvents() { + this.battleEventDismissTween?.remove(); + this.battleEventDismissTween = undefined; + this.battleEventObjects.forEach((object) => object.active && object.destroy()); this.battleEventObjects = []; + this.activeBattleEvent = undefined; + this.battleEventQueue = []; + this.deferBattleEventPresentation = false; } private checkBattleEvents() { + this.deferBattleEventPresentation = true; + try { + this.collectBattleEvents(); + } finally { + this.deferBattleEventPresentation = false; + } + this.showNextBattleEvent(); + } + + private collectBattleEvents() { const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) { this.triggerTacticalEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); @@ -17627,7 +17827,8 @@ export class BattleScene extends Phaser.Scene { }); } - private showSortieDoctrineBanner(title: string, lines: string[]) { + private showSortieDoctrineBanner(event: BattleSavePendingEvent) { + const { title, lines } = event; const doctrineLines = lines.filter((line) => ( line.startsWith('도원 공명') || line.startsWith('공명 ') || @@ -17767,16 +17968,13 @@ export class BattleScene extends Phaser.Scene { this.battleEventObjects.push(objectiveText); const eventObjects = [...this.battleEventObjects]; - this.tweens.add({ + this.battleEventDismissTween = this.tweens.add({ targets: eventObjects, alpha: 0, delay: 3200, duration: 420, ease: 'Sine.easeIn', - onComplete: () => { - eventObjects.forEach((object) => object.destroy()); - this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); - } + onComplete: () => this.completeActiveBattleEvent(event.key, eventObjects) }); } @@ -17806,7 +18004,7 @@ export class BattleScene extends Phaser.Scene { `${terrainLabel} 주변에 적이 버티고 있습니다.`, guardCount > 0 ? `주변 방어 병력 ${guardCount}명을 몰아내면 보급 보상이 붙습니다.` : '주변을 정리하면 승리 보상이 추가됩니다.' ]; - this.triggerBattleEvent(this.objectiveEventKey(objective.id, 'approach'), title, lines); + this.triggerBattleEvent(this.objectiveEventKey(objective.id, 'approach'), title, lines, { priority: 'low' }); } private triggerObjectiveAchievedFeedback(objective: BattleObjectiveDefinition, state: BattleObjectiveState) { @@ -18627,7 +18825,17 @@ export class BattleScene extends Phaser.Scene { } private validTriggeredBattleEventIds() { - const eventIds = new Set(['opening']); + const eventIds = new Set(['opening', 'first-engagement', 'victory-gate-pending']); + const tacticalEventIds: BattleTacticalGuideEventKey[] = [ + 'leader-wavering', + 'ally-danger', + 'village-approach', + 'village-secured', + 'vanguard-broken', + 'cavalry-approach', + 'enemy-posture' + ]; + tacticalEventIds.forEach((eventId) => eventIds.add(eventId)); Object.keys(battleScenario.tacticalGuide?.events ?? {}).forEach((eventId) => eventIds.add(eventId)); battleScenario.objectives.forEach((objective) => { eventIds.add(this.objectiveEventKey(objective.id, 'approach')); @@ -18745,11 +18953,25 @@ export class BattleScene extends Phaser.Scene { sortieOrderCommandUnlocked: this.sortieOrderCommandUnlocked, sortieOrderCommandUnlockTurn: this.sortieOrderCommandUnlockTurn, enemyUsableUseKeys: Array.from(this.enemyUsableUseKeys), - triggeredBattleEvents: Array.from(this.triggeredBattleEvents) + triggeredBattleEvents: Array.from(this.triggeredBattleEvents), + pendingBattleEvents: this.pendingBattleEventsForSave() }; } + private pendingBattleEventsForSave() { + const events = [ + ...(this.activeBattleEvent ? [this.activeBattleEvent] : []), + ...this.battleEventQueue + ].filter((event) => !this.triggeredBattleEvents.has(event.key)); + return events.length > 0 + ? events.map((event) => ({ ...event, lines: [...event.lines] })) + : undefined; + } + private applyBattleSaveState(state: BattleSaveState) { + this.clearBattleEvents(); + this.battleEventPresentationCount = 0; + this.tacticalEventReactionHistory = []; this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); @@ -18805,6 +19027,9 @@ export class BattleScene extends Phaser.Scene { : undefined; this.enemyUsableUseKeys = new Set(state.enemyUsableUseKeys ?? []); this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); + this.battleEventQueue = (state.pendingBattleEvents ?? []) + .filter((event) => !this.triggeredBattleEvents.has(event.key)) + .map((event) => ({ ...event, lines: [...event.lines] })); state.units.forEach((savedUnit) => { const unit = battleUnits.find((candidate) => candidate.id === savedUnit.id); @@ -18851,6 +19076,7 @@ export class BattleScene extends Phaser.Scene { this.renderSortieOrderHud(); this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); this.refreshEnemyIntentForecast(); + this.time.delayedCall(0, () => this.showNextBattleEvent()); } private restoreUnitView(unit: UnitData, direction: UnitDirection = 'south') { @@ -23022,7 +23248,7 @@ 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); + this.playMovementSound(unit, movementDuration, movementDistance, battleMap.terrain[y]?.[x]); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], @@ -23103,9 +23329,9 @@ export class BattleScene extends Phaser.Scene { this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); - const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size; + const eventPresentationCountBeforeTurnCheck = this.battleEventPresentationCount; this.checkBattleEvents(); - const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck; + const operationCuePlayed = this.battleEventPresentationCount !== eventPresentationCountBeforeTurnCheck; this.playTurnStartFeedbackCue('ally', statusResult, recoveryResult, operationCuePlayed); this.centerCameraOnAllyLine(); const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'] @@ -24046,7 +24272,7 @@ 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); + this.playMovementSound(unit, movementDuration, movementDistance, battleMap.terrain[y]?.[x]); this.playUnitWalk(view, direction); this.tweens.add({ targets: [view.sprite, view.hitZone], @@ -24171,19 +24397,21 @@ export class BattleScene extends Phaser.Scene { return this.scaledBattleDuration(duration, 110); } - private playMovementSound(unit: UnitData, duration: number, tileDistance = 1) { + private playMovementSound(unit: UnitData, duration: number, tileDistance = 1, terrain?: TerrainType) { const isMounted = this.isMountedUnit(unit); - const pulseCount = isMounted + const desiredPulseCount = isMounted ? Math.max(3, Math.min(10, tileDistance * 2 + 1)) : Math.max(2, Math.min(8, tileDistance + 1)); - const interval = Math.max(isMounted ? 72 : 108, duration / Math.max(1, pulseCount - 0.25)); + const minInterval = isMounted ? 125 : 180; + const pulseCount = Math.min(desiredPulseCount, Math.max(1, Math.floor(duration / minInterval) + 1)); + const interval = pulseCount > 1 ? duration / (pulseCount - 1) : 0; for (let index = 0; index < pulseCount; index += 1) { this.time.delayedCall(Math.round(index * interval), () => { soundDirector.playMovementStep(isMounted, index, { + terrain, volume: isMounted ? 0.24 : 0.18, - rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12, - stopAfterMs: Math.round(Math.min(isMounted ? 210 : 260, interval + 80)) + rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12 }); }); } @@ -28607,6 +28835,9 @@ export class BattleScene extends Phaser.Scene { objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })), battleStats: this.serializeBattleStats(), triggeredBattleEvents: Array.from(this.triggeredBattleEvents), + activeBattleEvent: this.activeBattleEvent ? { ...this.activeBattleEvent, lines: [...this.activeBattleEvent.lines] } : null, + pendingBattleEvents: this.battleEventQueue.map((event) => ({ ...event, lines: [...event.lines] })), + tacticalEventReactions: this.tacticalEventReactionHistory.map((reaction) => ({ ...reaction })), selectedUsable: this.selectedUsable?.id ?? null, audio: soundDirector.getDebugState(), itemStocks: this.serializeItemStocks(), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index e42f4b2..518abaa 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -981,15 +981,24 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ nextHints: ['형주 방위', '익주 진입', '성도 항복'] }, { - id: 'shu-han', + id: 'hanzhong-shu-han', title: '촉한 건국', - period: '한실을 잇는 깃발', - description: '여러 전장을 지나 촉한을 세우고 북벌을 바라보는 장기 목표입니다.', + period: '한중에서 나라로', + description: '가맹관에서 한중 결전까지 전선을 밀어 올리고, 유랑군의 깃발을 촉한의 국호로 세우는 장입니다.', battleIds: [ 'thirty-fourth-battle-jiameng-pass', 'thirty-fifth-battle-yangping-scout', 'thirty-sixth-battle-dingjun-vanguard', - 'thirty-seventh-battle-hanzhong-decisive', + 'thirty-seventh-battle-hanzhong-decisive' + ], + nextHints: ['가맹관 돌파', '정군산 결전', '한중왕 선언', '촉한 건국'] + }, + { + id: 'jingzhou-yiling', + title: '형주 붕괴와 이릉', + period: '잃은 땅과 남은 뜻', + description: '번성의 공세에서 형주 후방의 붕괴, 맥성의 고립과 이릉의 화공까지 이어지는 비극의 장입니다.', + battleIds: [ 'thirty-eighth-battle-jing-defense', 'thirty-ninth-battle-fan-castle-vanguard', 'fortieth-battle-han-river-flood', @@ -998,7 +1007,16 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ 'forty-third-battle-gongan-collapse', 'forty-fourth-battle-maicheng-isolation', 'forty-fifth-battle-yiling-vanguard', - 'forty-sixth-battle-yiling-fire', + 'forty-sixth-battle-yiling-fire' + ], + nextHints: ['번성 포위', '형주 후방 위기', '맥성 고립', '이릉 화공', '백제성의 유언'] + }, + { + id: 'nanzhong', + title: '남중 평정', + period: '칠종칠금', + description: '힘으로 꺾는 데서 멈추지 않고, 맹획의 마음과 남중의 신뢰를 얻어 북벌의 후방을 다지는 장입니다.', + battleIds: [ 'forty-seventh-battle-nanzhong-stabilization', 'forty-eighth-battle-meng-huo-main-force', 'forty-ninth-battle-meng-huo-second-capture', @@ -1008,7 +1026,7 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ 'fifty-third-battle-meng-huo-sixth-capture', 'fifty-fourth-battle-meng-huo-final-capture' ], - nextHints: ['왕업 선언', '번성 포위', '형주 위기', '이릉 화공', '남중 안정', '맹획 회유', '칠종칠금'] + nextHints: ['남중 안정', '맹획 회유', '칠종칠금', '북벌 후방 확보'] }, { id: 'northern-campaign', @@ -21974,22 +21992,26 @@ export class CampScene extends Phaser.Scene { const completedIds = this.completedBattleIds(); const listX = x + 24; const listY = y + 142; + const compactChapterList = campaignTimelineChapters.length > 9; + const chapterRowGap = compactChapterList ? 22 : 31; + const chapterRowHeight = compactChapterList ? 20 : 27; this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true))); campaignTimelineChapters.forEach((chapter, index) => { const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds); - const rowY = listY + 30 + index * 31; + const rowY = listY + 30 + index * chapterRowGap; const active = index === progress.activeChapterIndex; const complete = status === '완료'; - const row = this.track(this.add.rectangle(listX, rowY, 360, 27, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84)); + const row = this.track(this.add.rectangle(listX, rowY, 360, chapterRowHeight, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84)); row.setOrigin(0); row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34); const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf'; - this.track(this.add.text(listX + 12, rowY + 5, status, this.textStyle(11, markerColor, true))); - this.track(this.add.text(listX + 64, rowY + 4, chapter.title, this.textStyle(13, active ? '#f2e3bf' : '#d4dce6', active))); + const textY = rowY + (compactChapterList ? 2 : 5); + this.track(this.add.text(listX + 12, textY, status, this.textStyle(compactChapterList ? 10 : 11, markerColor, true))); + this.track(this.add.text(listX + 64, rowY + (compactChapterList ? 1 : 4), chapter.title, this.textStyle(compactChapterList ? 11 : 13, active ? '#f2e3bf' : '#d4dce6', active))); const countText = chapter.battleIds.length > 0 ? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}` : '예정'; - const count = this.track(this.add.text(listX + 342, rowY + 5, countText, this.textStyle(11, markerColor, true))); + const count = this.track(this.add.text(listX + 342, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true))); count.setOrigin(1, 0); }); @@ -22037,7 +22059,29 @@ export class CampScene extends Phaser.Scene { this.drawBar(x + 18, y + 162, width - 36, 8, ratio, status === '완료' ? palette.green : palette.gold); if (chapter.battleIds.length > 0) { - chapter.battleIds.forEach((battleId, index) => { + const visibleBattleLimit = 4; + const firstPendingBattleIndex = chapter.battleIds.findIndex((battleId) => !completedIds.has(battleId)); + const focusBattleIndex = firstPendingBattleIndex >= 0 ? firstPendingBattleIndex : chapter.battleIds.length - 1; + const visibleBattleStart = Phaser.Math.Clamp( + focusBattleIndex - 1, + 0, + Math.max(0, chapter.battleIds.length - visibleBattleLimit) + ); + const visibleBattleIds = chapter.battleIds.slice(visibleBattleStart, visibleBattleStart + visibleBattleLimit); + if (chapter.battleIds.length > visibleBattleIds.length) { + const range = this.track(this.add.text( + x + width - 18, + y + 174, + `전장 ${visibleBattleStart + 1}–${visibleBattleStart + visibleBattleIds.length} 표시`, + this.textStyle(10, '#9fb0bf', true) + )); + range.setOrigin(1, 0); + } + const battleTitleX = x + 78; + const battleTitleWidth = 116; + const objectiveX = battleTitleX + battleTitleWidth + 10; + const objectiveWidth = x + width - 30 - objectiveX; + visibleBattleIds.forEach((battleId, index) => { const battle = getBattleScenario(battleId); const complete = completedIds.has(battleId); const rowY = y + 190 + index * 28; @@ -22045,9 +22089,27 @@ export class CampScene extends Phaser.Scene { row.setOrigin(0); row.setStrokeStyle(1, complete ? palette.green : palette.blue, complete ? 0.48 : 0.32); this.track(this.add.text(x + 30, rowY + 4, complete ? '완료' : '대기', this.textStyle(10, complete ? '#a8ffd0' : '#9fb0bf', true))); - this.track(this.add.text(x + 78, rowY + 3, battle.title, this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete))); - const objective = this.track(this.add.text(x + width - 30, rowY + 4, battle.victoryConditionLabel, this.textStyle(10, '#9fb0bf', true))); - objective.setOrigin(1, 0); + const battleTitle = this.track(this.add.text( + battleTitleX, + rowY + 3, + this.compactText(battle.title, 10), + { + ...this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete), + fixedWidth: battleTitleWidth + } + )); + battleTitle.setData('timelineField', 'battle-title'); + const objective = this.track(this.add.text( + objectiveX, + rowY + 4, + this.compactText(battle.victoryConditionLabel, 15), + { + ...this.textStyle(10, '#9fb0bf', true), + align: 'right', + fixedWidth: objectiveWidth + } + )); + objective.setData('timelineField', 'victory-condition'); }); return; } @@ -22310,10 +22372,23 @@ export class CampScene extends Phaser.Scene { ); if (completed) { - const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96)); - done.setStrokeStyle(1, palette.green, 0.76); - const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true))); - text.setOrigin(0.5); + const chosen = this.recordedDialogueChoice(dialogue); + if (chosen) { + const memory = this.track(this.add.rectangle(x + 18, y + height - 94, width - 36, 76, 0x17231d, 0.96)); + memory.setOrigin(0); + memory.setStrokeStyle(1, palette.green, 0.76); + this.track(this.add.text(x + 32, y + height - 84, `나의 결정 · ${chosen.label}`, this.textStyle(13, '#a8ffd0', true))); + this.track(this.add.text(x + 32, y + height - 58, `남은 말 · ${this.compactText(chosen.response, 54)}`, { + ...this.textStyle(12, '#d4dce6'), + wordWrap: { width: width - 64, useAdvancedWrap: true }, + lineSpacing: 2 + })); + } else { + const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 250, 34, 0x17231d, 0.96)); + done.setStrokeStyle(1, palette.green, 0.76); + const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료 · 과거 선택 기록 없음', this.textStyle(13, '#a8ffd0', true))); + text.setOrigin(0.5); + } return; } @@ -22418,10 +22493,23 @@ export class CampScene extends Phaser.Scene { ); if (completed) { - const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96)); - done.setStrokeStyle(1, palette.green, 0.76); - const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료', this.textStyle(15, '#a8ffd0', true))); - text.setOrigin(0.5); + const chosen = this.recordedVisitChoice(visit); + if (chosen) { + const memory = this.track(this.add.rectangle(x + 18, y + height - 94, width - 36, 76, 0x17231d, 0.96)); + memory.setOrigin(0); + memory.setStrokeStyle(1, palette.green, 0.76); + this.track(this.add.text(x + 32, y + height - 84, `나의 결정 · ${chosen.label}`, this.textStyle(13, '#a8ffd0', true))); + this.track(this.add.text(x + 32, y + height - 58, `남은 말 · ${this.compactText(chosen.response, 54)}`, { + ...this.textStyle(12, '#d4dce6'), + wordWrap: { width: width - 64, useAdvancedWrap: true }, + lineSpacing: 2 + })); + } else { + const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 250, 34, 0x17231d, 0.96)); + done.setStrokeStyle(1, palette.green, 0.76); + const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료 · 과거 선택 기록 없음', this.textStyle(13, '#a8ffd0', true))); + text.setOrigin(0.5); + } return; } @@ -23063,7 +23151,7 @@ export class CampScene extends Phaser.Scene { bondExp: choice.bondExp, gold: choice.gold, itemRewards: choice.itemRewards - }); + }, choice.id); if (updated) { this.campaign = updated; @@ -23095,7 +23183,7 @@ export class CampScene extends Phaser.Scene { } const rewardExp = dialogue.rewardExp + choice.rewardExp; - const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp); + const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id); if (updated) { this.report = updated; this.campaign = getCampaignState(); @@ -23501,6 +23589,16 @@ export class CampScene extends Phaser.Scene { return this.report?.completedCampVisits ?? []; } + private recordedDialogueChoice(dialogue: CampDialogue) { + const choiceId = this.campaign?.campDialogueChoiceIds[dialogue.id]; + return choiceId ? dialogue.choices.find((choice) => choice.id === choiceId) : undefined; + } + + private recordedVisitChoice(visit: CampVisitDefinition) { + const choiceId = this.campaign?.campVisitChoiceIds[visit.id]; + return choiceId ? visit.choices.find((choice) => choice.id === choiceId) : undefined; + } + private inventoryLabels() { const inventory = this.campaign?.inventory ?? {}; const entries = Object.entries(inventory).filter(([, amount]) => amount > 0); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index da953fc..c5cf323 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1503,7 +1503,7 @@ export class StoryScene extends Phaser.Scene { const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3); const cardX = stage.x + ui(28); const cardW = Math.max(ui(250), board.x - cardX - ui(24)); - const cardH = ui(actors.length >= 4 ? 60 : 70); + const cardH = ui(actors.length >= 3 ? 56 : 70); const firstY = stage.y + ui(86); actors.forEach((actor, index) => { diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index c0980ea..16be9a9 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -85,6 +85,17 @@ export type BattleSaveCoreResonanceStats = { additionalDamage: number; }; +export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; + +export type BattleSavePendingEvent = { + key: string; + title: string; + lines: string[]; + priority: BattleSaveEventPriority; + playCue: boolean; + presented?: boolean; +}; + export type BattleSaveState = { version: 1; battleId: string; @@ -111,6 +122,7 @@ export type BattleSaveState = { tacticalCommand?: BattleSaveTacticalCommandState; enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; + pendingBattleEvents?: BattleSavePendingEvent[]; }; export type BattleSaveValidationOptions = { @@ -135,6 +147,9 @@ const maxBattleLogEntries = 10; const maxBattleLogEntryLength = 96; const maxBattleBondEntries = 128; const maxTriggeredBattleEventLength = 96; +const maxPendingBattleEventTitleLength = 96; +const maxPendingBattleEventLineLength = 256; +const maxPendingBattleEventLines = 12; const defaultBattleSaveArrayLimit = 128; const defaultBattleItemStockLimit = 9; const maxBattleEffectLabelLength = 32; @@ -272,7 +287,8 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida options ) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || - !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) + !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) || + !isOptionalPendingBattleEventArray(state.pendingBattleEvents, state.triggeredBattleEvents, options) ) { return false; } @@ -922,6 +938,58 @@ function isOptionalTriggeredBattleEventArray(value: unknown, options: BattleSave ); } +function isOptionalPendingBattleEventArray( + value: unknown, + triggeredEvents: unknown, + options: BattleSaveValidationOptions +) { + if (value === undefined) { + return true; + } + if ( + !Array.isArray(value) || + value.length > triggeredBattleEventLimit(options) || + !hasUniqueRecordStrings(value, 'key') + ) { + return false; + } + + const presentedIndices = value + .map((event, index) => (isRecord(event) && event.presented === true ? index : -1)) + .filter((index) => index >= 0); + if (presentedIndices.length > 1 || (presentedIndices.length === 1 && presentedIndices[0] !== 0)) { + return false; + } + + const completedKeys = new Set(Array.isArray(triggeredEvents) ? triggeredEvents : []); + return value.every((event) => ( + isRecord(event) && + typeof event.key === 'string' && + event.key.length > 0 && + event.key.length <= maxTriggeredBattleEventLength && + !completedKeys.has(event.key) && + (!options.validTriggeredBattleEventIds || options.validTriggeredBattleEventIds.has(event.key)) && + typeof event.title === 'string' && + event.title.trim().length > 0 && + event.title.length <= maxPendingBattleEventTitleLength && + Array.isArray(event.lines) && + event.lines.length > 0 && + event.lines.length <= maxPendingBattleEventLines && + event.lines.every((line) => ( + typeof line === 'string' && + line.trim().length > 0 && + line.length <= maxPendingBattleEventLineLength + )) && + isBattleSaveEventPriority(event.priority) && + typeof event.playCue === 'boolean' && + (event.presented === undefined || typeof event.presented === 'boolean') + )); +} + +function isBattleSaveEventPriority(value: unknown): value is BattleSaveEventPriority { + return value === 'critical' || value === 'high' || value === 'normal' || value === 'low'; +} + function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) { if (typeof value !== 'string') { return false; diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 1d22179..1a0c65a 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -77,6 +77,8 @@ export const campaignVictoryRewardCategoryIds = [ export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number]; export type CampaignVictoryRewardAcknowledgements = Partial>; +export type CampaignCampChoiceHistory = Record; + const campaignVictoryRewardCategoryIdSet = new Set(campaignVictoryRewardCategoryIds); export type CampaignSortieUnitPerformanceSnapshot = { @@ -373,6 +375,7 @@ const campaignInventoryCaps: Record = { const maxCampaignStringListEntries = 128; const maxCampaignStringListLength = 96; +const maxCampaignCampHistoryEntries = 256; const maxCampaignUnknownInventoryAmount = 999; const maxCampaignRewardNoteLength = 180; const maxCampaignBattleObjectiveEntries = 24; @@ -485,6 +488,8 @@ export type CampaignState = { completedTutorialIds: CampaignTutorialId[]; completedCampDialogues: string[]; completedCampVisits: string[]; + campDialogueChoiceIds: CampaignCampChoiceHistory; + campVisitChoiceIds: CampaignCampChoiceHistory; acknowledgedVictoryRewardBattleIds: string[]; acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements; dismissedVictoryRewardNoticeBattleIds: string[]; @@ -704,6 +709,8 @@ export function createInitialCampaignState(): CampaignState { completedTutorialIds: [], completedCampDialogues: [], completedCampVisits: [], + campDialogueChoiceIds: {}, + campVisitChoiceIds: {}, acknowledgedVictoryRewardBattleIds: [], acknowledgedVictoryRewardCategories: {}, dismissedVictoryRewardNoticeBattleIds: [], @@ -918,10 +925,16 @@ export function setFirstBattleReport(report: FirstBattleReport) { delete reportClone.sortieRecommendation; } const previousSettlement = state.battleHistory[battleId]; - const completedCampDialogues = - battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues]; - const completedCampVisits = - battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits]; + const completedCampDialogues = mergeCampCompletionIds( + state.completedCampDialogues, + reportClone.completedCampDialogues, + Object.keys(state.campDialogueChoiceIds) + ); + const completedCampVisits = mergeCampCompletionIds( + state.completedCampVisits, + reportClone.completedCampVisits ?? [], + Object.keys(state.campVisitChoiceIds) + ); reportClone.completedCampDialogues = completedCampDialogues; reportClone.completedCampVisits = completedCampVisits; @@ -1313,14 +1326,27 @@ function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCat return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category)); } -export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { +export function applyCampBondExp(dialogueId: string, bondId: string, amount: number, choiceId?: string) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } - if (hasCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) { - if (syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) { + if ( + hasCampCompletionFlag( + state.completedCampDialogues, + state.firstBattleReport.completedCampDialogues, + state.campDialogueChoiceIds, + dialogueId + ) + ) { + const completionSynced = syncCompletionFlag( + state.completedCampDialogues, + state.firstBattleReport.completedCampDialogues, + dialogueId + ); + const choiceRemembered = rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId); + if (completionSynced || choiceRemembered) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } @@ -1334,6 +1360,7 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num } syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId); + rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId); advanceBond(campaignBond, amount); reportBond.level = campaignBond.level; reportBond.exp = campaignBond.exp; @@ -1346,15 +1373,29 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num export function applyCampVisitReward( visitId: string, - reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] } + reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }, + choiceId?: string ) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } - if (hasCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) { - if (syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) { + if ( + hasCampCompletionFlag( + state.completedCampVisits, + state.firstBattleReport.completedCampVisits, + state.campVisitChoiceIds, + visitId + ) + ) { + const completionSynced = syncCompletionFlag( + state.completedCampVisits, + state.firstBattleReport.completedCampVisits, + visitId + ); + const choiceRemembered = rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); + if (completionSynced || choiceRemembered) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } @@ -1362,6 +1403,7 @@ export function applyCampVisitReward( } syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId); + rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); if (reward.bondId && reward.bondExp && reward.bondExp > 0) { const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId); @@ -1389,8 +1431,31 @@ export function applyCampVisitReward( return cloneCampaignState(state); } -function hasCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) { - return stateFlags.includes(flagId) || reportFlags.includes(flagId); +function hasCampCompletionFlag( + stateFlags: string[], + reportFlags: string[], + choiceHistory: CampaignCampChoiceHistory, + flagId: string +) { + const normalizedFlagId = normalizeKeyString(flagId); + return ( + stateFlags.includes(flagId) || + reportFlags.includes(flagId) || + Boolean(normalizedFlagId && choiceHistory[normalizedFlagId]) + ); +} + +function rememberCampChoice(history: CampaignCampChoiceHistory, entryId: string, choiceId?: string) { + const normalizedEntryId = normalizeKeyString(entryId); + const normalizedChoiceId = normalizeKeyString(choiceId); + if (!normalizedEntryId || !normalizedChoiceId || history[normalizedEntryId]) { + return false; + } + if (Object.keys(history).length >= maxCampaignCampHistoryEntries) { + return false; + } + history[normalizedEntryId] = normalizedChoiceId; + return true; } function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) { @@ -1566,8 +1631,10 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.bonds = arrayOrEmpty(normalized.bonds) .map(normalizeCampBondSnapshot) .filter((bond): bond is CampBondSnapshot => Boolean(bond)); - normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues); - normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); + normalized.completedCampDialogues = uniqueCampHistoryIds(normalized.completedCampDialogues); + normalized.completedCampVisits = uniqueCampHistoryIds(normalized.completedCampVisits); + normalized.campDialogueChoiceIds = normalizeCampChoiceHistory(normalized.campDialogueChoiceIds); + normalized.campVisitChoiceIds = normalizeCampChoiceHistory(normalized.campVisitChoiceIds); normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds) .filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id)); normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds) @@ -1580,6 +1647,20 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.inventory = normalizeInventory(normalized.inventory); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); + normalized.completedCampDialogues = mergeCampCompletionIds( + normalized.completedCampDialogues, + normalized.firstBattleReport?.completedCampDialogues ?? [], + Object.keys(normalized.campDialogueChoiceIds) + ); + normalized.completedCampVisits = mergeCampCompletionIds( + normalized.completedCampVisits, + normalized.firstBattleReport?.completedCampVisits ?? [], + Object.keys(normalized.campVisitChoiceIds) + ); + if (normalized.firstBattleReport) { + normalized.firstBattleReport.completedCampDialogues = [...normalized.completedCampDialogues]; + normalized.firstBattleReport.completedCampVisits = [...normalized.completedCampVisits]; + } backfillEarlyJianYongRecruit(normalized); const rosterUnitIds = normalizedRosterUnitIds(normalized.roster); const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; @@ -1734,6 +1815,40 @@ function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): Campaig }, {}); } +function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory { + const history: CampaignCampChoiceHistory = {}; + let acceptedEntryCount = 0; + for (const [entryId, choiceId] of Object.entries(recordOrEmpty(value))) { + if (acceptedEntryCount >= maxCampaignCampHistoryEntries) { + break; + } + const normalizedEntryId = normalizeKeyString(entryId); + const normalizedChoiceId = normalizeKeyString(choiceId); + if (normalizedEntryId && normalizedChoiceId) { + if (!history[normalizedEntryId]) { + acceptedEntryCount += 1; + } + history[normalizedEntryId] = normalizedChoiceId; + } + } + return history; +} + +function uniqueCampHistoryIds(value: unknown): string[] { + return [ + ...new Set( + arrayOrEmpty(value) + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength) + ) + ].slice(0, maxCampaignCampHistoryEntries); +} + +function mergeCampCompletionIds(...completionIdGroups: string[][]) { + return uniqueCampHistoryIds(completionIdGroups.flat()); +} + function uniqueStrings(value: unknown): string[] { return [ ...new Set( @@ -2704,8 +2819,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), - completedCampDialogues: uniqueStrings(report.completedCampDialogues), - completedCampVisits: uniqueStrings(report.completedCampVisits), + completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), + completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() }; } @@ -3102,8 +3217,8 @@ function cloneCampaignState(state: CampaignState): CampaignState { function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards); - cloned.completedCampDialogues = uniqueStrings(cloned.completedCampDialogues); - cloned.completedCampVisits = uniqueStrings(cloned.completedCampVisits); + cloned.completedCampDialogues = uniqueCampHistoryIds(cloned.completedCampDialogues); + cloned.completedCampVisits = uniqueCampHistoryIds(cloned.completedCampVisits); if (cloned.campaignRewards) { cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId); }