From a9e401aedfee3a1d2a88b8de959e3ed039cf9789 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 23 Jul 2026 02:14:59 +0900 Subject: [PATCH] feat: align campaign audiovisual story flow --- package.json | 1 + scripts/qa-representative-battles.mjs | 66 ++- .../verify-battle-environment-profiles.mjs | 3 +- scripts/verify-campaign-flow-data.mjs | 64 +++ .../verify-campaign-presentation-profiles.mjs | 333 ++++++++++++ .../verify-campaign-save-normalization.mjs | 20 +- scripts/verify-flow.mjs | 57 +- scripts/verify-release-candidate.mjs | 127 ++++- scripts/verify-static-data.mjs | 1 + src/game/data/battles.ts | 2 +- src/game/data/campaignFlow.ts | 191 +++---- src/game/data/campaignPresentationProfiles.ts | 490 ++++++++++++++++++ src/game/data/scenario.ts | 40 +- src/game/scenes/BattleScene.ts | 96 ++-- src/game/scenes/CampScene.ts | 15 +- src/game/scenes/StoryScene.ts | 69 ++- src/game/scenes/TitleScene.ts | 29 +- src/game/state/campaignState.ts | 44 ++ 18 files changed, 1405 insertions(+), 243 deletions(-) create mode 100644 scripts/verify-campaign-presentation-profiles.mjs create mode 100644 src/game/data/campaignPresentationProfiles.ts diff --git a/package.json b/package.json index 0fc8f26..4c74c9a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:campaign-completion": "node scripts/verify-campaign-completion.mjs", + "verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs", "verify:growth-consistency": "node scripts/verify-growth-consistency.mjs", "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", "verify:battle-usables": "node scripts/verify-battle-usables.mjs", diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 413e0f4..1922e15 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1668,17 +1668,28 @@ async function verifyBattleEnvironment(page, battleId) { const environment = state?.environment; const expected = battleEnvironmentExpectations.get(battleId); const actualAmbienceKey = state?.audio?.currentAmbienceKey ?? state?.audio?.pendingAmbienceKey ?? null; + const actualMusicKey = state?.audio?.currentMusicKey ?? state?.audio?.pendingMusicKey ?? null; if (!expected) { if ( - environment?.profileId !== null || + !environment?.presentationArcId || + environment?.profileId !== `arc-${environment.presentationArcId}` || + environment?.effect !== 'arc-wash' || + environment?.musicKey !== actualMusicKey || actualAmbienceKey !== null || - environment?.active !== false || - environment?.objectCount !== 0 || + environment?.ambienceKey !== null || + environment?.active !== true || + environment?.objectCount !== 1 || + environment?.maskedObjectCount !== 1 || + environment?.masked !== true || + environment?.tintCount !== 1 || + !(environment?.tintAlpha > 0 && environment.tintAlpha <= 0.08) || environment?.particleCount !== 0 || environment?.hazeCount !== 0 ) { - throw new Error(`Unexpected battle environment for ${battleId}: ${JSON.stringify(environment)}`); + throw new Error( + `Campaign arc wash mismatch for ${battleId}: ${JSON.stringify({ actualMusicKey, actualAmbienceKey, environment })}` + ); } return; } @@ -1689,6 +1700,7 @@ async function verifyBattleEnvironment(page, battleId) { Math.abs(depthRange.maximum - expected.depthRange[1]) < 0.001; if ( environment?.profileId !== expected.profileId || + environment?.musicKey !== actualMusicKey || environment?.ambienceKey !== expected.ambienceKey || actualAmbienceKey !== expected.ambienceKey || environment?.active !== true || @@ -1700,7 +1712,7 @@ async function verifyBattleEnvironment(page, battleId) { !depthMatches ) { throw new Error( - `Battle environment mismatch for ${battleId}: ${JSON.stringify({ expected, actualAmbienceKey, environment })}` + `Battle environment mismatch for ${battleId}: ${JSON.stringify({ expected, actualMusicKey, actualAmbienceKey, environment })}` ); } } @@ -1819,7 +1831,7 @@ async function enterCampaignCampFromBattleResult(page, battle, route) { settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } - const expectedDestination = battle.no === 1 ? 'victory-story' : 'camp'; + const expectedDestination = 'victory-story'; if (settlement?.cta?.destination !== expectedDestination) { throw new Error( `Battle ${battle.no} result continuation mismatch: ${JSON.stringify({ settlement, expectedDestination })}` @@ -1829,22 +1841,7 @@ async function enterCampaignCampFromBattleResult(page, battle, route) { await page.mouse.click(continuePoint.x, continuePoint.y); console.log(`QA intermission ${battle.no}: continuing to ${expectedDestination}`); - if (expectedDestination === 'victory-story') { - await page.waitForFunction(() => { - const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - return activeScenes.includes('StoryScene') || activeScenes.includes('CampScene'); - }, undefined, { timeout: 30000 }); - for (let index = 0; index < 24; index += 1) { - const reachedCamp = await page.evaluate(() => ( - window.__HEROS_DEBUG__?.activeScenes()?.includes('CampScene') ?? false - )); - if (reachedCamp) { - break; - } - await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.advance?.()); - await page.waitForTimeout(260); - } - } + await advancePostBattleStoryToCamp(page); console.log(`QA intermission ${battle.no}: waiting for the reward camp`); await page.waitForFunction( @@ -1900,12 +1897,13 @@ async function enterFinalCampFromBattleResult(page) { ); settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } - if (settlement?.cta?.destination !== 'camp') { - throw new Error(`Final victory must continue to the final camp before the epilogue: ${JSON.stringify(settlement)}`); + if (settlement?.cta?.destination !== 'victory-story') { + throw new Error(`Final victory must continue through its aftermath before the final camp: ${JSON.stringify(settlement)}`); } const continuePoint = await readBattleResultCtaPoint(page); await page.mouse.click(continuePoint.x, continuePoint.y); + await advancePostBattleStoryToCamp(page); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const camp = window.__HEROS_DEBUG__?.camp?.(); @@ -1946,6 +1944,26 @@ async function enterFinalCampFromBattleResult(page) { }; } +async function advancePostBattleStoryToCamp(page) { + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene') || activeScenes.includes('CampScene'); + }, undefined, { timeout: 30000 }); + + for (let index = 0; index < 64; index += 1) { + const reachedCamp = await page.evaluate(() => ( + window.__HEROS_DEBUG__?.activeScenes()?.includes('CampScene') ?? false + )); + if (reachedCamp) { + return; + } + await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.advance?.()); + await page.waitForTimeout(260); + } + + throw new Error('Post-battle aftermath did not reach CampScene within 64 story advances.'); +} + async function readBattleResultCtaPoint(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); diff --git a/scripts/verify-battle-environment-profiles.mjs b/scripts/verify-battle-environment-profiles.mjs index 281f684..5b6ab80 100644 --- a/scripts/verify-battle-environment-profiles.mjs +++ b/scripts/verify-battle-environment-profiles.mjs @@ -146,7 +146,8 @@ try { 'this.drawBattleEnvironment();', 'this.updateBattleEnvironment(delta);', 'this.clearBattleEnvironment();', - 'ambienceKey: profile?.soundscape.ambienceKey', + 'campaignPresentationProfileFor(battleScenario.id)', + 'const tintColor = profile?.tint.color', 'environment: this.battleEnvironmentDebugState()' ].forEach((requiredSource) => { if (!battleSceneSource.includes(requiredSource)) { diff --git a/scripts/verify-campaign-flow-data.mjs b/scripts/verify-campaign-flow-data.mjs index 583e206..d614fd2 100644 --- a/scripts/verify-campaign-flow-data.mjs +++ b/scripts/verify-campaign-flow-data.mjs @@ -4,6 +4,9 @@ const campaignStateSource = readFileSync('src/game/state/campaignState.ts', 'utf const campaignRoutingSource = readFileSync('src/game/state/campaignRouting.ts', 'utf8'); const campaignFlowSource = readFileSync('src/game/data/campaignFlow.ts', 'utf8'); const battlesSource = readFileSync('src/game/data/battles.ts', 'utf8'); +const battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); +const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); +const titleSceneSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8'); const battleScenarioIds = extractBattleScenarioIds(battlesSource); const battleIds = new Set(battleScenarioIds.values()); @@ -64,6 +67,10 @@ for (const flowKey of sortieFlowKeys) { } } assert(/\bpages\s*:/.test(flowBody), `Sortie flow ${flowKey} must provide story pages`); + assert( + !/\b\w+BattleVictoryPages\b/.test(flowBody), + `Sortie flow ${flowKey} must not defer a completed battle's victory story until the next camp sortie` + ); for (const fieldName of sortieFlowDisplayFields) { assert(hasVisibleSortieFlowField(flowBody, fieldName), `Sortie flow ${flowKey} must provide a visible ${fieldName}`); } @@ -72,6 +79,7 @@ for (const flowKey of sortieFlowKeys) { for (const flowKey of sortieFlowKeys) { const nextBattleId = sortieFlowNextBattleIds.get(flowKey); const campaignStep = sortieFlowCampaignSteps.get(flowKey); + const flowBody = sortieFlowBodies.get(flowKey) ?? ''; if (!nextBattleId) { continue; } @@ -83,6 +91,10 @@ for (const flowKey of sortieFlowKeys) { `Sortie flow ${flowKey} marks campaign step ${campaignStep} but routes to ${routedBattleSteps.get(campaignStep) ?? 'no battle'} instead of ${nextBattleId}` ); } + assert( + /\bpages\s*:\s*\w+BattleIntroPages\b/.test(flowBody), + `Battle sortie flow ${flowKey} must contain only the next battle intro pages` + ); } for (const flowKey of sortieFlowKeys) { @@ -122,6 +134,54 @@ for (const step of campaignSteps) { } } +const battleVictoryStoryRouteCount = countOccurrences(battleSceneSource, 'pages: battleScenario.victoryPages'); +const campSceneDestinationCount = countOccurrences(battlesSource, "nextCampScene: 'CampScene'") - 1; +assert( + campSceneDestinationCount === battleIds.size, + `Every battle scenario must return to the typed CampScene destination after its aftermath (found ${campSceneDestinationCount}/${battleIds.size})` +); +assert( + battleVictoryStoryRouteCount === 2, + `Battle results and victory improvement navigation must both route through the completed scenario's victory pages (found ${battleVictoryStoryRouteCount})` +); +assert( + battleSceneSource.includes("return { destination: 'victory-story' as const, label: '승리 이야기로' }"), + 'Every completed battle result must advertise the victory-story destination before camp' +); +assert( + /private resultContinueRoute\(outcome = this\.battleOutcome\)\s*\{\s*if \(outcome !== 'victory'\) \{\s*return undefined;/.test(battleSceneSource), + 'Defeat results must never expose the victory-story continuation route' +); +assert( + /if \(outcome === 'victory'\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource), + 'The victory-story result CTA must only be created for victories' +); +assert( + /if \(outcome === 'victory'\) \{[\s\S]*?pages: battleScenario\.victoryPages,[\s\S]*?return;\s*\}\s*this\.beginResultNavigation\(\(\) => startLazyScene\(this, 'CampScene', campSceneData\)\);/.test(battleSceneSource), + 'Victory improvement navigation must show aftermath pages, while defeat improvement navigation must return directly to camp for retry' +); +assert( + !battleSceneSource.includes('firstBattleVictoryPages'), + 'BattleScene must not special-case first-battle aftermath pages' +); +assert( + countOccurrences(battleSceneSource, 'completedAftermathBattleId: battleScenario.id') === 2, + 'Both victory result routes must clear their persisted aftermath marker only when CampScene is reached' +); +assert( + campaignStateSource.includes('state.pendingAftermathBattleId = battleId as BattleScenarioId') && + campaignStateSource.includes('export function completeCampaignAftermath'), + 'Campaign saves must persist a pending victory aftermath and expose an explicit completion transition' +); +assert( + /if \(campaign\.pendingAftermathBattleId\)[\s\S]*?pages: aftermathScenario\.victoryPages,[\s\S]*?presentationStage: 'aftermath'[\s\S]*?if \(campaign\.step === 'ending-complete'\)/.test(titleSceneSource), + 'Continue must restore a pending aftermath before routing camp or ending campaign steps' +); +assert( + /if \(data\?\.completedAftermathBattleId\) \{\s*completeCampaignAftermath\(data\.completedAftermathBattleId\);\s*\}/.test(campSceneSource), + 'CampScene must complete the pending aftermath before loading its campaign snapshot' +); + if (failures.length > 0) { throw new Error(`Campaign flow data verification failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`); } @@ -136,6 +196,10 @@ function assert(condition, message) { } } +function countOccurrences(source, value) { + return source.split(value).length - 1; +} + function extractBattleScenarioIds(source) { const ids = new Map(); const declarationPattern = /export const (\w+BattleScenario)\b[\s\S]*?^\};/gm; diff --git a/scripts/verify-campaign-presentation-profiles.mjs b/scripts/verify-campaign-presentation-profiles.mjs new file mode 100644 index 0000000..62143a0 --- /dev/null +++ b/scripts/verify-campaign-presentation-profiles.mjs @@ -0,0 +1,333 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const expectedArcRanges = { + 'yellow-turban': [1, 4], + 'anti-dong': [5, 6], + xuzhou: [7, 9], + wandering: [10, 15], + wolong: [16, 18], + 'red-cliffs': [19, 22], + 'jing-yi': [23, 34], + 'hanzhong-shuhan': [35, 37], + 'jingzhou-crisis': [38, 44], + 'yiling-baidi': [45, 46], + nanzhong: [47, 54], + northern: [55, 66] +}; + +const expectedBattleMusicKeys = { + 'yellow-turban': 'militia-theme', + 'anti-dong': 'battle-prep', + xuzhou: 'story-dark', + wandering: 'story-dark', + wolong: 'battle-prep', + 'red-cliffs': 'camp-grand-strategy', + 'jing-yi': 'camp-grand-strategy', + 'hanzhong-shuhan': 'battle-prep', + 'jingzhou-crisis': 'story-dark', + 'yiling-baidi': 'story-dark', + nanzhong: 'camp-frontier', + northern: 'camp-frontier' +}; + +const expectedStages = ['camp', 'story', 'sortie', 'battle', 'aftermath']; +const expectedIntensities = new Set(['quiet', 'measured', 'urgent', 'climactic']); +const errors = []; +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const presentationModule = await server.ssrLoadModule( + '/src/game/data/campaignPresentationProfiles.ts' + ); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { campaignBattleRouteEntries } = await server.ssrLoadModule( + '/src/game/state/campaignRouting.ts' + ); + const { musicTracks, ambienceTracks } = await server.ssrLoadModule( + '/src/game/audio/audioAssets.ts' + ); + const { campSkinDefinitions, campSkinBattleArcs } = await server.ssrLoadModule( + '/src/game/data/campSkins.ts' + ); + const { getCampSoundscape } = await server.ssrLoadModule( + '/src/game/data/campSoundscapes.ts' + ); + const { battleEnvironmentProfiles } = await server.ssrLoadModule( + '/src/game/data/battleEnvironmentProfiles.ts' + ); + const { + campaignArcPresentationProfiles, + campaignBattlePresentationProfiles, + campaignPresentationStatePolicies, + campaignPresentationTransitionPolicy, + campaignPresentationArcForOrdinal, + campaignPresentationProfileFor, + campaignPresentationStateRuleFor, + resolveCampaignPresentationSoundscape + } = presentationModule; + + const scenarioBattleIds = Object.keys(battleScenarios); + const battleIds = campaignBattleRouteEntries().map((entry) => entry.battleId); + const arcEntries = Object.entries(campaignArcPresentationProfiles); + const arcIds = Object.keys(expectedArcRanges); + const battleMusicKeys = new Set(); + + assertEqual(battleIds.length, 66, 'campaign battle count'); + assertEqual(new Set(battleIds).size, 66, 'canonical campaign route uniqueness'); + assertEqual( + JSON.stringify(scenarioBattleIds), + JSON.stringify(battleIds), + 'battle scenario declaration order must match the independent campaign route' + ); + assertEqual(arcEntries.length, 12, 'campaign presentation arc count'); + assertEqual(Object.keys(campaignBattlePresentationProfiles).length, 66, 'battle profile count'); + assertEqual(Object.keys(campaignPresentationStatePolicies).length, 7, 'state policy count'); + + arcEntries.forEach(([arcId, profile], index) => { + const expectedRange = expectedArcRanges[arcId]; + assert(Boolean(expectedRange), `${arcId}: unexpected presentation arc`); + if (!expectedRange) { + return; + } + + const [expectedFirst, expectedLast] = expectedRange; + const campArc = campSkinBattleArcs.find(({ skinId }) => skinId === arcId); + const skin = campSkinDefinitions[arcId]; + const campSoundscape = getCampSoundscape(arcId); + const statePolicy = campaignPresentationStatePolicies[profile.statePolicyId]; + + assertEqual(profile.id, arcId, `${arcId}: profile id`); + assertEqual(profile.skinId, arcId, `${arcId}: skin id`); + assertNonEmptyString(profile.label, `${arcId}: label`); + assertEqual(profile.firstBattleOrdinal, expectedFirst, `${arcId}: first battle`); + assertEqual(profile.lastBattleOrdinal, expectedLast, `${arcId}: last battle`); + assertEqual(campArc?.firstBattleOrdinal, expectedFirst, `${arcId}: camp arc first battle`); + assertEqual(campArc?.lastBattleOrdinal, expectedLast, `${arcId}: camp arc last battle`); + assertEqual(expectedFirst, index === 0 ? 1 : arcEntries[index - 1][1].lastBattleOrdinal + 1, `${arcId}: contiguous start`); + + assert(Boolean(skin), `${arcId}: matching camp skin`); + assertEqual(profile.palette.accentColor, skin?.accentColor, `${arcId}: accent color`); + assertEqual(profile.palette.headerColor, skin?.headerColor, `${arcId}: header color`); + assertEqual(profile.palette.washColor, skin?.washColor, `${arcId}: wash color`); + assert( + Number.isFinite(profile.palette.battleTintColor) && + profile.palette.battleTintColor >= 0 && + profile.palette.battleTintColor <= 0xffffff, + `${arcId}: battle tint color must be a 24-bit color` + ); + assert( + Number.isFinite(profile.palette.battleTintAlpha) && + profile.palette.battleTintAlpha >= 0 && + profile.palette.battleTintAlpha <= 0.08, + `${arcId}: battle tint alpha must stay within the 0..0.08 legibility budget` + ); + + assertEqual(profile.audio.familyId, campSoundscape.music.id, `${arcId}: music family`); + assertEqual(profile.audio.anchorMusicKey, campSoundscape.music.trackKey, `${arcId}: anchor music`); + assertEqual(profile.audio.anchorMusicVolume, campSoundscape.music.volume, `${arcId}: anchor volume`); + assertEqual(profile.audio.defaultAmbienceKey, campSoundscape.ambience.trackKey, `${arcId}: ambience`); + assertEqual(profile.audio.defaultAmbienceVolume, campSoundscape.ambience.volume, `${arcId}: ambience volume`); + assertEqual(profile.audio.battleMusicKey, expectedBattleMusicKeys[arcId], `${arcId}: battle music`); + battleMusicKeys.add(profile.audio.battleMusicKey); + + [ + profile.audio.anchorMusicKey, + profile.audio.tensionMusicKey, + profile.audio.battleMusicKey, + profile.audio.resolutionMusicKey + ].forEach((trackKey) => { + assert(Boolean(musicTracks[trackKey]), `${arcId}: unregistered music track "${trackKey}"`); + }); + assert( + Boolean(ambienceTracks[profile.audio.defaultAmbienceKey]), + `${arcId}: unregistered ambience track "${profile.audio.defaultAmbienceKey}"` + ); + + assert(Array.isArray(profile.motifs) && profile.motifs.length >= 3, `${arcId}: at least three motifs`); + assertEqual(new Set(profile.motifs).size, profile.motifs.length, `${arcId}: unique motifs`); + profile.motifs.forEach((motif) => assertNonEmptyString(motif, `${arcId}: motif`)); + + assert(Boolean(statePolicy), `${arcId}: known state policy "${profile.statePolicyId}"`); + expectedStages.forEach((stage) => { + const rule = statePolicy?.[stage]; + assert(Boolean(rule), `${arcId}/${stage}: state rule`); + assert(expectedIntensities.has(rule?.intensity), `${arcId}/${stage}: valid intensity`); + assert( + campaignPresentationStateRuleFor(battleIds[expectedFirst - 1], stage) === rule, + `${arcId}/${stage}: state rule lookup` + ); + }); + }); + + arcIds.forEach((arcId) => { + assert(Boolean(campaignArcPresentationProfiles[arcId]), `${arcId}: missing presentation arc`); + }); + assertEqual(arcEntries.at(-1)?.[1].lastBattleOrdinal, 66, 'final arc coverage'); + assert( + battleMusicKeys.size >= 4, + `battle music must span at least four existing tracks; received ${[...battleMusicKeys].join(', ')}` + ); + + battleIds.forEach((battleId, index) => { + const battleOrdinal = index + 1; + const profile = campaignBattlePresentationProfiles[battleId]; + const expectedArcId = arcEntries.find( + ([, arc]) => battleOrdinal >= arc.firstBattleOrdinal && battleOrdinal <= arc.lastBattleOrdinal + )?.[0]; + + assert(Boolean(profile), `${battleId}: presentation profile`); + assertEqual(profile?.battleId, battleId, `${battleId}: battle id`); + assertEqual(profile?.battleOrdinal, battleOrdinal, `${battleId}: battle ordinal`); + assertEqual(profile?.arc.id, expectedArcId, `${battleId}: arc id`); + assert(campaignPresentationProfileFor(battleId) === profile, `${battleId}: profile lookup identity`); + assert(campaignPresentationArcForOrdinal(battleOrdinal) === profile?.arc, `${battleId}: ordinal lookup identity`); + + const campPlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'camp' }); + assertEqual(campPlan?.musicKey, profile?.arc.audio.anchorMusicKey, `${battleId}: camp music`); + assertEqual(campPlan?.ambienceKey, profile?.arc.audio.defaultAmbienceKey, `${battleId}: camp ambience`); + + const battlePlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' }); + const environment = battleEnvironmentProfiles[battleId]; + 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`); + assert(battlePlan?.transition === campaignPresentationTransitionPolicy, `${battleId}: transition policy identity`); + }); + + const preservedStoryPlan = resolveCampaignPresentationSoundscape({ + battleId: battleIds[0], + stage: 'story', + sceneMusicKey: 'title-theme', + sceneAmbienceKey: 'battle-rain', + sceneAmbienceVolume: 0.11 + }); + assertEqual(preservedStoryPlan?.musicKey, 'title-theme', 'story scene music preservation'); + assertEqual(preservedStoryPlan?.ambienceKey, 'battle-rain', 'story scene ambience preservation'); + assertEqual(preservedStoryPlan?.ambienceVolume, 0.11, 'story scene ambience volume preservation'); + const foundingStoryPlan = resolveCampaignPresentationSoundscape({ + battleId: battleIds[0], + stage: 'story', + sceneMusicKey: 'militia-theme' + }); + assertEqual( + foundingStoryPlan?.musicKey, + campaignBattlePresentationProfiles[battleIds[0]].arc.audio.anchorMusicKey, + 'story rally cue follows the founding arc anchor' + ); + const redCliffsStoryPlan = resolveCampaignPresentationSoundscape({ + battleId: battleIds[18], + stage: 'story', + sceneMusicKey: 'battle-prep' + }); + assertEqual( + redCliffsStoryPlan?.musicKey, + expectedBattleMusicKeys['red-cliffs'], + 'story preparation cue follows the Red Cliffs battle family' + ); + const foundingAftermathPlan = resolveCampaignPresentationSoundscape({ + battleId: battleIds[0], + stage: 'aftermath', + sceneMusicKey: 'battle-prep' + }); + assertEqual( + foundingAftermathPlan?.musicKey, + campaignBattlePresentationProfiles[battleIds[0]].arc.audio.resolutionMusicKey, + 'aftermath preparation cue resolves through the arc theme' + ); + assertEqual(campaignPresentationProfileFor('unknown-battle'), undefined, 'unknown battle profile'); + assertEqual(campaignPresentationStateRuleFor('unknown-battle', 'camp'), undefined, 'unknown state rule'); + assertEqual( + resolveCampaignPresentationSoundscape({ battleId: 'unknown-battle', stage: 'battle' }), + undefined, + 'unknown soundscape' + ); + + 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'); + assert(Object.isFrozen(campaignPresentationTransitionPolicy), 'transition policy must be frozen'); + + const soundDirectorSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8'); + assert( + soundDirectorSource.includes("createLoopChannel('music', 0.22, 900)"), + 'SoundDirector music channel must retain the 900ms crossfade' + ); + assert( + soundDirectorSource.includes("createLoopChannel('ambience', 0.08, 1200)"), + 'SoundDirector ambience channel must retain the 1200ms crossfade' + ); + + const battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); + const storySceneSource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8'); + const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); + const titleSceneSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8'); + [ + 'resolveCampaignPresentationSoundscape({', + "stage: 'battle'", + 'musicKey: presentationSoundscape?.musicKey', + 'presentationBattleId: battleScenario.id', + "presentationStage: 'aftermath'", + 'campaignPresentationProfileFor(battleScenario.id)', + 'battleTintColor' + ].forEach((requiredSource) => { + assert(battleSceneSource.includes(requiredSource), `BattleScene presentation integration: ${requiredSource}`); + }); + [ + 'presentationBattleId?: string', + "presentationStage?: Extract", + 'resolveCampaignPresentationSoundscape({', + 'this.applyStoryPresentationWash()', + 'profile.arc.palette.washColor' + ].forEach((requiredSource) => { + assert(storySceneSource.includes(requiredSource), `StoryScene presentation integration: ${requiredSource}`); + }); + [ + 'presentationBattleId: flow.nextBattleId', + "presentationStage: 'story'", + 'presentationBattleId: flow.afterBattleId' + ].forEach((requiredSource) => { + assert(campSceneSource.includes(requiredSource), `CampScene presentation integration: ${requiredSource}`); + }); + assert( + titleSceneSource.split("presentationStage: 'story'").length - 1 === 2 && + titleSceneSource.split("presentationBattleId: 'first-battle-zhuo-commandery'").length - 1 >= 2, + 'TitleScene must apply the first campaign arc to both new-game and resumed prologue stories' + ); + + if (errors.length > 0) { + throw new Error( + `Campaign presentation profile verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}` + ); + } + + 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.` + ); +} finally { + await server.close(); +} + +function assertNonEmptyString(value, context) { + assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`); +} + +function assertEqual(actual, expected, context) { + assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`); +} + +function assert(condition, message) { + if (!condition) { + errors.push(message); + } +} + +function format(value) { + return value === undefined ? 'undefined' : JSON.stringify(value); +} diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 8e4e6c1..af76a0e 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -29,6 +29,7 @@ try { acknowledgeCampaignVictoryReward, campaignVictoryRewardPresentation, campaignStorageKey, + completeCampaignAftermath, dismissCampaignVictoryRewardNotice, applyCampBondExp, applyCampVisitReward, @@ -51,7 +52,8 @@ try { setCampaignReserveTrainingAssignment, setCampaignSortieOrderSelection, setCampaignSortieResonanceSelection, - setFirstBattleReport + setFirstBattleReport, + summarizeCampaignProgress } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); @@ -851,6 +853,22 @@ try { createdAt: '2026-07-03T09:00:00.000Z' }; setFirstBattleReport(firstBattleReport); + const pendingAftermathState = loadCampaignState(); + assert( + pendingAftermathState.pendingAftermathBattleId === firstScenario.id && + pendingAftermathState.step === 'first-camp' && + summarizeCampaignProgress(pendingAftermathState).meta === '승리 후 이야기', + `Expected a settled victory to preserve its pending aftermath across save reloads: ${JSON.stringify(pendingAftermathState)}` + ); + const completedAftermathState = completeCampaignAftermath(firstScenario.id); + assert( + completedAftermathState.pendingAftermathBattleId === undefined && + completedAftermathState.step === 'first-camp' && + summarizeCampaignProgress(completedAftermathState).meta === '승리 후 군영' && + completedAftermathState.firstBattleReport?.battleId === firstScenario.id && + completedAftermathState.battleHistory[firstScenario.id]?.outcome === 'victory', + `Expected completing the aftermath to clear only its resume marker: ${JSON.stringify(completedAftermathState)}` + ); const preJianYongSave = JSON.parse(storage.get(campaignStorageKey)); preJianYongSave.roster = preJianYongSave.roster.filter((unit) => unit.id !== 'jian-yong'); preJianYongSave.bonds = preJianYongSave.bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong'); diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 3d8d504..44a3e6e 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -10456,10 +10456,11 @@ try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete'); finalSettlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } - if (finalSettlement?.cta?.destination !== 'camp') { - throw new Error(`Expected the final battle result to route through the reward camp: ${JSON.stringify(finalSettlement)}`); + if (finalSettlement?.cta?.destination !== 'victory-story') { + throw new Error(`Expected the final battle result to show its aftermath before the reward camp: ${JSON.stringify(finalSettlement)}`); } await clickBattleBounds(page, finalSettlement.cta.bounds); + await waitForCampReady(page, { preserveArrivalReward: true }); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const camp = window.__HEROS_DEBUG__?.camp?.(); @@ -10487,27 +10488,6 @@ try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false); await clickLegacyUi(page, 1160, 38); await waitForStoryReady(page); - await page.waitForFunction(() => { - const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); - return ( - story?.currentPageId === 'sixty-sixth-victory-wuzhang-held' && - story?.ready === true && - story?.backgroundReady === true && - story?.activeTexture === story?.background - ); - }); - await page.screenshot({ path: 'dist/verification-ending-epilogue-story.png', fullPage: true }); - await page.evaluate(async () => { - const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); - for (let i = 0; i < 8; i += 1) { - const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); - if (story?.currentPageId === 'ending-wuzhang-council') { - break; - } - window.__HEROS_DEBUG__?.scene('StoryScene')?.advance?.(); - await delay(520); - } - }); await page.waitForFunction(() => { const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); return ( @@ -10519,6 +10499,7 @@ try { story?.activeTexture === story?.background ); }); + await page.screenshot({ path: 'dist/verification-ending-epilogue-story.png', fullPage: true }); await page.screenshot({ path: 'dist/verification-wuzhang-inheritance-story.png', fullPage: true }); await page.evaluate(async () => { const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); @@ -10762,6 +10743,36 @@ async function waitForStoryReady(page) { } async function waitForCampReady(page, options = {}) { + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene'); + }, undefined, { timeout: 90000 }); + + let recordedAftermath = false; + for (let attempt = 0; attempt < 360; attempt += 1) { + const transition = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null + })); + if (transition.activeScenes.includes('CampScene')) { + break; + } + if (transition.activeScenes.includes('StoryScene') && transition.story?.ready === true) { + if ( + transition.story?.presentation?.stage !== 'aftermath' || + transition.story?.nextScene !== 'CampScene' + ) { + throw new Error(`Expected only the completed battle's aftermath before camp: ${JSON.stringify(transition.story)}`); + } + if (!recordedAftermath) { + await recordFlowRuntimeProgress(page, 'story', { storyStage: 'aftermath' }); + recordedAftermath = true; + } + await page.keyboard.press('Space'); + } + await page.waitForTimeout(250); + } + await page.waitForFunction((expected) => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const campScene = window.__HEROS_DEBUG__?.scene('CampScene'); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 28f69be..e0a37e8 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -143,6 +143,10 @@ try { assert( initialStoryProgress?.pageIndex === 0 && initialStoryProgress?.totalPages > 1 && + initialStoryProgress?.presentation?.battleId === 'first-battle-zhuo-commandery' && + initialStoryProgress.presentation.arcId === 'yellow-turban' && + initialStoryProgress.presentation.stage === 'story' && + initialStoryProgress.presentation.washVisible === true && initialStoryProgress?.progressLabel?.startsWith(`1 / ${initialStoryProgress.totalPages}`) && initialStoryProgress.progressLabel.includes('클릭') && initialStoryProgress.progressLabel.includes('스페이스') && @@ -157,6 +161,19 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await clickLegacyUi(page, 962, 310); + await waitForStoryReady(page); + const resumedPrologue = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + assert( + resumedPrologue?.pageIndex === 0 && + resumedPrologue?.presentation?.battleId === 'first-battle-zhuo-commandery' && + resumedPrologue.presentation.arcId === 'yellow-turban' && + resumedPrologue.presentation.stage === 'story' && + resumedPrologue.presentation.washVisible === true, + `Expected a resumed prologue to retain the founding campaign presentation: ${JSON.stringify(resumedPrologue)}` + ); const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', { startDeployment: false, captureLastStory: true @@ -2153,6 +2170,11 @@ try { `Expected the completed victory result to expose one correctly named story CTA: ${JSON.stringify(firstResultState?.resultActions)}` ); const firstResultSave = await readCampaignSave(page); + assert( + firstResultSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' && + firstResultSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', + `Expected the victory result to persist its unseen aftermath in current and slot saves: ${JSON.stringify(firstResultSave)}` + ); const firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; const firstSortieOrderCommand = firstSortieOrderResult?.command; @@ -2318,6 +2340,35 @@ try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 }); await clickLegacyUi(page, 738, 642); + await waitForStoryReady(page); + const directFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + assert( + directFirstAftermath?.pageIndex === 0 && + directFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' && + directFirstAftermath.presentation.stage === 'aftermath', + `Expected the victory CTA to enter the completed battle's aftermath: ${JSON.stringify(directFirstAftermath)}` + ); + await page.keyboard.press('Space'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()?.pageIndex === 1, + undefined, + { timeout: 30000 } + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await clickLegacyUi(page, 962, 310); + await waitForStoryReady(page); + const resumedFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + const resumedFirstAftermathSave = await readCampaignSave(page); + assert( + resumedFirstAftermath?.pageIndex === 0 && + resumedFirstAftermath?.totalPages === 5 && + resumedFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' && + resumedFirstAftermath.presentation.stage === 'aftermath' && + resumedFirstAftermathSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' && + resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', + `Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}` + ); const firstCampTransition = await waitForCampAfterBattleResult(page); assert( firstCampTransition.lastStory?.isLastPage === true && @@ -2333,6 +2384,12 @@ try { ), `Expected the post-battle story to name the next action as returning to camp: ${JSON.stringify(firstCampTransition)}` ); + const firstCampSaveAfterAftermath = await readCampaignSave(page); + assert( + firstCampSaveAfterAftermath.current?.pendingAftermathBattleId === undefined && + firstCampSaveAfterAftermath.slot1?.pendingAftermathBattleId === undefined, + `Expected CampScene entry to complete the persisted aftermath exactly once: ${JSON.stringify(firstCampSaveAfterAftermath)}` + ); const firstRewardDisplaysByPage = Object.fromEntries( firstCampTransition.rewardDisplays.map((display) => [display.pageId, display]) ); @@ -6088,7 +6145,18 @@ try { ); const midImprovementPoint = await readBattleResultEvaluationControlPoint(page, 'improvement'); await page.mouse.click(midImprovementPoint.x, midImprovementPoint.y); - await waitForCamp(page); + const midImprovementAftermath = await waitForCampAfterBattleResult(page); + assert( + midImprovementAftermath.lastStory?.isLastPage === true && + midImprovementAftermath.lastStory?.totalPages === 3 && + midImprovementAftermath.lastStory?.currentPageId === 'fifty-ninth-next-northern-plan' && + midImprovementAftermath.lastStory?.nextScene === 'CampScene' && + midImprovementAftermath.lastStory?.advanceHint === 'camp-return' && + midImprovementAftermath.lastStory?.presentation?.battleId === midCampNextBattleId && + midImprovementAftermath.lastStory?.presentation?.stage === 'aftermath' && + midImprovementAftermath.rewardDisplays.length === 0, + `Expected next-sortie improvement to show only the completed battle's aftermath before camp: ${JSON.stringify(midImprovementAftermath)}` + ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true, undefined, @@ -6100,6 +6168,11 @@ try { campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave), campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) ); + const midImprovementAftermathCompleted = + updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId && + updatedMobileResultSave.slot1?.pendingAftermathBattleId === midCampNextBattleId && + pendingMidImprovementSave.current?.pendingAftermathBattleId === undefined && + pendingMidImprovementSave.slot1?.pendingAftermathBattleId === undefined; const midImprovementRewardAcknowledgements = campaignVictoryAcknowledgementSnapshot( pendingMidImprovementSave, midCampNextBattleId @@ -6114,6 +6187,7 @@ try { pendingMidImprovementReward.sortieVisible === false && pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId && midImprovementCampEntrySaveUnchanged && + midImprovementAftermathCompleted && midImprovementRewardDestinationPending, `Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify({ openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate, @@ -6121,6 +6195,7 @@ try { rewardBattleId: pendingMidImprovementReward?.victoryRewardAcknowledgement?.battleId, expectedBattleId: midCampNextBattleId, saveUnchanged: midImprovementCampEntrySaveUnchanged, + aftermathCompleted: midImprovementAftermathCompleted, rewardDestinationPending: midImprovementRewardDestinationPending, rewardAcknowledgements: midImprovementRewardAcknowledgements })}` @@ -6133,7 +6208,29 @@ try { const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const guidedPreviewSave = await readCampaignSave(page); const guidedPreview = guidedPreviewState?.sortieGuidedImprovement; - const guidedPreviewDestinationIsolation = sameJsonValue(pendingMidImprovementSave, guidedPreviewSave); + const guidedPreviewDestinationIsolation = sameJsonValue( + campaignSaveWithoutVictoryRewardAcknowledgementEffects(pendingMidImprovementSave), + campaignSaveWithoutVictoryRewardAcknowledgementEffects(guidedPreviewSave) + ); + const guidedPreviewRewardAcknowledgements = campaignVictoryAcknowledgementSnapshot( + guidedPreviewSave, + midCampNextBattleId + ); + const guidedPreviewNoticeDismissals = campaignVictoryNoticeDismissalSnapshot( + guidedPreviewSave, + midCampNextBattleId + ); + const guidedPreviewOnlySortieRewardsAcknowledged = ['current', 'slot1'].every((saveKey) => { + const before = midImprovementRewardAcknowledgements[saveKey]; + const after = guidedPreviewRewardAcknowledgements[saveKey]; + return !after.battleAcknowledged && + before.categories.every((category) => after.categories.includes(category)) && + after.categories + .filter((category) => !before.categories.includes(category)) + .every((category) => ['recruits', 'unlocks'].includes(category)) && + !after.categories.includes('equipment') && + !after.categories.includes('supplies'); + }); assert( guidedPreviewState?.sortiePrepStep === 'formation' && guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId && @@ -6147,6 +6244,9 @@ try { isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) && boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) && guidedPreviewDestinationIsolation && + guidedPreviewOnlySortieRewardsAcknowledged && + guidedPreviewNoticeDismissals.current === true && + guidedPreviewNoticeDismissals.slot1 === true && guidedPreviewState?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true && guidedPreviewState?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true, `Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({ @@ -6157,6 +6257,8 @@ try { comparisonAction: guidedPreviewState?.sortieComparisonAction, campEntrySaveUnchanged: midImprovementCampEntrySaveUnchanged, destinationIsolation: guidedPreviewDestinationIsolation, + rewardAcknowledgements: guidedPreviewRewardAcknowledgements, + noticeDismissals: guidedPreviewNoticeDismissals, rewardBadges: guidedPreviewState?.campTabs ?.filter((tab) => ['equipment', 'supplies'].includes(tab.id)) .map((tab) => ({ id: tab.id, new: tab.newBadgeVisible })) @@ -10306,6 +10408,7 @@ function campaignSaveWithoutCampRecruitmentEffects(save) { bonds: _bonds, acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds, acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories, + pendingAftermathBattleId: _pendingAftermathBattleId, firstBattleReport, ...stableFields } = state; @@ -10366,6 +10469,26 @@ function campaignSaveWithoutVictoryRewardNoticeDismissals(save) { }; } +function campaignSaveWithoutVictoryRewardAcknowledgementEffects(save) { + const stableState = (state) => { + if (!state) { + return state; + } + const { + updatedAt: _updatedAt, + acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds, + acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories, + dismissedVictoryRewardNoticeBattleIds: _dismissedVictoryRewardNoticeBattleIds, + ...stableFields + } = state; + return stableFields; + }; + return { + current: stableState(save?.current), + slot1: stableState(save?.slot1) + }; +} + function recommendationFeedbackSemantic(feedback) { if (!feedback) { return null; diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index e127584..ae45402 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -6,6 +6,7 @@ const checks = [ 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-campaign-completion.mjs', + 'scripts/verify-campaign-presentation-profiles.mjs', 'scripts/verify-growth-consistency.mjs', 'scripts/verify-combat-presentation-settings.mjs', 'scripts/verify-visual-motion-settings.mjs', diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 1ec0c3b..8dbd3e2 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -428,7 +428,7 @@ export type BattleScenarioDefinition = { itemRewards: string[]; campaignReward?: BattleCampaignRewardDefinition; victoryPages: StoryPage[]; - nextCampScene: string; + nextCampScene: 'CampScene'; }; export const firstBattleScenario: BattleScenarioDefinition = { diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index b2bcd9c..28c354f 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -71,137 +71,74 @@ import { import { baidiEntrustmentPages, eighthBattleIntroPages, - eighthBattleVictoryPages, eighteenthBattleIntroPages, - eighteenthBattleVictoryPages, eleventhBattleIntroPages, - eleventhBattleVictoryPages, endingEpiloguePages, fiftiethBattleIntroPages, - fiftiethBattleVictoryPages, fiftyEighthBattleIntroPages, - fiftyEighthBattleVictoryPages, fiftyNinthBattleIntroPages, - fiftyNinthBattleVictoryPages, sixtiethBattleIntroPages, - sixtiethBattleVictoryPages, sixtyFirstBattleIntroPages, - sixtyFirstBattleVictoryPages, sixtySecondBattleIntroPages, - sixtySecondBattleVictoryPages, sixtyThirdBattleIntroPages, - sixtyThirdBattleVictoryPages, sixtyFourthBattleIntroPages, - sixtyFourthBattleVictoryPages, sixtyFifthBattleIntroPages, - sixtyFifthBattleVictoryPages, sixtySixthBattleIntroPages, - sixtySixthBattleVictoryPages, fiftyFirstBattleIntroPages, - fiftyFirstBattleVictoryPages, fiftySecondBattleIntroPages, - fiftySecondBattleVictoryPages, fiftyThirdBattleIntroPages, - fiftyThirdBattleVictoryPages, fiftyFourthBattleIntroPages, - fiftyFourthBattleVictoryPages, fiftyFifthBattleIntroPages, - fiftyFifthBattleVictoryPages, fiftySixthBattleIntroPages, - fiftySixthBattleVictoryPages, fiftySeventhBattleIntroPages, - fiftySeventhBattleVictoryPages, fortiethBattleIntroPages, - fortiethBattleVictoryPages, fortyFirstBattleIntroPages, - fortyFirstBattleVictoryPages, fortySecondBattleIntroPages, - fortySecondBattleVictoryPages, fortyThirdBattleIntroPages, - fortyThirdBattleVictoryPages, fortyFourthBattleIntroPages, - fortyFourthBattleVictoryPages, fortyFifthBattleIntroPages, - fortyFifthBattleVictoryPages, fortyEighthBattleIntroPages, - fortyEighthBattleVictoryPages, fortyNinthBattleIntroPages, - fortyNinthBattleVictoryPages, fortySixthBattleIntroPages, fortySeventhBattleIntroPages, - fortySeventhBattleVictoryPages, fifthBattleIntroPages, - fifthBattleVictoryPages, fifteenthBattleIntroPages, - fifteenthBattleVictoryPages, fourteenthBattleIntroPages, - fourteenthBattleVictoryPages, fourthBattleIntroPages, - fourthBattleVictoryPages, hanzhongKingCouncilPages, ninthBattleIntroPages, - ninthBattleVictoryPages, nineteenthBattleIntroPages, - nineteenthBattleVictoryPages, northernCampaignPrepPages, secondBattleIntroPages, - secondBattleVictoryPages, seventhBattleIntroPages, - seventhBattleVictoryPages, sixthBattleIntroPages, - sixthBattleVictoryPages, sixteenthBattleIntroPages, - sixteenthBattleVictoryPages, seventeenthBattleIntroPages, - seventeenthBattleVictoryPages, shuHanFoundationPages, tenthBattleIntroPages, - tenthBattleVictoryPages, thirteenthBattleIntroPages, - thirteenthBattleVictoryPages, thirtyFifthBattleIntroPages, - thirtyFifthBattleVictoryPages, thirtyFourthBattleIntroPages, - thirtyFourthBattleVictoryPages, thirtyEighthBattleIntroPages, - thirtyEighthBattleVictoryPages, thirtyFirstBattleIntroPages, - thirtyFirstBattleVictoryPages, thirtyNinthBattleIntroPages, - thirtyNinthBattleVictoryPages, thirtySecondBattleIntroPages, - thirtySecondBattleVictoryPages, thirtyThirdBattleIntroPages, - thirtyThirdBattleVictoryPages, thirtySixthBattleIntroPages, - thirtySixthBattleVictoryPages, thirtySeventhBattleIntroPages, thirtiethBattleIntroPages, - thirtiethBattleVictoryPages, twentyEighthBattleIntroPages, - twentyEighthBattleVictoryPages, twentyNinthBattleIntroPages, - twentyNinthBattleVictoryPages, twentyFirstBattleIntroPages, - twentyFirstBattleVictoryPages, twentySecondBattleIntroPages, - twentySecondBattleVictoryPages, twentyThirdBattleIntroPages, - twentyThirdBattleVictoryPages, twentyFifthBattleIntroPages, - twentyFifthBattleVictoryPages, twentyFourthBattleIntroPages, - twentyFourthBattleVictoryPages, twentySixthBattleIntroPages, - twentySixthBattleVictoryPages, twentySeventhBattleIntroPages, - twentySeventhBattleVictoryPages, twentiethBattleIntroPages, - twentiethBattleVictoryPages, twelfthBattleIntroPages, - twelfthBattleVictoryPages, thirdBattleIntroPages, - thirdBattleVictoryPages, type StoryPage } from './scenario'; @@ -236,7 +173,7 @@ const sortieFlows: Record = { rewardHint: '지난 보상: 콩, 상처약, 탁주, 황건 부적 · 다음 보상: 단궁, 의용군 명성', nextBattleId: thirdBattleScenario.id, campaignStep: 'third-battle', - pages: [...secondBattleVictoryPages, ...thirdBattleIntroPages] + pages: thirdBattleIntroPages }, [thirdBattleScenario.id]: { afterBattleId: thirdBattleScenario.id, @@ -246,7 +183,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fourthBattleScenario.title} 개방`, nextBattleId: fourthBattleScenario.id, campaignStep: 'fourth-battle', - pages: [...thirdBattleVictoryPages, ...fourthBattleIntroPages] + pages: fourthBattleIntroPages }, [fourthBattleScenario.id]: { afterBattleId: fourthBattleScenario.id, @@ -256,7 +193,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fifthBattleScenario.title} 개방`, nextBattleId: fifthBattleScenario.id, campaignStep: 'fifth-battle', - pages: [...fourthBattleVictoryPages, ...fifthBattleIntroPages] + pages: fifthBattleIntroPages }, [fifthBattleScenario.id]: { afterBattleId: fifthBattleScenario.id, @@ -266,7 +203,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixthBattleScenario.title} 개방`, nextBattleId: sixthBattleScenario.id, campaignStep: 'sixth-battle', - pages: [...fifthBattleVictoryPages, ...sixthBattleIntroPages] + pages: sixthBattleIntroPages }, [sixthBattleScenario.id]: { afterBattleId: sixthBattleScenario.id, @@ -276,7 +213,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${seventhBattleScenario.title} 개방`, nextBattleId: seventhBattleScenario.id, campaignStep: 'seventh-battle', - pages: [...sixthBattleVictoryPages, ...seventhBattleIntroPages] + pages: seventhBattleIntroPages }, [seventhBattleScenario.id]: { afterBattleId: seventhBattleScenario.id, @@ -286,7 +223,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${eighthBattleScenario.title} 개방`, nextBattleId: eighthBattleScenario.id, campaignStep: 'eighth-battle', - pages: [...seventhBattleVictoryPages, ...eighthBattleIntroPages] + pages: eighthBattleIntroPages }, [eighthBattleScenario.id]: { afterBattleId: eighthBattleScenario.id, @@ -296,7 +233,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${ninthBattleScenario.title} 개방`, nextBattleId: ninthBattleScenario.id, campaignStep: 'ninth-battle', - pages: [...eighthBattleVictoryPages, ...ninthBattleIntroPages] + pages: ninthBattleIntroPages }, [ninthBattleScenario.id]: { afterBattleId: ninthBattleScenario.id, @@ -306,7 +243,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${tenthBattleScenario.title} 개방`, nextBattleId: tenthBattleScenario.id, campaignStep: 'tenth-battle', - pages: [...ninthBattleVictoryPages, ...tenthBattleIntroPages] + pages: tenthBattleIntroPages }, [tenthBattleScenario.id]: { afterBattleId: tenthBattleScenario.id, @@ -316,7 +253,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${eleventhBattleScenario.title} 개방`, nextBattleId: eleventhBattleScenario.id, campaignStep: 'eleventh-battle', - pages: [...tenthBattleVictoryPages, ...eleventhBattleIntroPages] + pages: eleventhBattleIntroPages }, [eleventhBattleScenario.id]: { afterBattleId: eleventhBattleScenario.id, @@ -327,7 +264,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twelfthBattleScenario.title} 개방`, nextBattleId: twelfthBattleScenario.id, campaignStep: 'twelfth-battle', - pages: [...eleventhBattleVictoryPages, ...twelfthBattleIntroPages] + pages: twelfthBattleIntroPages }, [twelfthBattleScenario.id]: { afterBattleId: twelfthBattleScenario.id, @@ -338,7 +275,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirteenthBattleScenario.title} 개방`, nextBattleId: thirteenthBattleScenario.id, campaignStep: 'thirteenth-battle', - pages: [...twelfthBattleVictoryPages, ...thirteenthBattleIntroPages] + pages: thirteenthBattleIntroPages }, [thirteenthBattleScenario.id]: { afterBattleId: thirteenthBattleScenario.id, @@ -349,7 +286,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fourteenthBattleScenario.title} 개방 / 손건 합류`, nextBattleId: fourteenthBattleScenario.id, campaignStep: 'fourteenth-battle', - pages: [...thirteenthBattleVictoryPages, ...fourteenthBattleIntroPages] + pages: fourteenthBattleIntroPages }, [fourteenthBattleScenario.id]: { afterBattleId: fourteenthBattleScenario.id, @@ -360,7 +297,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fifteenthBattleScenario.title} 개방 / 원소 접선 명분`, nextBattleId: fifteenthBattleScenario.id, campaignStep: 'fifteenth-battle', - pages: [...fourteenthBattleVictoryPages, ...fifteenthBattleIntroPages] + pages: fifteenthBattleIntroPages }, [fifteenthBattleScenario.id]: { afterBattleId: fifteenthBattleScenario.id, @@ -371,7 +308,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixteenthBattleScenario.title} 개방 / 조운 합류`, nextBattleId: sixteenthBattleScenario.id, campaignStep: 'sixteenth-battle', - pages: [...fifteenthBattleVictoryPages, ...sixteenthBattleIntroPages] + pages: sixteenthBattleIntroPages }, [sixteenthBattleScenario.id]: { afterBattleId: sixteenthBattleScenario.id, @@ -382,7 +319,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${seventeenthBattleScenario.title} 개방 / 와룡 방문 예우`, nextBattleId: seventeenthBattleScenario.id, campaignStep: 'seventeenth-battle', - pages: [...sixteenthBattleVictoryPages, ...seventeenthBattleIntroPages] + pages: seventeenthBattleIntroPages }, [seventeenthBattleScenario.id]: { afterBattleId: seventeenthBattleScenario.id, @@ -393,7 +330,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${eighteenthBattleScenario.title} 개방 / 제갈량 첫 계책`, nextBattleId: eighteenthBattleScenario.id, campaignStep: 'eighteenth-battle', - pages: [...seventeenthBattleVictoryPages, ...eighteenthBattleIntroPages] + pages: eighteenthBattleIntroPages }, [eighteenthBattleScenario.id]: { afterBattleId: eighteenthBattleScenario.id, @@ -404,7 +341,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${nineteenthBattleScenario.title} 개방 / 박망파 지연 성공`, nextBattleId: nineteenthBattleScenario.id, campaignStep: 'nineteenth-battle', - pages: [...eighteenthBattleVictoryPages, ...nineteenthBattleIntroPages] + pages: nineteenthBattleIntroPages }, [nineteenthBattleScenario.id]: { afterBattleId: nineteenthBattleScenario.id, @@ -415,7 +352,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentiethBattleScenario.title} 개방 / 강동 사절 문서`, nextBattleId: twentiethBattleScenario.id, campaignStep: 'twentieth-battle', - pages: [...nineteenthBattleVictoryPages, ...twentiethBattleIntroPages] + pages: twentiethBattleIntroPages }, [twentiethBattleScenario.id]: { afterBattleId: twentiethBattleScenario.id, @@ -426,7 +363,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyFirstBattleScenario.title} 개방 / 화공 논의 진전`, nextBattleId: twentyFirstBattleScenario.id, campaignStep: 'twenty-first-battle', - pages: [...twentiethBattleVictoryPages, ...twentyFirstBattleIntroPages] + pages: twentyFirstBattleIntroPages }, [twentyFirstBattleScenario.id]: { afterBattleId: twentyFirstBattleScenario.id, @@ -437,7 +374,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentySecondBattleScenario.title} 개방 / 화공 논의 본격화`, nextBattleId: twentySecondBattleScenario.id, campaignStep: 'twenty-second-battle', - pages: [...twentyFirstBattleVictoryPages, ...twentySecondBattleIntroPages] + pages: twentySecondBattleIntroPages }, [twentySecondBattleScenario.id]: { afterBattleId: twentySecondBattleScenario.id, @@ -448,7 +385,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyThirdBattleScenario.title} 개방 / 마량 합류 / 적벽 명성`, nextBattleId: twentyThirdBattleScenario.id, campaignStep: 'twenty-third-battle', - pages: [...twentySecondBattleVictoryPages, ...twentyThirdBattleIntroPages] + pages: twentyThirdBattleIntroPages }, [twentyThirdBattleScenario.id]: { afterBattleId: twentyThirdBattleScenario.id, @@ -459,7 +396,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyFourthBattleScenario.title} 개방 / 이적 합류 / 계양 설득 명분`, nextBattleId: twentyFourthBattleScenario.id, campaignStep: 'twenty-fourth-battle', - pages: [...twentyThirdBattleVictoryPages, ...twentyFourthBattleIntroPages] + pages: twentyFourthBattleIntroPages }, [twentyFourthBattleScenario.id]: { afterBattleId: twentyFourthBattleScenario.id, @@ -470,7 +407,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyFifthBattleScenario.title} 개방 / 공지 합류 / 무릉 산길 정보`, nextBattleId: twentyFifthBattleScenario.id, campaignStep: 'twenty-fifth-battle', - pages: [...twentyFourthBattleVictoryPages, ...twentyFifthBattleIntroPages] + pages: twentyFifthBattleIntroPages }, [twentyFifthBattleScenario.id]: { afterBattleId: twentyFifthBattleScenario.id, @@ -481,7 +418,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentySixthBattleScenario.title} 개방 / 공지의 무릉 길 정보 / 황충·위연 합류`, nextBattleId: twentySixthBattleScenario.id, campaignStep: 'twenty-sixth-battle', - pages: [...twentyFifthBattleVictoryPages, ...twentySixthBattleIntroPages] + pages: twentySixthBattleIntroPages }, [twentySixthBattleScenario.id]: { afterBattleId: twentySixthBattleScenario.id, @@ -492,7 +429,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentySeventhBattleScenario.title} 개방 / 황충·위연 전력화 / 방통 합류`, nextBattleId: twentySeventhBattleScenario.id, campaignStep: 'twenty-seventh-battle', - pages: [...twentySixthBattleVictoryPages, ...twentySeventhBattleIntroPages] + pages: twentySeventhBattleIntroPages }, [twentySeventhBattleScenario.id]: { afterBattleId: twentySeventhBattleScenario.id, @@ -503,7 +440,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyEighthBattleScenario.title} 개방 / 방통 전력화 / 법정 합류`, nextBattleId: twentyEighthBattleScenario.id, campaignStep: 'twenty-eighth-battle', - pages: [...twentySeventhBattleVictoryPages, ...twentyEighthBattleIntroPages] + pages: twentyEighthBattleIntroPages }, [twentyEighthBattleScenario.id]: { afterBattleId: twentyEighthBattleScenario.id, @@ -514,7 +451,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${twentyNinthBattleScenario.title} 개방 / 오의 합류`, nextBattleId: twentyNinthBattleScenario.id, campaignStep: 'twenty-ninth-battle', - pages: [...twentyEighthBattleVictoryPages, ...twentyNinthBattleIntroPages] + pages: twentyNinthBattleIntroPages }, [twentyNinthBattleScenario.id]: { afterBattleId: twentyNinthBattleScenario.id, @@ -525,7 +462,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtiethBattleScenario.title} 개방 / 봉추 생환`, nextBattleId: thirtiethBattleScenario.id, campaignStep: 'thirtieth-battle', - pages: [...twentyNinthBattleVictoryPages, ...thirtiethBattleIntroPages] + pages: thirtiethBattleIntroPages }, [thirtiethBattleScenario.id]: { afterBattleId: thirtiethBattleScenario.id, @@ -536,7 +473,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtyFirstBattleScenario.title} 개방 / 엄안 합류`, nextBattleId: thirtyFirstBattleScenario.id, campaignStep: 'thirty-first-battle', - pages: [...thirtiethBattleVictoryPages, ...thirtyFirstBattleIntroPages] + pages: thirtyFirstBattleIntroPages }, [thirtyFirstBattleScenario.id]: { afterBattleId: thirtyFirstBattleScenario.id, @@ -547,7 +484,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtySecondBattleScenario.title} 개방 / 이엄 합류`, nextBattleId: thirtySecondBattleScenario.id, campaignStep: 'thirty-second-battle', - pages: [...thirtyFirstBattleVictoryPages, ...thirtySecondBattleIntroPages] + pages: thirtySecondBattleIntroPages }, [thirtySecondBattleScenario.id]: { afterBattleId: thirtySecondBattleScenario.id, @@ -558,7 +495,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtyThirdBattleScenario.title} 개방 / 황권 합류`, nextBattleId: thirtyThirdBattleScenario.id, campaignStep: 'thirty-third-battle', - pages: [...thirtySecondBattleVictoryPages, ...thirtyThirdBattleIntroPages] + pages: thirtyThirdBattleIntroPages }, [thirtyThirdBattleScenario.id]: { afterBattleId: thirtyThirdBattleScenario.id, @@ -569,7 +506,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtyFourthBattleScenario.title} 개방 / 마초 합류`, nextBattleId: thirtyFourthBattleScenario.id, campaignStep: 'thirty-fourth-battle', - pages: [...thirtyThirdBattleVictoryPages, ...thirtyFourthBattleIntroPages] + pages: thirtyFourthBattleIntroPages }, [thirtyFourthBattleScenario.id]: { afterBattleId: thirtyFourthBattleScenario.id, @@ -580,7 +517,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtyFifthBattleScenario.title} 개방 / 마대 합류`, nextBattleId: thirtyFifthBattleScenario.id, campaignStep: 'thirty-fifth-battle', - pages: [...thirtyFourthBattleVictoryPages, ...thirtyFifthBattleIntroPages] + pages: thirtyFifthBattleIntroPages }, [thirtyFifthBattleScenario.id]: { afterBattleId: thirtyFifthBattleScenario.id, @@ -591,7 +528,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtySixthBattleScenario.title} 개방 / 왕평 합류`, nextBattleId: thirtySixthBattleScenario.id, campaignStep: 'thirty-sixth-battle', - pages: [...thirtyFifthBattleVictoryPages, ...thirtySixthBattleIntroPages] + pages: thirtySixthBattleIntroPages }, [thirtySixthBattleScenario.id]: { afterBattleId: thirtySixthBattleScenario.id, @@ -602,7 +539,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtySeventhBattleScenario.title} 개방 / 한중왕 즉위 준비`, nextBattleId: thirtySeventhBattleScenario.id, campaignStep: 'thirty-seventh-battle', - pages: [...thirtySixthBattleVictoryPages, ...thirtySeventhBattleIntroPages] + pages: thirtySeventhBattleIntroPages }, [thirtySeventhBattleScenario.id]: { afterBattleId: thirtySeventhBattleScenario.id, @@ -646,7 +583,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${thirtyNinthBattleScenario.title} 개방 / 번성 외곽도`, nextBattleId: thirtyNinthBattleScenario.id, campaignStep: 'thirty-ninth-battle', - pages: [...thirtyEighthBattleVictoryPages, ...thirtyNinthBattleIntroPages] + pages: thirtyNinthBattleIntroPages }, [thirtyNinthBattleScenario.id]: { afterBattleId: thirtyNinthBattleScenario.id, @@ -657,7 +594,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortiethBattleScenario.title} 개방 / 칠군 항복문`, nextBattleId: fortiethBattleScenario.id, campaignStep: 'fortieth-battle', - pages: [...thirtyNinthBattleVictoryPages, ...fortiethBattleIntroPages] + pages: fortiethBattleIntroPages }, [fortiethBattleScenario.id]: { afterBattleId: fortiethBattleScenario.id, @@ -668,7 +605,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyFirstBattleScenario.title} 개방 / 번성 성문도`, nextBattleId: fortyFirstBattleScenario.id, campaignStep: 'forty-first-battle', - pages: [...fortiethBattleVictoryPages, ...fortyFirstBattleIntroPages] + pages: fortyFirstBattleIntroPages }, [fortyFirstBattleScenario.id]: { afterBattleId: fortyFirstBattleScenario.id, @@ -679,7 +616,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortySecondBattleScenario.title} 개방 / 강릉 창고 장부`, nextBattleId: fortySecondBattleScenario.id, campaignStep: 'forty-second-battle', - pages: [...fortyFirstBattleVictoryPages, ...fortySecondBattleIntroPages] + pages: fortySecondBattleIntroPages }, [fortySecondBattleScenario.id]: { afterBattleId: fortySecondBattleScenario.id, @@ -690,7 +627,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyThirdBattleScenario.title} 개방 / 공안 성문 기록`, nextBattleId: fortyThirdBattleScenario.id, campaignStep: 'forty-third-battle', - pages: [...fortySecondBattleVictoryPages, ...fortyThirdBattleIntroPages] + pages: fortyThirdBattleIntroPages }, [fortyThirdBattleScenario.id]: { afterBattleId: fortyThirdBattleScenario.id, @@ -701,7 +638,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyFourthBattleScenario.title} 개방 / 맥성 북문 지도`, nextBattleId: fortyFourthBattleScenario.id, campaignStep: 'forty-fourth-battle', - pages: [...fortyThirdBattleVictoryPages, ...fortyFourthBattleIntroPages] + pages: fortyFourthBattleIntroPages }, [fortyFourthBattleScenario.id]: { afterBattleId: fortyFourthBattleScenario.id, @@ -712,7 +649,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyFifthBattleScenario.title} 개방 / 이릉 동진 지도`, nextBattleId: fortyFifthBattleScenario.id, campaignStep: 'forty-fifth-battle', - pages: [...fortyFourthBattleVictoryPages, ...fortyFifthBattleIntroPages] + pages: fortyFifthBattleIntroPages }, [fortyFifthBattleScenario.id]: { afterBattleId: fortyFifthBattleScenario.id, @@ -723,7 +660,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortySixthBattleScenario.title} 개방 / 화공 방화선 표식`, nextBattleId: fortySixthBattleScenario.id, campaignStep: 'forty-sixth-battle', - pages: [...fortyFifthBattleVictoryPages, ...fortySixthBattleIntroPages] + pages: fortySixthBattleIntroPages }, [fortySixthBattleScenario.id]: { afterBattleId: fortySixthBattleScenario.id, @@ -756,7 +693,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyEighthBattleScenario.title} 개방 / 맹획 회유 서신`, nextBattleId: fortyEighthBattleScenario.id, campaignStep: 'forty-eighth-battle', - pages: [...fortySeventhBattleVictoryPages, ...fortyEighthBattleIntroPages] + pages: fortyEighthBattleIntroPages }, [fortyEighthBattleScenario.id]: { afterBattleId: fortyEighthBattleScenario.id, @@ -767,7 +704,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fortyNinthBattleScenario.title} 개방 / 칠종칠금 회유문`, nextBattleId: fortyNinthBattleScenario.id, campaignStep: 'forty-ninth-battle', - pages: [...fortyEighthBattleVictoryPages, ...fortyNinthBattleIntroPages] + pages: fortyNinthBattleIntroPages }, [fortyNinthBattleScenario.id]: { afterBattleId: fortyNinthBattleScenario.id, @@ -778,7 +715,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftiethBattleScenario.title} 개방 / 남중 호족 설득문`, nextBattleId: fiftiethBattleScenario.id, campaignStep: 'fiftieth-battle', - pages: [...fortyNinthBattleVictoryPages, ...fiftiethBattleIntroPages] + pages: fiftiethBattleIntroPages }, [fiftiethBattleScenario.id]: { afterBattleId: fiftiethBattleScenario.id, @@ -789,7 +726,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftyFirstBattleScenario.title} 개방 / 호족 보증 장부`, nextBattleId: fiftyFirstBattleScenario.id, campaignStep: 'fifty-first-battle', - pages: [...fiftiethBattleVictoryPages, ...fiftyFirstBattleIntroPages] + pages: fiftyFirstBattleIntroPages }, [fiftyFirstBattleScenario.id]: { afterBattleId: fiftyFirstBattleScenario.id, @@ -800,7 +737,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftySecondBattleScenario.title} 개방 / 강경파 분리 장부`, nextBattleId: fiftySecondBattleScenario.id, campaignStep: 'fifty-second-battle', - pages: [...fiftyFirstBattleVictoryPages, ...fiftySecondBattleIntroPages] + pages: fiftySecondBattleIntroPages }, [fiftySecondBattleScenario.id]: { afterBattleId: fiftySecondBattleScenario.id, @@ -811,7 +748,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftyThirdBattleScenario.title} 개방 / 남중 신뢰 장부`, nextBattleId: fiftyThirdBattleScenario.id, campaignStep: 'fifty-third-battle', - pages: [...fiftySecondBattleVictoryPages, ...fiftyThirdBattleIntroPages] + pages: fiftyThirdBattleIntroPages }, [fiftyThirdBattleScenario.id]: { afterBattleId: fiftyThirdBattleScenario.id, @@ -822,7 +759,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftyFourthBattleScenario.title} 개방 / 남중 평정 장부`, nextBattleId: fiftyFourthBattleScenario.id, campaignStep: 'fifty-fourth-battle', - pages: [...fiftyThirdBattleVictoryPages, ...fiftyFourthBattleIntroPages] + pages: fiftyFourthBattleIntroPages }, [fiftyFourthBattleScenario.id]: { afterBattleId: fiftyFourthBattleScenario.id, @@ -832,7 +769,7 @@ const sortieFlows: Record = { '맹획은 일곱 번의 생포 끝에 항복했고, 남중은 촉한의 약속을 받아들였습니다. 출진 준비를 통해 새 전투가 아니라 북벌 준비 회의를 진행하고, 한중 창고와 출전 무장 재편을 군영 안에서 정리합니다.', rewardHint: '군영 목표: 북벌 준비 회의 / 한중 창고와 출전 무장 재편', campaignStep: 'northern-campaign-prep-camp', - pages: [...fiftyFourthBattleVictoryPages, ...northernCampaignPrepPages], + pages: northernCampaignPrepPages, unavailableNotice: '북벌 준비 회의가 완료되었습니다. 제1차 북벌 출진 편성을 다시 점검하세요.' }, 'northern-campaign-prep-camp': { @@ -855,7 +792,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftySixthBattleScenario.title} 개방 / 천수 진군 장부`, nextBattleId: fiftySixthBattleScenario.id, campaignStep: 'fifty-sixth-battle', - pages: [...fiftyFifthBattleVictoryPages, ...fiftySixthBattleIntroPages] + pages: fiftySixthBattleIntroPages }, [fiftySixthBattleScenario.id]: { afterBattleId: fiftySixthBattleScenario.id, @@ -866,7 +803,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftySeventhBattleScenario.title} 개방 / 강유 합류 단서`, nextBattleId: fiftySeventhBattleScenario.id, campaignStep: 'fifty-seventh-battle', - pages: [...fiftySixthBattleVictoryPages, ...fiftySeventhBattleIntroPages] + pages: fiftySeventhBattleIntroPages }, [fiftySeventhBattleScenario.id]: { afterBattleId: fiftySeventhBattleScenario.id, @@ -877,7 +814,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftyEighthBattleScenario.title} 개방 / 강유 첫 출전 기록`, nextBattleId: fiftyEighthBattleScenario.id, campaignStep: 'fifty-eighth-battle', - pages: [...fiftySeventhBattleVictoryPages, ...fiftyEighthBattleIntroPages] + pages: fiftyEighthBattleIntroPages }, [fiftyEighthBattleScenario.id]: { afterBattleId: fiftyEighthBattleScenario.id, @@ -888,7 +825,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${fiftyNinthBattleScenario.title} 개방 / 진창 공성 장부`, nextBattleId: fiftyNinthBattleScenario.id, campaignStep: 'fifty-ninth-battle', - pages: [...fiftyEighthBattleVictoryPages, ...fiftyNinthBattleIntroPages] + pages: fiftyNinthBattleIntroPages }, [fiftyNinthBattleScenario.id]: { afterBattleId: fiftyNinthBattleScenario.id, @@ -899,7 +836,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtiethBattleScenario.title} 개방 / 무도·음평 장부`, nextBattleId: sixtiethBattleScenario.id, campaignStep: 'sixtieth-battle', - pages: [...fiftyNinthBattleVictoryPages, ...sixtiethBattleIntroPages] + pages: sixtiethBattleIntroPages }, [sixtiethBattleScenario.id]: { afterBattleId: sixtiethBattleScenario.id, @@ -910,7 +847,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtyFirstBattleScenario.title} 개방 / 한중 우로 장부`, nextBattleId: sixtyFirstBattleScenario.id, campaignStep: 'sixty-first-battle', - pages: [...sixtiethBattleVictoryPages, ...sixtyFirstBattleIntroPages] + pages: sixtyFirstBattleIntroPages }, [sixtyFirstBattleScenario.id]: { afterBattleId: sixtyFirstBattleScenario.id, @@ -921,7 +858,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtySecondBattleScenario.title} 개방 / 기산 재출정 장부`, nextBattleId: sixtySecondBattleScenario.id, campaignStep: 'sixty-second-battle', - pages: [...sixtyFirstBattleVictoryPages, ...sixtySecondBattleIntroPages] + pages: sixtySecondBattleIntroPages }, [sixtySecondBattleScenario.id]: { afterBattleId: sixtySecondBattleScenario.id, @@ -932,7 +869,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtyThirdBattleScenario.title} 개방 / 노성 추격 장부`, nextBattleId: sixtyThirdBattleScenario.id, campaignStep: 'sixty-third-battle', - pages: [...sixtySecondBattleVictoryPages, ...sixtyThirdBattleIntroPages] + pages: sixtyThirdBattleIntroPages }, [sixtyThirdBattleScenario.id]: { afterBattleId: sixtyThirdBattleScenario.id, @@ -943,7 +880,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtyFourthBattleScenario.title} 개방 / 위수 진영 장부`, nextBattleId: sixtyFourthBattleScenario.id, campaignStep: 'sixty-fourth-battle', - pages: [...sixtyThirdBattleVictoryPages, ...sixtyFourthBattleIntroPages] + pages: sixtyFourthBattleIntroPages }, [sixtyFourthBattleScenario.id]: { afterBattleId: sixtyFourthBattleScenario.id, @@ -954,7 +891,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtyFifthBattleScenario.title} 개방 / 북안 전진 장부`, nextBattleId: sixtyFifthBattleScenario.id, campaignStep: 'sixty-fifth-battle', - pages: [...sixtyFourthBattleVictoryPages, ...sixtyFifthBattleIntroPages] + pages: sixtyFifthBattleIntroPages }, 'sixty-fifth-camp': { afterBattleId: sixtyFifthBattleScenario.id, @@ -965,7 +902,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtySixthBattleScenario.title} 개방 / 오장원 귀환 장부`, nextBattleId: sixtySixthBattleScenario.id, campaignStep: 'sixty-sixth-battle', - pages: [...sixtyFifthBattleVictoryPages, ...sixtySixthBattleIntroPages] + pages: sixtySixthBattleIntroPages }, [sixtyFifthBattleScenario.id]: { afterBattleId: sixtyFifthBattleScenario.id, @@ -976,7 +913,7 @@ const sortieFlows: Record = { rewardHint: `예상 보상: ${sixtySixthBattleScenario.title} 개방 / 오장원 귀환 장부`, nextBattleId: sixtySixthBattleScenario.id, campaignStep: 'sixty-sixth-battle', - pages: [...sixtyFifthBattleVictoryPages, ...sixtySixthBattleIntroPages] + pages: sixtySixthBattleIntroPages }, [sixtySixthBattleScenario.id]: { afterBattleId: sixtySixthBattleScenario.id, @@ -986,7 +923,7 @@ const sortieFlows: Record = { '오장원 본영을 지켜 낸 뒤 촉한군은 북벌의 마지막 장부를 닫습니다. 강유는 이어 받은 지도를 품고, 남은 장수들은 병사들이 돌아갈 길을 정리합니다.', rewardHint: '최종 보상: 오장원 에필로그 / 엔딩 개방', campaignStep: 'ending-complete', - pages: [...sixtySixthBattleVictoryPages, ...endingEpiloguePages], + pages: endingEpiloguePages, unavailableNotice: '최종 에필로그와 엔딩을 이미 확인했습니다. 타이틀에서 이어하기를 선택하면 엔딩 화면으로 돌아갑니다.' }, 'northern-campaign-prep-camp-legacy': { diff --git a/src/game/data/campaignPresentationProfiles.ts b/src/game/data/campaignPresentationProfiles.ts new file mode 100644 index 0000000..8992ce2 --- /dev/null +++ b/src/game/data/campaignPresentationProfiles.ts @@ -0,0 +1,490 @@ +import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets'; +import { battleEnvironmentProfileFor } from './battleEnvironmentProfiles'; +import { battleScenarios, type BattleScenarioId } from './battles'; +import { + getCampSoundscape, + type CampMusicGroupId +} from './campSoundscapes'; +import { campSkinDefinitions, type CampSkinId } from './campSkins'; + +export type CampaignPresentationArcId = CampSkinId; + +export type CampaignPresentationStage = + | 'camp' + | 'story' + | 'sortie' + | 'battle' + | 'aftermath'; + +export type CampaignPresentationIntensity = 'quiet' | 'measured' | 'urgent' | 'climactic'; + +export type CampaignPresentationMusicRole = 'anchor' | 'tension' | 'battle' | 'resolution'; + +export type CampaignPresentationAmbienceMode = + | 'arc-default' + | 'battle-environment' + | 'scene-cue'; + +export type CampaignPresentationStatePolicyId = + | 'founding-rally' + | 'coalition-fire' + | 'fractured-road' + | 'strategic-ascent' + | 'tragic-collapse' + | 'frontier-pacification' + | 'legacy-expedition'; + +export type CampaignPresentationStateRule = { + intensity: CampaignPresentationIntensity; + musicRole: CampaignPresentationMusicRole; + ambienceMode: CampaignPresentationAmbienceMode; + preserveSceneMusic: boolean; + preserveSceneAmbience: boolean; +}; + +export type CampaignPresentationStatePolicy = Readonly< + Record +>; + +export type CampaignPresentationPalette = { + accentColor: number; + headerColor: number; + washColor: number; + battleTintColor: number; + battleTintAlpha: number; +}; + +export type CampaignPresentationAudioFamily = { + familyId: CampMusicGroupId; + anchorMusicKey: MusicTrackKey; + tensionMusicKey: MusicTrackKey; + battleMusicKey: MusicTrackKey; + resolutionMusicKey: MusicTrackKey; + defaultAmbienceKey?: AmbienceTrackKey; + anchorMusicVolume: number; + defaultAmbienceVolume?: number; +}; + +export type CampaignArcPresentationProfile = { + id: CampaignPresentationArcId; + label: string; + firstBattleOrdinal: number; + lastBattleOrdinal: number; + skinId: CampSkinId; + statePolicyId: CampaignPresentationStatePolicyId; + audio: CampaignPresentationAudioFamily; + palette: CampaignPresentationPalette; + motifs: readonly string[]; +}; + +export type CampaignBattlePresentationProfile = { + battleId: BattleScenarioId; + battleOrdinal: number; + arc: CampaignArcPresentationProfile; +}; + +export type CampaignPresentationSoundscapeInput = { + battleId: BattleScenarioId | string; + stage: CampaignPresentationStage; + sceneMusicKey?: MusicTrackKey; + sceneAmbienceKey?: AmbienceTrackKey; + sceneAmbienceVolume?: number; +}; + +export type CampaignPresentationSoundscape = { + battleId: BattleScenarioId; + arcId: CampaignPresentationArcId; + stage: CampaignPresentationStage; + intensity: CampaignPresentationIntensity; + musicKey: MusicTrackKey; + musicVolume: number; + ambienceKey?: AmbienceTrackKey; + ambienceVolume?: number; + transition: typeof campaignPresentationTransitionPolicy; +}; + +/** + * This mirrors the fixed loop-channel behavior in SoundDirector. Presentation + * consumers should submit one soundscape request and let SoundDirector keep a + * same-key loop or perform its existing crossfade. + */ +export const campaignPresentationTransitionPolicy = Object.freeze({ + sameTrackBehavior: 'keep-current-loop' as const, + musicCrossfadeMs: 900, + ambienceCrossfadeMs: 1200, + battleAmbienceBehavior: 'specific-profile-only' as const +}); + +export const campaignPresentationStatePolicies: Readonly< + Record +> = { + 'founding-rally': statePolicy('quiet', 'measured', 'urgent', 'urgent', 'measured'), + 'coalition-fire': statePolicy('measured', 'urgent', 'urgent', 'climactic', 'measured'), + 'fractured-road': statePolicy('quiet', 'urgent', 'urgent', 'climactic', 'quiet'), + 'strategic-ascent': statePolicy('quiet', 'measured', 'urgent', 'climactic', 'measured'), + 'tragic-collapse': statePolicy('quiet', 'urgent', 'urgent', 'climactic', 'quiet'), + 'frontier-pacification': statePolicy('measured', 'measured', 'urgent', 'urgent', 'measured'), + 'legacy-expedition': statePolicy('quiet', 'measured', 'urgent', 'climactic', 'quiet') +}; + +type ArcProfileSpec = Omit< + CampaignArcPresentationProfile, + 'id' | 'skinId' | 'audio' | 'palette' +> & { + tensionMusicKey: MusicTrackKey; + battleMusicKey: MusicTrackKey; + resolutionMusicKey: MusicTrackKey; + battleTintColor: number; + battleTintAlpha: number; +}; + +export const campaignArcPresentationProfiles: Readonly< + Record +> = { + 'yellow-turban': arcProfile('yellow-turban', { + label: '황건적 토벌과 의병의 시작', + firstBattleOrdinal: 1, + lastBattleOrdinal: 4, + statePolicyId: 'founding-rally', + tensionMusicKey: 'militia-theme', + battleMusicKey: 'militia-theme', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x6a4b24, + battleTintAlpha: 0.035, + motifs: ['짚신과 의병 깃발', '복숭아빛 맹세', '황진 속 민가'] + }), + 'anti-dong': arcProfile('anti-dong', { + label: '반동탁 연합과 갈라지는 맹세', + firstBattleOrdinal: 5, + lastBattleOrdinal: 6, + statePolicyId: 'coalition-fire', + tensionMusicKey: 'story-dark', + battleMusicKey: 'battle-prep', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x61251d, + battleTintAlpha: 0.045, + motifs: ['연합군 봉화', '붉은 군막', '갈라지는 맹세'] + }), + xuzhou: arcProfile('xuzhou', { + label: '서주 수호와 첫 터전', + firstBattleOrdinal: 7, + lastBattleOrdinal: 9, + statePolicyId: 'fractured-road', + tensionMusicKey: 'story-dark', + battleMusicKey: 'story-dark', + resolutionMusicKey: 'militia-theme', + battleTintColor: 0x1b3850, + battleTintAlpha: 0.045, + motifs: ['성문 등불', '푸른 비안개', '지켜야 할 백성'] + }), + wandering: arcProfile('wandering', { + label: '서주 상실과 천하 유랑', + firstBattleOrdinal: 10, + lastBattleOrdinal: 15, + statePolicyId: 'fractured-road', + tensionMusicKey: 'story-dark', + battleMusicKey: 'story-dark', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x172d46, + battleTintAlpha: 0.05, + motifs: ['해진 깃발', '객군의 밤바람', '끊기지 않는 길'] + }), + wolong: arcProfile('wolong', { + label: '형주 의탁과 와룡의 계책', + firstBattleOrdinal: 16, + lastBattleOrdinal: 18, + statePolicyId: 'strategic-ascent', + tensionMusicKey: 'militia-theme', + battleMusicKey: 'battle-prep', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x4b3921, + battleTintAlpha: 0.035, + motifs: ['대나무 그림자', '가을 서재', '세 번의 발걸음'] + }), + 'red-cliffs': arcProfile('red-cliffs', { + label: '장판과 적벽대전', + firstBattleOrdinal: 19, + lastBattleOrdinal: 22, + statePolicyId: 'coalition-fire', + tensionMusicKey: 'story-dark', + battleMusicKey: 'camp-grand-strategy', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x6b2714, + battleTintAlpha: 0.055, + motifs: ['강 위 불빛', '동남풍', '붉은 돛과 불화살'] + }), + 'jing-yi': arcProfile('jing-yi', { + label: '형주 남부와 익주 확보', + firstBattleOrdinal: 23, + lastBattleOrdinal: 34, + statePolicyId: 'strategic-ascent', + tensionMusicKey: 'militia-theme', + battleMusicKey: 'camp-grand-strategy', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x24452c, + battleTintAlpha: 0.035, + motifs: ['강과 산길', '입촉 지도', '넓어지는 군영'] + }), + 'hanzhong-shuhan': arcProfile('hanzhong-shuhan', { + label: '한중 확보와 촉한 건국', + firstBattleOrdinal: 35, + lastBattleOrdinal: 37, + statePolicyId: 'strategic-ascent', + tensionMusicKey: 'militia-theme', + battleMusicKey: 'battle-prep', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x5a421c, + battleTintAlpha: 0.04, + motifs: ['금빛 왕기', '산성 봉화', '한실의 명분'] + }), + 'jingzhou-crisis': arcProfile('jingzhou-crisis', { + label: '형주 위기와 맥성의 고립', + firstBattleOrdinal: 38, + lastBattleOrdinal: 44, + statePolicyId: 'tragic-collapse', + tensionMusicKey: 'story-dark', + battleMusicKey: 'story-dark', + resolutionMusicKey: 'story-dark', + battleTintColor: 0x4c2f23, + battleTintAlpha: 0.055, + motifs: ['강가 감시대', '꺼져 가는 봉화', '비어 가는 자리'] + }), + 'yiling-baidi': arcProfile('yiling-baidi', { + label: '이릉의 불길과 백제성 유탁', + firstBattleOrdinal: 45, + lastBattleOrdinal: 46, + statePolicyId: 'tragic-collapse', + tensionMusicKey: 'story-dark', + battleMusicKey: 'story-dark', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x3f2630, + battleTintAlpha: 0.06, + motifs: ['재가 된 숲', '백제성 등불', '남겨진 자리'] + }), + nanzhong: arcProfile('nanzhong', { + label: '남중 평정과 칠종칠금', + firstBattleOrdinal: 47, + lastBattleOrdinal: 54, + statePolicyId: 'frontier-pacification', + tensionMusicKey: 'militia-theme', + battleMusicKey: 'camp-frontier', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x274d2d, + battleTintAlpha: 0.045, + motifs: ['남중 강안', '우림의 북', '일곱 번의 약속 장부'] + }), + northern: arcProfile('northern', { + label: '북벌과 오장원의 계승', + firstBattleOrdinal: 55, + lastBattleOrdinal: 66, + statePolicyId: 'legacy-expedition', + tensionMusicKey: 'story-dark', + battleMusicKey: 'camp-frontier', + resolutionMusicKey: 'oath-theme', + battleTintColor: 0x263d5c, + battleTintAlpha: 0.05, + motifs: ['설원 군기', '긴 보급로', '강유에게 남긴 계승 지도'] + }) +}; + +const orderedBattleIds = Object.keys(battleScenarios) as BattleScenarioId[]; + +export const campaignBattlePresentationProfiles: Readonly< + Record +> = Object.freeze( + Object.fromEntries( + orderedBattleIds.map((battleId, index) => { + const battleOrdinal = index + 1; + const arc = campaignPresentationArcForOrdinal(battleOrdinal); + if (!arc) { + throw new Error(`${battleId}: no campaign presentation arc covers battle ${battleOrdinal}.`); + } + return [battleId, Object.freeze({ battleId, battleOrdinal, arc })]; + }) + ) as Record +); + +export function campaignPresentationArcForOrdinal(battleOrdinal: number) { + return Object.values(campaignArcPresentationProfiles).find( + (profile) => + battleOrdinal >= profile.firstBattleOrdinal && battleOrdinal <= profile.lastBattleOrdinal + ); +} + +export function campaignPresentationProfileFor(battleId: BattleScenarioId | string) { + return campaignBattlePresentationProfiles[battleId as BattleScenarioId]; +} + +export function campaignPresentationStateRuleFor( + battleId: BattleScenarioId | string, + stage: CampaignPresentationStage +) { + const profile = campaignPresentationProfileFor(battleId); + return profile + ? campaignPresentationStatePolicies[profile.arc.statePolicyId][stage] + : undefined; +} + +export function resolveCampaignPresentationSoundscape({ + battleId, + stage, + sceneMusicKey, + sceneAmbienceKey, + sceneAmbienceVolume +}: CampaignPresentationSoundscapeInput): CampaignPresentationSoundscape | undefined { + const profile = campaignPresentationProfileFor(battleId); + if (!profile) { + return undefined; + } + + const { arc } = profile; + const rule = campaignPresentationStatePolicies[arc.statePolicyId][stage]; + const musicKey = presentationMusicKeyForStage(arc.audio, rule, stage, sceneMusicKey); + let ambienceKey: AmbienceTrackKey | undefined; + let ambienceVolume: number | undefined; + + if (rule.preserveSceneAmbience && sceneAmbienceKey) { + ambienceKey = sceneAmbienceKey; + ambienceVolume = sceneAmbienceVolume; + } else if (rule.ambienceMode === 'arc-default') { + ambienceKey = arc.audio.defaultAmbienceKey; + ambienceVolume = arc.audio.defaultAmbienceVolume; + } else if (rule.ambienceMode === 'battle-environment') { + const environment = battleEnvironmentProfileFor(profile.battleId); + ambienceKey = environment?.soundscape.ambienceKey; + ambienceVolume = environment?.soundscape.ambienceVolume; + } + + return { + battleId: profile.battleId, + arcId: arc.id, + stage, + intensity: rule.intensity, + musicKey, + musicVolume: arc.audio.anchorMusicVolume, + ambienceKey, + ambienceVolume, + transition: campaignPresentationTransitionPolicy + }; +} + +function presentationMusicKeyForStage( + audio: CampaignPresentationAudioFamily, + rule: CampaignPresentationStateRule, + stage: CampaignPresentationStage, + sceneMusicKey?: MusicTrackKey +) { + if (!sceneMusicKey) { + return musicKeyForRole(audio, rule.musicRole); + } + + // Story data uses a small set of semantic cues. Reinterpret those cues + // through the current campaign arc so preparation, travel, and aftermath + // retain their intent without forcing all 66 battles through one track. + if (stage === 'story') { + if (sceneMusicKey === 'battle-prep') { + return audio.battleMusicKey; + } + if (sceneMusicKey === 'militia-theme') { + return audio.anchorMusicKey; + } + if (sceneMusicKey === 'story-dark') { + return audio.tensionMusicKey; + } + } + + if (stage === 'aftermath') { + if (sceneMusicKey === 'battle-prep' || sceneMusicKey === 'militia-theme') { + return audio.resolutionMusicKey; + } + if (sceneMusicKey === 'story-dark') { + return audio.tensionMusicKey; + } + } + + return rule.preserveSceneMusic + ? sceneMusicKey + : musicKeyForRole(audio, rule.musicRole); +} + +function arcProfile( + id: CampaignPresentationArcId, + spec: ArcProfileSpec +): CampaignArcPresentationProfile { + const skin = campSkinDefinitions[id]; + const soundscape = getCampSoundscape(id); + return Object.freeze({ + id, + label: spec.label, + firstBattleOrdinal: spec.firstBattleOrdinal, + lastBattleOrdinal: spec.lastBattleOrdinal, + skinId: id, + statePolicyId: spec.statePolicyId, + audio: Object.freeze({ + familyId: soundscape.music.id, + anchorMusicKey: soundscape.music.trackKey, + tensionMusicKey: spec.tensionMusicKey, + battleMusicKey: spec.battleMusicKey, + resolutionMusicKey: spec.resolutionMusicKey, + defaultAmbienceKey: soundscape.ambience.trackKey, + anchorMusicVolume: soundscape.music.volume, + defaultAmbienceVolume: soundscape.ambience.volume + }), + palette: Object.freeze({ + accentColor: skin.accentColor, + headerColor: skin.headerColor, + washColor: skin.washColor, + battleTintColor: spec.battleTintColor, + battleTintAlpha: spec.battleTintAlpha + }), + motifs: Object.freeze([...spec.motifs]) + }); +} + +function statePolicy( + camp: CampaignPresentationIntensity, + story: CampaignPresentationIntensity, + sortie: CampaignPresentationIntensity, + battle: CampaignPresentationIntensity, + aftermath: CampaignPresentationIntensity +): CampaignPresentationStatePolicy { + return Object.freeze({ + camp: stateRule(camp, 'anchor', 'arc-default'), + story: stateRule(story, 'tension', 'scene-cue', true, true), + sortie: stateRule(sortie, 'anchor', 'arc-default'), + battle: stateRule(battle, 'battle', 'battle-environment'), + aftermath: stateRule(aftermath, 'resolution', 'scene-cue', true, true) + }); +} + +function stateRule( + intensity: CampaignPresentationIntensity, + musicRole: CampaignPresentationMusicRole, + ambienceMode: CampaignPresentationAmbienceMode, + preserveSceneMusic = false, + preserveSceneAmbience = false +): CampaignPresentationStateRule { + return Object.freeze({ + intensity, + musicRole, + ambienceMode, + preserveSceneMusic, + preserveSceneAmbience + }); +} + +function musicKeyForRole( + audio: CampaignPresentationAudioFamily, + role: CampaignPresentationMusicRole +): MusicTrackKey { + switch (role) { + case 'anchor': + return audio.anchorMusicKey; + case 'tension': + return audio.tensionMusicKey; + case 'battle': + return audio.battleMusicKey; + case 'resolution': + return audio.resolutionMusicKey; + } +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index e4f3736..16abedf 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -364,7 +364,7 @@ const firstBattleStoryOverrides: Record> = { 'first-victory-next': { chapter: '다음 길', speaker: '전황', - text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 다음 전투부터 유비·관우·장비·간옹 네 명 중 최대 세 명을 골라 전장에 맞는 편성을 꾸릴 수 있다.', + text: '탁현에서부터 유비를 도와 온 간옹이 의용군의 군정과 책략을 맡기로 했다. 군량과 말이 넉넉지 않아, 유비·관우·장비·간옹 네 사람 가운데 셋이 선두를 맡고 나머지 한 사람은 후방을 돌보기로 했다.', cutscene: { kind: 'operation', title: '간옹 합류 · 첫 편성 선택', @@ -686,7 +686,7 @@ export const fourthBattleVictoryPages: StoryPage[] = [ bgm: 'battle-prep', chapter: '새로운 혼란', background: 'story-yellow-pursuit', - text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 세 형제의 길은 이제 반동탁의 흐름으로 이어진다.' + text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 동탁의 폭정에 맞서 제후들이 격문을 돌리기 시작했고, 세 형제에게도 그 소식이 닿았다.' } ]; @@ -887,7 +887,7 @@ export const eighthBattleIntroPages: StoryPage[] = [ chapter: '첫 방위전', background: 'story-xuzhou', speaker: '간옹', - text: '현덕, 탁현에서 시작한 편성 선택이 이제 더 넓어졌네. 칼이 필요한 곳과 책략이 필요한 곳, 그리고 말보다 장부가 필요한 곳을 구분해 미축까지 알맞게 배치해야 하네.' + text: '현덕, 이제 미축까지 장부를 맡아 줄 사람이 늘었네. 칼이 필요한 곳, 책략이 필요한 곳, 그리고 군량을 지킬 곳을 가려 누구를 선두에 세우고 누구를 후방에 남길지 정해야 하네.' } ]; @@ -2104,7 +2104,7 @@ export const twentyEighthBattleIntroPages: StoryPage[] = [ chapter: '와룡과 봉추의 첫 편성', background: 'story-yizhou-luo-road', speaker: '마량', - text: '이번 전투부터 방통도 출전 후보가 됩니다. 와룡과 봉추를 모두 데려가면 성루와 보급선 계책은 강해지지만, 전열과 회복 중 하나가 얇아질 수 있습니다.' + text: '방통 선생도 이번 길에 나설 뜻을 밝혔습니다. 와룡과 봉추를 함께 모시면 성루와 보급선을 흔들 계책은 두터워지지만, 전열을 받치거나 부상자를 돌볼 손은 그만큼 줄어듭니다.' }, { id: 'twenty-eighth-fu-pass-sortie', @@ -2166,7 +2166,7 @@ export const twentyNinthBattleIntroPages: StoryPage[] = [ chapter: '법정의 첫 출전 선택', background: 'story-yizhou-luo-road', speaker: '제갈량', - text: '이번 전투부터 법정도 출전 후보입니다. 그를 데려가면 오의와 성문 장수들의 속내를 읽기 쉽지만, 책사가 늘어나는 만큼 고개를 열 전열 무장은 더 신중히 골라야 합니다.' + text: '법정이 낙성의 길잡이를 자청했습니다. 그가 함께하면 오의와 성문 장수들의 속내를 읽기 쉬우나, 군막의 책사가 늘어나는 만큼 고개를 열 선봉은 더욱 신중히 정해야 합니다.' }, { id: 'twenty-ninth-luo-sortie', @@ -2228,7 +2228,7 @@ export const thirtiethBattleIntroPages: StoryPage[] = [ chapter: '오의의 첫 출전 선택', background: 'story-yizhou-luo-road', speaker: '법정', - text: '이번 전투부터 오의도 출전 후보입니다. 낙성의 길을 아는 오의를 데려가면 샛길과 매복 고지를 읽기 쉬우나, 방통까지 지키려면 전열과 보급 후보는 더 좁아집니다.' + text: '오의가 낙성의 샛길과 매복 고지를 안내하겠다고 나섰습니다. 그에게 길을 맡기면 적의 눈을 피하기 쉬우나, 방통까지 호위하려면 전열과 군량을 지킬 사람은 더 적어집니다.' }, { id: 'thirtieth-luofeng-sortie', @@ -2449,7 +2449,7 @@ export const thirtyThirdBattleVictoryPages: StoryPage[] = [ chapter: '익주 확보', background: 'story-chengdu-surrender', speaker: '제갈량', - text: '형주와 익주의 기반이 마련되었습니다. 유비군은 이제 유랑군이 아니라 나라의 뿌리를 갖춘 세력으로 바뀌며, 한중과 촉한 건국을 향한 다음 장을 준비합니다.' + text: '형주와 익주의 기반이 마련되었습니다. 우리 군은 이제 떠도는 의병이 아니라 백성과 땅을 지킬 뿌리를 얻었습니다. 북쪽 한중을 바라보고, 마침내 한실을 다시 세울 깃발을 올릴 때입니다.' } ]; @@ -3091,7 +3091,7 @@ export const fortySecondBattleVictoryPages: StoryPage[] = [ chapter: '갈라지는 형주', background: 'story-jingzhou-crisis', speaker: '제갈량', - text: '나루 하나는 지켰으나, 강릉과 공안의 장수들이 흔들리기 시작했습니다. 다음 장에서는 형주 후방의 균열이 더 노골적인 배신과 침투로 이어질 것입니다.' + text: '나루 하나는 지켰으나 강릉과 공안의 장수들이 흔들리기 시작했습니다. 지금 후방의 틈을 막지 못하면, 강동의 손길이 배신과 침투로 그 균열을 벌릴 것입니다.' } ]; @@ -3216,7 +3216,7 @@ export const fortyFourthBattleVictoryPages: StoryPage[] = [ chapter: '형주의 상실', background: 'story-maicheng-isolation', speaker: '마량', - text: '맥성의 퇴로는 열렸으나 강릉과 공안의 마음은 이미 갈라졌습니다. 다음 장에서는 형주 상실의 대가와 유비의 분노가 이릉으로 번지는 흐름을 준비해야 합니다.' + text: '맥성의 퇴로는 열렸으나 강릉과 공안의 마음은 이미 갈라졌습니다. 형주를 잃었다는 급보가 익주에 닿으면, 주군의 슬픔과 분노는 끝내 이릉까지 군을 몰고 갈 것입니다.' } ]; @@ -3344,7 +3344,7 @@ export const fortySixthBattleVictoryPages: StoryPage[] = [ background: 'story-yiling-fire-attack', speaker: '유비', portrait: 'liuBei', - text: '뜻을 세운 날은 멀어졌고, 손에 남은 것은 불탄 깃발뿐이오. 그러나 남은 이들이 있다면 뜻도 아직 끝나지 않았소. 다음 장에서는 백제성에서 남은 나라의 길을 정해야 하오.' + text: '뜻을 세운 날은 멀어졌고, 손에 남은 것은 불탄 깃발뿐이오. 그러나 남은 이들이 있다면 뜻도 아직 끝나지 않았소. 백제성으로 가서 남은 나라의 길을 정해야 하오.' } ]; @@ -3449,7 +3449,7 @@ export const fortySeventhBattleVictoryPages: StoryPage[] = [ chapter: '남만 평정의 시작', background: 'story-nanzhong', speaker: '제갈량', - text: '첫 길은 열렸습니다. 이제 남중의 여러 세력을 모두 적으로 만들지 않으면서, 반란의 중심을 하나씩 마주해야 합니다. 다음 장에서는 맹획의 본대와 처음으로 맞섭니다.' + text: '첫 길은 열렸습니다. 남중의 여러 세력을 모두 적으로 돌리지 않으면서 반란의 중심을 하나씩 마주해야 합니다. 맹획이 깊은 산채에서 본대를 모으고 있으니, 이제 그와 직접 맞설 때입니다.' } ]; @@ -3555,7 +3555,7 @@ export const fortyNinthBattleVictoryPages: StoryPage[] = [ chapter: '남중의 마음', background: 'story-nanzhong-captures', speaker: '황권', - text: '포로 명부와 군량 장부가 어긋나지 않았습니다. 다음 싸움에서는 맹획만이 아니라 그를 따르는 호족들의 마음도 흔들릴 것입니다. 회유는 이제 전투 뒤의 일이 아니라 전투 중의 목표가 되었습니다.' + text: '포로 명부와 군량 장부가 어긋나지 않았습니다. 맹획만이 아니라 그를 따르는 호족들의 마음도 흔들릴 것입니다. 이제는 칼을 맞대는 동안에도 그들이 돌아설 길을 함께 열어 두어야 합니다.' } ]; @@ -3608,7 +3608,7 @@ export const fiftiethBattleVictoryPages: StoryPage[] = [ chapter: '전장 밖의 회유', background: 'story-nanzhong-captures', speaker: '제갈량', - text: '남중을 얻는 길은 점점 분명해지고 있습니다. 다음 장에서는 맹획을 따르던 호족들과 직접 이야기를 나누고, 전투 전 선택이 전장 목표에 영향을 주는 흐름을 준비해야 합니다.' + text: '남중을 얻는 길은 점점 분명해지고 있습니다. 맹획을 따르던 호족들을 군막으로 불러 속내를 듣겠습니다. 우리가 어떤 보증을 내어 주느냐에 따라, 다음 싸움에서 지켜야 할 마을과 물러날 적이 달라질 것입니다.' } ]; @@ -3892,7 +3892,7 @@ export const fiftyFifthBattleVictoryPages: StoryPage[] = [ chapter: '북벌의 첫 발', background: 'story-northern', speaker: '제갈량', - text: '첫 길은 열렸으나, 위나라의 본대는 아직 물러나지 않았습니다. 오늘 얻은 것은 승리 하나가 아니라 산길, 보급, 민심이 이어진 첫 전선입니다. 이제부터는 무장들의 배치와 보급의 선택이 더 중요해질 것입니다.' + text: '첫 길은 열렸으나 위나라의 본대는 아직 물러나지 않았습니다. 오늘 얻은 것은 승리 하나가 아니라 산길과 보급, 민심이 이어진 첫 전선입니다. 이제는 장수마다 맡길 길을 가르고, 군량이 끊기지 않도록 후방까지 헤아려야 합니다.' }, { id: 'fifty-fifth-next-front', @@ -3998,7 +3998,7 @@ export const fiftySeventhBattleVictoryPages: StoryPage[] = [ chapter: '새 장수', background: 'story-jieting-crisis', speaker: '강유', - text: '강유는 위군의 의심과 백성의 말 사이에서 끝내 마음을 정했습니다. 오늘부터 그는 촉한의 깃발 아래 서지만, 새로 얻은 장수 하나가 곧바로 모든 길을 열어 주지는 않습니다. 다음 출전부터는 더 넓어진 장수진 안에서 누구를 고를지 판단해야 합니다.' + text: '위군의 의심과 백성의 말 사이에서 오래 망설였으나, 이제 마음을 정했습니다. 오늘부터 촉한의 깃발 아래 서겠습니다. 다만 제가 안다는 길이 모든 전장을 열지는 못하니, 장수마다 가장 잘 지킬 곳을 맡겨 주십시오.' }, { id: 'fifty-seventh-next-front', @@ -4006,7 +4006,7 @@ export const fiftySeventhBattleVictoryPages: StoryPage[] = [ chapter: '북벌의 다음 부담', background: 'story-jieting-crisis', speaker: '제갈량', - text: '가정의 위기는 수습되었으나 위군은 이제 우리의 길과 사람을 모두 보았습니다. 강유를 얻은 기쁨과 마속의 실책을 함께 품고, 다음 북벌에서는 더 엄격한 편성과 보급 판단이 필요할 것입니다.' + text: '가정의 위기는 수습되었으나 위군은 이제 우리의 길과 사람을 모두 보았습니다. 강유를 얻은 기쁨에 취하거나 마속의 실책을 덮지 않겠습니다. 다음 북벌에서는 장수마다 맡을 길을 분명히 하고, 군량이 닿는 거리부터 헤아리겠습니다.' } ]; @@ -4033,7 +4033,7 @@ export const fiftyEighthBattleIntroPages: StoryPage[] = [ chapter: '새로운 그림자', background: 'story-jieting-crisis', speaker: '마량', - text: '장합의 추격만 막으면 끝나는 전장이 아닙니다. 위군 본영 뒤에서 사마의가 군량 수레가 어디로 빠지는지 보고 있습니다. 강유를 내보내면 길은 넓어지지만, 출전 자리는 하나 줄어듭니다. 이번 편성은 누구를 믿고 퇴로를 맡길지의 선택입니다.' + text: '장합의 추격만 막으면 끝나는 전장이 아닙니다. 위군 본영 뒤에서 사마의가 군량 수레가 어디로 빠지는지 보고 있습니다. 강유에게 낮은 길을 맡기려면 다른 장수 한 사람은 후방을 지켜야 합니다. 누구를 믿고 퇴로를 맡길지 먼저 정해야 합니다.' } ]; @@ -4051,7 +4051,7 @@ export const fiftyEighthBattleVictoryPages: StoryPage[] = [ chapter: '강유의 첫 평가', background: 'story-jieting-crisis', speaker: '제갈량', - text: '강유는 천수의 길을 숨기지 않았고, 조운과 마대는 병사들이 돌아올 시간을 벌었습니다. 왕평의 물길 판단도 여전히 군의 목숨줄입니다. 이제부터 출전 명단은 단순히 강한 장수를 고르는 일이 아니라, 어떤 길을 택할지 정하는 일이 됩니다.' + text: '강유는 천수의 길을 숨기지 않았고, 조운과 마대는 병사들이 돌아올 시간을 벌었습니다. 왕평의 물길 판단도 여전히 군의 목숨줄입니다. 앞으로 장수를 정할 때에는 힘만 볼 것이 아니라, 그에게 어떤 길을 맡길지도 함께 헤아려야 합니다.' }, { id: 'fifty-eighth-next-northern-plan', @@ -4192,7 +4192,7 @@ export const sixtyFirstBattleIntroPages: StoryPage[] = [ chapter: '넓어진 책임', background: 'story-hanzhong-rain', speaker: '마량', - text: '백성은 촉한의 깃발만 보고 안심하지 않습니다. 비가 오면 수레는 늦고, 군문이 흔들리면 새 고을은 먼저 두려워합니다. 출전 명단에서 누가 길을 잡고 누가 장부를 지킬지 분명히 해야 합니다.' + text: '백성은 촉한의 깃발만 보고 안심하지 않습니다. 비가 오면 수레는 늦고, 군문이 흔들리면 새 고을은 먼저 두려워합니다. 군의를 열어 누가 길을 잡고 누가 장부를 지킬지 분명히 해야 합니다.' } ]; @@ -4245,7 +4245,7 @@ export const sixtySecondBattleIntroPages: StoryPage[] = [ chapter: '보급을 공세로', background: 'story-qishan-renewed', speaker: '황권', - text: '지난 전투에서 지킨 장부를 이번에는 밀어 올릴 차례입니다. 이엄을 후방에 남길지, 마대의 측면 기동을 넣을지, 혹은 마량의 민심 문서를 앞세울지 출전 명단에서 먼저 정해야 합니다.' + text: '지난 싸움에서 지킨 장부를 이제 북쪽으로 밀어 올릴 차례입니다. 이엄을 후방에 남길지, 마대에게 측면을 맡길지, 혹은 마량의 민심 문서를 앞세울지 군의를 열어 먼저 정해야 합니다.' } ]; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 000e22e..f460e71 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -3,7 +3,7 @@ import { soundDirector, type StrategyImpactKind } from '../audio/SoundDirector'; import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { battleMapAssets } from '../data/battleMapAssets'; -import { firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; +import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; import { defaultBattleScenario, getBattleScenario, @@ -20,6 +20,10 @@ import { type BattleEnvironmentParticleProfile, type BattleEnvironmentProfile } from '../data/battleEnvironmentProfiles'; +import { + campaignPresentationProfileFor, + resolveCampaignPresentationSoundscape +} from '../data/campaignPresentationProfiles'; import { getSortieFlow } from '../data/campaignFlow'; import { enemyIntentOpeningGuide, @@ -1750,8 +1754,6 @@ type ResultButtonView = { type ResultButtonTone = 'primary' | 'secondary' | 'utility' | 'ghost'; type ResultButtonKind = 'continue' | 'retry' | 'title' | 'detail'; type ResultSettlementStatus = 'hidden' | 'pending' | 'animating' | 'complete'; -type ResultContinueDestination = 'victory-story' | 'camp'; - type ThreatTile = { x: number; y: number; @@ -4018,10 +4020,15 @@ export class BattleScene extends Phaser.Scene { this.combatPresentationLast = undefined; this.fastForwardHeld = false; const environmentProfile = battleEnvironmentProfileFor(battleScenario.id); + const presentationSoundscape = resolveCampaignPresentationSoundscape({ + battleId: battleScenario.id, + stage: 'battle' + }); soundDirector.playSoundscape({ - musicKey: 'battle-prep', - ambienceKey: environmentProfile?.soundscape.ambienceKey, - ambienceVolume: environmentProfile?.soundscape.ambienceVolume + musicKey: presentationSoundscape?.musicKey ?? 'battle-prep', + musicVolume: presentationSoundscape?.musicVolume, + ambienceKey: presentationSoundscape?.ambienceKey ?? environmentProfile?.soundscape.ambienceKey, + ambienceVolume: presentationSoundscape?.ambienceVolume ?? environmentProfile?.soundscape.ambienceVolume }); this.input.mouse?.disableContextMenu(); this.installBattleAudioUnlock(); @@ -5349,22 +5356,29 @@ export class BattleScene extends Phaser.Scene { private drawBattleEnvironment() { this.clearBattleEnvironment(); const profile = battleEnvironmentProfileFor(battleScenario.id); + const presentation = campaignPresentationProfileFor(battleScenario.id); this.battleEnvironmentProfile = profile; - if (!profile || !this.mapMask) { + if (!this.mapMask || (!profile && !presentation)) { return; } - this.battleEnvironmentRandomState = this.battleEnvironmentSeed(profile.id); + this.battleEnvironmentRandomState = this.battleEnvironmentSeed(profile?.id ?? `arc-${presentation!.arc.id}`); const { mapX, mapY, mapWidth, mapHeight } = this.layout; - if (profile.tint.alpha > 0) { - const tint = this.add.rectangle(mapX, mapY, mapWidth, mapHeight, profile.tint.color, profile.tint.alpha); + const tintColor = profile?.tint.color ?? presentation!.arc.palette.battleTintColor; + const tintAlpha = profile?.tint.alpha ?? presentation!.arc.palette.battleTintAlpha; + if (tintAlpha > 0) { + const tint = this.add.rectangle(mapX, mapY, mapWidth, mapHeight, tintColor, tintAlpha); tint.setOrigin(0); tint.setDepth(battleEnvironmentTintDepth); tint.setMask(this.mapMask); - tint.setName(`battle-environment-${profile.id}-tint`); + tint.setName(`battle-environment-${profile?.id ?? `arc-${presentation!.arc.id}`}-tint`); this.battleEnvironmentObjects.push(tint); } + if (!profile) { + return; + } + const reducedMotion = isVisualMotionReduced(); if (!reducedMotion && profile.haze) { this.createBattleEnvironmentHaze(profile.haze); @@ -13377,16 +13391,16 @@ export class BattleScene extends Phaser.Scene { void this.playResultSettlementAnimation(animationToken); } - private resultContinueRoute(): { destination: ResultContinueDestination; label: string } { - if (battleScenario.id === 'first-battle-zhuo-commandery') { - return { destination: 'victory-story', label: '승리 이야기로' }; + private resultContinueRoute(outcome = this.battleOutcome) { + if (outcome !== 'victory') { + return undefined; } - return { destination: 'camp', label: '군영으로' }; + return { destination: 'victory-story' as const, label: '승리 이야기로' }; } private resultContinueCtaLabel() { return this.resultSettlementStatus === 'complete' - ? this.resultContinueRoute().label + ? this.resultContinueRoute()?.label ?? '군영으로' : '정산 완료하기'; } @@ -13464,15 +13478,17 @@ export class BattleScene extends Phaser.Scene { return; } - const { destination } = this.resultContinueRoute(); - if (destination === 'victory-story') { - this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { - pages: firstBattleVictoryPages, - nextScene: 'CampScene' - })); + if (!this.resultContinueRoute()) { return; } - this.beginResultNavigation(() => startLazyScene(this, 'CampScene')); + + this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { + pages: battleScenario.victoryPages, + nextScene: battleScenario.nextCampScene, + nextSceneData: { completedAftermathBattleId: battleScenario.id }, + presentationBattleId: battleScenario.id, + presentationStage: 'aftermath' + })); } private hideBattleResult() { @@ -13749,11 +13765,16 @@ export class BattleScene extends Phaser.Scene { : {}) }; this.hideResultFormationReview(); - if (outcome === 'victory' && battleScenario.id === 'first-battle-zhuo-commandery') { + if (outcome === 'victory') { this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { - pages: firstBattleVictoryPages, - nextScene: 'CampScene', - nextSceneData: campSceneData + pages: battleScenario.victoryPages, + nextScene: battleScenario.nextCampScene, + nextSceneData: { + ...campSceneData, + completedAftermathBattleId: battleScenario.id + }, + presentationBattleId: battleScenario.id, + presentationStage: 'aftermath' })); return; } @@ -28207,6 +28228,9 @@ export class BattleScene extends Phaser.Scene { private battleEnvironmentDebugState() { const profile = this.battleEnvironmentProfile; + const presentation = campaignPresentationProfileFor(battleScenario.id); + const soundscape = resolveCampaignPresentationSoundscape({ battleId: battleScenario.id, stage: 'battle' }); + const tintAlpha = profile?.tint.alpha ?? presentation?.arc.palette.battleTintAlpha ?? 0; const activeObjects = this.battleEnvironmentObjects.filter((object) => object.active); const depths = activeObjects.map((object) => ( object as Phaser.GameObjects.GameObject & { depth: number } @@ -28215,16 +28239,18 @@ export class BattleScene extends Phaser.Scene { (object as Phaser.GameObjects.GameObject & { mask?: Phaser.Display.Masks.GeometryMask | null }).mask === this.mapMask )).length; return { - profileId: profile?.id ?? null, - label: profile?.label ?? null, - effect: profile?.effect ?? null, - ambienceKey: profile?.soundscape.ambienceKey ?? null, - ambienceVolume: profile?.soundscape.ambienceVolume ?? 0, + profileId: profile?.id ?? (presentation ? `arc-${presentation.arc.id}` : null), + presentationArcId: presentation?.arc.id ?? null, + label: profile?.label ?? presentation?.arc.label ?? null, + effect: profile?.effect ?? (presentation ? 'arc-wash' : null), + musicKey: soundscape?.musicKey ?? null, + ambienceKey: soundscape?.ambienceKey ?? null, + ambienceVolume: soundscape?.ambienceVolume ?? 0, reducedMotion: isVisualMotionReduced(), - active: Boolean(profile && activeObjects.length > 0), + active: activeObjects.length > 0, particleCount: this.battleEnvironmentParticles.filter((view) => view.object.active).length, hazeCount: this.battleEnvironmentHaze.filter((view) => view.object.active).length, - tintCount: profile?.tint.alpha && activeObjects.length > 0 ? 1 : 0, + tintCount: tintAlpha > 0 && activeObjects.length > 0 ? 1 : 0, objectCount: activeObjects.length, maskedObjectCount, masked: activeObjects.length === 0 ? true : maskedObjectCount === activeObjects.length, @@ -28239,7 +28265,7 @@ export class BattleScene extends Phaser.Scene { height: this.layout.mapHeight } : null, - tintAlpha: profile?.tint.alpha ?? 0, + tintAlpha, fixedPool: true, verification: battleEnvironmentProfileVerification }; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f87f9b4..e42f4b2 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -171,6 +171,7 @@ import { campaignVictoryRewardPresentation, campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, + completeCampaignAftermath, defaultCampaignReserveTrainingFocusId, dismissCampaignVictoryRewardNotice, ensureCampaignRosterUnits, @@ -825,6 +826,7 @@ type CampSceneData = { openSortieImprovement?: boolean; retryBattleId?: BattleScenarioId; skipIntroStory?: boolean; + completedAftermathBattleId?: BattleScenarioId; }; const campSupplies: CampSupplyDefinition[] = [ @@ -11398,6 +11400,9 @@ export class CampScene extends Phaser.Scene { init(data?: CampSceneData) { this.ownedCampTextureKeys.clear(); + if (data?.completedAftermathBattleId) { + completeCampaignAftermath(data.completedAftermathBattleId); + } this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep); this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement); this.retrySortieBattleId = data?.retryBattleId; @@ -19858,14 +19863,18 @@ export class CampScene extends Phaser.Scene { } this.startCampNavigationWithCampaignStep(flow.campaignStep, 'StoryScene', { pages: flow.pages, - nextScene: flow.campaignStep === 'ending-complete' ? 'EndingScene' : 'CampScene' + nextScene: flow.campaignStep === 'ending-complete' ? 'EndingScene' : 'CampScene', + presentationBattleId: flow.afterBattleId, + presentationStage: flow.campaignStep === 'ending-complete' ? 'aftermath' : 'story' }); return; } if (flow.pages.length > 0) { this.startCampNavigation('StoryScene', { pages: flow.pages, - nextScene: 'CampScene' + nextScene: 'CampScene', + presentationBattleId: flow.afterBattleId, + presentationStage: 'story' }); return; } @@ -19905,6 +19914,8 @@ export class CampScene extends Phaser.Scene { this.startCampNavigationWithCampaignStep(flow.campaignStep, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', + presentationBattleId: flow.nextBattleId, + presentationStage: 'story', nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index b921892..da953fc 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; +import { musicTracks, type MusicTrackKey } from '../audio/audioAssets'; import { equipmentItemIdForInventoryLabel } from '../data/battleItems'; import { battleUiIconFrames, @@ -26,6 +27,11 @@ import { type StoryEnvironmentParticleProfile, type StoryEnvironmentProfile } from '../data/storyEnvironmentProfiles'; +import { + campaignPresentationProfileFor, + resolveCampaignPresentationSoundscape, + type CampaignPresentationStage +} from '../data/campaignPresentationProfiles'; import { ensureUnitAnimations, loadUnitBaseSheets, @@ -53,6 +59,8 @@ type StorySceneData = { pages?: StoryPage[]; nextScene?: string; nextSceneData?: Record; + presentationBattleId?: string; + presentationStage?: Extract; }; type StoryPageAssetRequest = { @@ -183,6 +191,9 @@ export class StoryScene extends Phaser.Scene { private selectedStoryBackgroundKeys = storyBackgroundKeysForPages(prologuePages); private nextScene = 'BattleScene'; private nextSceneData?: Record; + private presentationBattleId?: string; + private presentationStage: Extract = 'story'; + private presentationWash?: Phaser.GameObjects.Rectangle; private transitioning = false; private background?: Phaser.GameObjects.Image; private chapterText?: Phaser.GameObjects.Text; @@ -223,6 +234,9 @@ export class StoryScene extends Phaser.Scene { this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); this.nextScene = data?.nextScene ?? 'BattleScene'; this.nextSceneData = data?.nextSceneData; + this.presentationBattleId = data?.presentationBattleId; + this.presentationStage = data?.presentationStage ?? 'story'; + this.presentationWash = undefined; this.pageIndex = 0; this.transitioning = false; this.readyStoryPageIndexes.clear(); @@ -260,7 +274,7 @@ export class StoryScene extends Phaser.Scene { return { scene: this.scene.key, - ready: this.loadingText === undefined && this.background !== undefined, + ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true, pageIndex: this.pageIndex, totalPages: this.pages.length, currentPageId: page?.id, @@ -300,6 +314,7 @@ export class StoryScene extends Phaser.Scene { items: this.rewardDisplay.items.map((item) => ({ ...item, icon: { ...item.icon } })) }, environment: this.storyEnvironmentDebugState(), + presentation: this.storyPresentationDebugState(), assetStreaming: { currentPageReady: this.readyStoryPageIndexes.has(this.pageIndex), nextPageIndex: this.pageIndex + 1 < this.pages.length ? this.pageIndex + 1 : null, @@ -354,6 +369,9 @@ export class StoryScene extends Phaser.Scene { this.loadingText = undefined; this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0]))); this.background.setDepth(0); + this.presentationWash = this.add.rectangle(0, 0, width, height, 0x000000, 0); + this.presentationWash.setOrigin(0); + this.presentationWash.setDepth(0.12); this.drawSceneShade(width, height); this.drawDialogPanel(width, height); this.showPage(0, true); @@ -771,11 +789,23 @@ export class StoryScene extends Phaser.Scene { private applyPage(page: StoryPage) { const backgroundKey = this.pageBackgroundKey(page); const environmentProfile = storyEnvironmentProfileFor(page.background, page.id); + const sceneMusicKey = this.storyMusicTrackKey(page.bgm); + const presentationSoundscape = this.presentationBattleId && sceneMusicKey + ? resolveCampaignPresentationSoundscape({ + battleId: this.presentationBattleId, + stage: this.presentationStage, + sceneMusicKey, + sceneAmbienceKey: environmentProfile?.ambienceKey, + sceneAmbienceVolume: environmentProfile?.ambienceVolume + }) + : undefined; soundDirector.playSoundscape({ - musicKey: page.bgm, - ambienceKey: environmentProfile?.ambienceKey, - ambienceVolume: environmentProfile?.ambienceVolume + musicKey: presentationSoundscape?.musicKey ?? page.bgm, + musicVolume: presentationSoundscape?.musicVolume, + ambienceKey: presentationSoundscape?.ambienceKey ?? environmentProfile?.ambienceKey, + ambienceVolume: presentationSoundscape?.ambienceVolume ?? environmentProfile?.ambienceVolume }); + this.applyStoryPresentationWash(); this.applyBackground(page); this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id); this.renderCutscene(page); @@ -809,6 +839,37 @@ export class StoryScene extends Phaser.Scene { ); } + private applyStoryPresentationWash() { + const profile = this.presentationBattleId + ? campaignPresentationProfileFor(this.presentationBattleId) + : undefined; + if (!profile || !this.presentationWash) { + this.presentationWash?.setVisible(false); + return; + } + + const alpha = Phaser.Math.Clamp(profile.arc.palette.battleTintAlpha * 0.62, 0.018, 0.038); + this.presentationWash.setFillStyle(profile.arc.palette.washColor, alpha); + this.presentationWash.setVisible(true); + } + + private storyMusicTrackKey(value: string | undefined): MusicTrackKey | undefined { + return value && value in musicTracks ? value as MusicTrackKey : undefined; + } + + private storyPresentationDebugState() { + const profile = this.presentationBattleId + ? campaignPresentationProfileFor(this.presentationBattleId) + : undefined; + return { + battleId: profile?.battleId ?? this.presentationBattleId ?? null, + arcId: profile?.arc.id ?? null, + stage: this.presentationStage, + washColor: profile?.arc.palette.washColor ?? null, + washVisible: this.presentationWash?.visible ?? false + }; + } + private progressActionLabel(isLastPage = this.pageIndex >= this.pages.length - 1) { if (!isLastPage) { return '다음'; diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index be43cfe..01ccf81 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -882,7 +882,10 @@ export class TitleScene extends Phaser.Scene { soundDirector.resume(); soundDirector.playSelect(); startNewCampaign(); - void this.navigateTo('StoryScene'); + void this.navigateTo('StoryScene', { + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); } private continueGame(slot?: number) { @@ -905,6 +908,18 @@ export class TitleScene extends Phaser.Scene { }); return; } + if (campaign.pendingAftermathBattleId) { + const { battleScenarios } = await import('../data/battles'); + const aftermathScenario = battleScenarios[campaign.pendingAftermathBattleId]; + await this.navigateTo('StoryScene', { + pages: aftermathScenario.victoryPages, + nextScene: aftermathScenario.nextCampScene, + nextSceneData: { completedAftermathBattleId: aftermathScenario.id }, + presentationBattleId: aftermathScenario.id, + presentationStage: 'aftermath' + }); + return; + } if (campaign.step === 'ending-complete') { await this.navigateTo('EndingScene'); return; @@ -931,11 +946,19 @@ export class TitleScene extends Phaser.Scene { if (campaign.step === 'first-victory-story') { const { firstBattleVictoryPages } = await import('../data/scenario'); - await this.navigateTo('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + await this.navigateTo('StoryScene', { + pages: firstBattleVictoryPages, + nextScene: 'TitleScene', + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'aftermath' + }); return; } - await this.navigateTo('StoryScene'); + await this.navigateTo('StoryScene', { + presentationBattleId: 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); } private async navigateTo(sceneKey: 'StoryScene' | 'BattleScene' | 'CampScene' | 'EndingScene', data?: Record) { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index b9878b5..1d22179 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -490,6 +490,7 @@ export type CampaignState = { dismissedVictoryRewardNoticeBattleIds: string[]; battleHistory: Record; latestBattleId?: string; + pendingAftermathBattleId?: BattleScenarioId; firstBattleReport?: FirstBattleReport; }; @@ -606,6 +607,16 @@ const campaignStepProgressLabels: Partial): CampaignState { ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); + const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' && + normalized.pendingAftermathBattleId in battleScenarios + ? normalized.pendingAftermathBattleId as BattleScenarioId + : undefined; + const pendingAftermathStep = pendingAftermathBattleId + ? campaignBattleSteps[pendingAftermathBattleId]?.victory + : undefined; + if ( + pendingAftermathBattleId && + normalized.latestBattleId === pendingAftermathBattleId && + normalized.step === pendingAftermathStep && + normalized.firstBattleReport?.battleId === pendingAftermathBattleId && + normalized.firstBattleReport.outcome === 'victory' && + normalized.battleHistory[pendingAftermathBattleId]?.outcome === 'victory' + ) { + normalized.pendingAftermathBattleId = pendingAftermathBattleId; + } else { + delete normalized.pendingAftermathBattleId; + } if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); }