diff --git a/docs/audio-sources.md b/docs/audio-sources.md index 5aea730..eec1cf3 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -29,6 +29,22 @@ The following files are generated deterministically by `scripts/generate-camp-au | `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps | | `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps | +## Battlefield Ambience and Tactical Cues + +The source files in this section were downloaded from their official Pixabay detail pages on 2026-07-21. They use the Pixabay Content License linked above. + +| Project file | Role | Pixabay title | Creator | Source | +| --- | --- | --- | --- | --- | +| `src/assets/audio/ambience/battle-rain.mp3` | Seamless battlefield rain bed | Gentle Midday Rain | DRAGON-STUDIO | https://pixabay.com/sound-effects/nature-gentle-midday-rain-499668/ | +| `src/assets/audio/ambience/battle-fire.mp3` | Seamless battlefield fire bed | Fire Sounds | DRAGON-STUDIO | https://pixabay.com/sound-effects/film-special-effects-fire-sounds-405444/ | +| `src/assets/audio/sfx/ally-turn.mp3` | Brighter two-beat allied turn cue | Ancient War Drums | DRAGON-STUDIO | https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/ | +| `src/assets/audio/sfx/enemy-turn.mp3` | Lower, slower enemy turn cue | Ancient War Drums | DRAGON-STUDIO | https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/ | +| `src/assets/audio/sfx/operation-alert.mp3` | Faster tactical event alert | Ancient War Drums | DRAGON-STUDIO | https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/ | +| `src/assets/audio/sfx/objective-success.mp3` | Objective achieved signal | Level Complete | Universfield | https://pixabay.com/sound-effects/film-special-effects-level-complete-143022/ | +| `src/assets/audio/sfx/objective-failure.mp3` | Objective failed signal | failure 1 | Leszek_Szary (Freesound) | https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/ | + +All seven files were resampled to 48 kHz stereo MP3. Tactical cues were silence-trimmed, equalized by role, and balanced to approximately -17.5 to -19 LUFS. The rain and fire recordings were reduced to stable excerpts, balanced near -25 LUFS, and rebuilt with a one-second end-to-start crossfade so repeated playback does not introduce a hard seam. + ## Sound Effects | Project file | Pixabay title | Creator | Source | diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 08d3b87..413e0f4 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -19,6 +19,14 @@ const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1'; const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH; const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1'; const baselineViewport = desktopBrowserViewport; +const battleEnvironmentExpectations = new Map([ + ['ninth-battle-xuzhou-gate-night-raid', { profileId: 'xuzhou-night-mist', ambienceKey: 'river-night-ambience', objectCount: 5, depthRange: [3.04, 3.12] }], + ['twenty-second-battle-red-cliffs-fire', { profileId: 'red-cliffs-firestorm', ambienceKey: 'battle-fire', objectCount: 26, depthRange: [3.04, 3.3] }], + ['fortieth-battle-han-river-flood', { profileId: 'han-river-flood-rain', ambienceKey: 'battle-rain', objectCount: 34, depthRange: [3.04, 3.3] }], + ['forty-sixth-battle-yiling-fire', { profileId: 'yiling-burning-forest', ambienceKey: 'battle-fire', objectCount: 29, depthRange: [3.04, 3.3] }], + ['sixty-first-battle-hanzhong-rain-defense', { profileId: 'hanzhong-defense-rain', ambienceKey: 'battle-rain', objectCount: 39, depthRange: [3.04, 3.3] }], + ['sixty-sixth-battle-wuzhang-final', { profileId: 'wuzhang-snow-wind', ambienceKey: 'mountain-wind-ambience', objectCount: 35, depthRange: [3.04, 3.3] }] +]); function withRenderer(url, renderer) { if (!['canvas', 'webgl'].includes(renderer)) { @@ -1348,6 +1356,7 @@ async function setupBattle(page, battle) { { timeout: 90000 } ); await startDeploymentIfNeeded(page, battle.id); + await verifyBattleEnvironment(page, battle.id); await verifySortieDoctrine(page, battle); await normalizeRepresentativeBattleUnits(page, battle); } @@ -1535,6 +1544,7 @@ async function setupCumulativeBattle(page, battle, firstBattle, route) { { timeout: 90000 } ); await startDeploymentIfNeeded(page, battle.id); + await verifyBattleEnvironment(page, battle.id); await verifySortieDoctrine(page, battle); await normalizeRepresentativeBattleUnits(page, battle); return preparation; @@ -1653,6 +1663,48 @@ async function startDeploymentIfNeeded(page, battleId) { ); } +async function verifyBattleEnvironment(page, battleId) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const environment = state?.environment; + const expected = battleEnvironmentExpectations.get(battleId); + const actualAmbienceKey = state?.audio?.currentAmbienceKey ?? state?.audio?.pendingAmbienceKey ?? null; + + if (!expected) { + if ( + environment?.profileId !== null || + actualAmbienceKey !== null || + environment?.active !== false || + environment?.objectCount !== 0 || + environment?.particleCount !== 0 || + environment?.hazeCount !== 0 + ) { + throw new Error(`Unexpected battle environment for ${battleId}: ${JSON.stringify(environment)}`); + } + return; + } + + const depthRange = environment?.depthRange; + const depthMatches = depthRange && + Math.abs(depthRange.minimum - expected.depthRange[0]) < 0.001 && + Math.abs(depthRange.maximum - expected.depthRange[1]) < 0.001; + if ( + environment?.profileId !== expected.profileId || + environment?.ambienceKey !== expected.ambienceKey || + actualAmbienceKey !== expected.ambienceKey || + environment?.active !== true || + environment?.objectCount !== expected.objectCount || + environment?.maskedObjectCount !== expected.objectCount || + environment?.masked !== true || + environment?.fixedPool !== true || + environment?.verification?.valid !== true || + !depthMatches + ) { + throw new Error( + `Battle environment mismatch for ${battleId}: ${JSON.stringify({ expected, actualAmbienceKey, environment })}` + ); + } +} + async function verifySortieDoctrine(page, battle) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const doctrine = state?.sortieDoctrine; diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index bcc26ad..9ccadd5 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -11,6 +11,44 @@ const sourceFilesToScan = [ 'src/game/scenes/StoryScene.ts', 'src/game/scenes/TitleScene.ts' ]; +const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire']; +const requiredTacticalCueKeys = [ + 'ally-turn', + 'enemy-turn', + 'operation-alert', + 'objective-success', + 'objective-failure' +]; +const requiredBattleSceneAudioMethods = [ + 'playSoundscape', + 'playAllyTurnCue', + 'playEnemyTurnCue', + 'playOperationAlert', + 'playObjectiveAchieved', + 'playObjectiveFailed' +]; +const battlefieldSourceRecords = [ + { + projectFile: 'src/assets/audio/ambience/battle-rain.mp3', + source: 'https://pixabay.com/sound-effects/nature-gentle-midday-rain-499668/' + }, + { + projectFile: 'src/assets/audio/ambience/battle-fire.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-fire-sounds-405444/' + }, + ...['ally-turn', 'enemy-turn', 'operation-alert'].map((key) => ({ + projectFile: `src/assets/audio/sfx/${key}.mp3`, + source: 'https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/' + })), + { + projectFile: 'src/assets/audio/sfx/objective-success.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-level-complete-143022/' + }, + { + projectFile: 'src/assets/audio/sfx/objective-failure.mp3', + source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/' + } +]; const server = await createServer({ logLevel: 'error', @@ -29,6 +67,10 @@ try { validateTrackMap(errors, 'music', musicTracks, 'bgm'); validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience'); validateTrackMap(errors, 'effect', effectTracks, 'sfx'); + validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys); + validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys); + validateBattlefieldSourceDocumentation(errors); + validateBattleSceneAudioIntegration(errors); validateEffectPools(errors, effectPools, effectTracks); validateEffectPoolRegistration(errors, effectPools); validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); @@ -77,6 +119,43 @@ function validateEffectPools(errors, effectPools, effectTracks) { }); } +function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) { + requiredKeys.forEach((key) => { + if (!(key in tracks)) { + errors.push(`required ${label} track "${key}" is not registered`); + } + }); +} + +function validateBattlefieldSourceDocumentation(errors) { + const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); + if (!docs.includes('2026-07-21')) { + errors.push('docs/audio-sources.md must record the 2026-07-21 battlefield audio download date'); + } + if (!docs.includes('https://pixabay.com/service/license-summary/')) { + errors.push('docs/audio-sources.md must link the Pixabay Content License summary'); + } + battlefieldSourceRecords.forEach(({ projectFile, source }) => { + if (!docs.includes(projectFile)) { + errors.push(`docs/audio-sources.md is missing battlefield audio file "${projectFile}"`); + } + if (!docs.includes(source)) { + errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`); + } + }); +} + +function validateBattleSceneAudioIntegration(errors) { + const path = join('src', 'game', 'scenes', 'BattleScene.ts'); + const source = readFileSync(path, 'utf8'); + requiredBattleSceneAudioMethods.forEach((method) => { + const pattern = new RegExp(`\\bsoundDirector\\.${method}\\s*\\(`); + if (!pattern.test(source)) { + errors.push(`${path} must call soundDirector.${method}()`); + } + }); +} + function validateEffectPoolRegistration(errors, effectPools) { if (Object.keys(effectPools).length === 0) { return; diff --git a/scripts/verify-battle-environment-profiles.mjs b/scripts/verify-battle-environment-profiles.mjs new file mode 100644 index 0000000..281f684 --- /dev/null +++ b/scripts/verify-battle-environment-profiles.mjs @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const expectedProfiles = { + 'ninth-battle-xuzhou-gate-night-raid': { + profileId: 'xuzhou-night-mist', + effect: 'night-mist', + ambienceKey: 'river-night-ambience', + particleKind: null, + hazeKind: 'mist' + }, + 'twenty-second-battle-red-cliffs-fire': { + profileId: 'red-cliffs-firestorm', + effect: 'embers-smoke', + ambienceKey: 'battle-fire', + particleKind: 'ember', + hazeKind: 'smoke' + }, + 'fortieth-battle-han-river-flood': { + profileId: 'han-river-flood-rain', + effect: 'rain-mist', + ambienceKey: 'battle-rain', + particleKind: 'rain', + hazeKind: 'mist' + }, + 'forty-sixth-battle-yiling-fire': { + profileId: 'yiling-burning-forest', + effect: 'embers-smoke', + ambienceKey: 'battle-fire', + particleKind: 'ember', + hazeKind: 'smoke' + }, + 'sixty-first-battle-hanzhong-rain-defense': { + profileId: 'hanzhong-defense-rain', + effect: 'steady-rain', + ambienceKey: 'battle-rain', + particleKind: 'rain', + hazeKind: 'mist' + }, + 'sixty-sixth-battle-wuzhang-final': { + profileId: 'wuzhang-snow-wind', + effect: 'snow-wind', + ambienceKey: 'mountain-wind-ambience', + particleKind: 'snow', + hazeKind: 'mist' + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const environmentModule = await server.ssrLoadModule('/src/game/data/battleEnvironmentProfiles.ts'); + const { + battleEnvironmentProfiles, + battleEnvironmentProfileBattleIds, + battleEnvironmentProfileFor, + battleEnvironmentProfileVerification, + validateBattleEnvironmentProfiles + } = environmentModule; + const errors = [...validateBattleEnvironmentProfiles()]; + const expectedBattleIds = Object.keys(expectedProfiles).sort(); + const actualBattleIds = Object.keys(battleEnvironmentProfiles).sort(); + const declaredBattleIds = [...battleEnvironmentProfileBattleIds].sort(); + + if (JSON.stringify(actualBattleIds) !== JSON.stringify(expectedBattleIds)) { + errors.push(`expected exactly six representative profile keys: ${JSON.stringify(actualBattleIds)}`); + } + if (JSON.stringify(declaredBattleIds) !== JSON.stringify(expectedBattleIds)) { + errors.push(`representative battle id declaration is out of sync: ${JSON.stringify(declaredBattleIds)}`); + } + + const profileIds = new Set(); + Object.entries(expectedProfiles).forEach(([battleId, expected]) => { + const profile = battleEnvironmentProfiles[battleId]; + if (!battleScenarios[battleId]) { + errors.push(`${battleId}: battle scenario does not exist`); + } + if (!profile) { + errors.push(`${battleId}: missing environment profile`); + return; + } + if (battleEnvironmentProfileFor(battleId) !== profile) { + errors.push(`${battleId}: lookup API must return the registered profile object`); + } + if (profile.id !== expected.profileId || profile.effect !== expected.effect) { + errors.push(`${battleId}: expected ${expected.profileId}/${expected.effect}, got ${profile.id}/${profile.effect}`); + } + if (profile.soundscape?.ambienceKey !== expected.ambienceKey) { + errors.push( + `${battleId}: expected ambience ${expected.ambienceKey}, got ${profile.soundscape?.ambienceKey ?? null}` + ); + } + if (!ambienceTracks[profile.soundscape?.ambienceKey]) { + errors.push(`${battleId}: ambience track is not registered: ${profile.soundscape?.ambienceKey ?? null}`); + } + if ( + !Number.isFinite(profile.soundscape?.ambienceVolume) || + profile.soundscape.ambienceVolume <= 0 || + profile.soundscape.ambienceVolume > 0.16 + ) { + errors.push(`${battleId}: ambience volume must stay within 0..0.16`); + } + if ((profile.particles?.kind ?? null) !== expected.particleKind) { + errors.push(`${battleId}: expected particle kind ${expected.particleKind}, got ${profile.particles?.kind ?? null}`); + } + if ((profile.haze?.kind ?? null) !== expected.hazeKind) { + errors.push(`${battleId}: expected haze kind ${expected.hazeKind}, got ${profile.haze?.kind ?? null}`); + } + if (profileIds.has(profile.id)) { + errors.push(`${battleId}: duplicate profile id ${profile.id}`); + } + profileIds.add(profile.id); + if (!Number.isInteger(profile.particles?.count ?? 0) || (profile.particles?.count ?? 0) > 40) { + errors.push(`${battleId}: particle fixed-pool budget must be an integer at or below 40`); + } + if (!Number.isInteger(profile.haze?.count ?? 0) || (profile.haze?.count ?? 0) > 4) { + errors.push(`${battleId}: haze fixed-pool budget must be an integer at or below 4`); + } + if ((profile.particles?.count ?? 0) + (profile.haze?.count ?? 0) + 1 > 45) { + errors.push(`${battleId}: total environment object budget exceeds 45`); + } + if (profile.particles && (!Array.isArray(profile.particles.colors) || profile.particles.colors.length === 0)) { + errors.push(`${battleId}: particle colors are required`); + } + }); + + if ( + battleEnvironmentProfileVerification?.valid !== true || + battleEnvironmentProfileVerification?.profileCount !== expectedBattleIds.length || + battleEnvironmentProfileVerification?.representativeBattleCount !== expectedBattleIds.length || + battleEnvironmentProfileVerification?.maximumParticleCount > 40 || + battleEnvironmentProfileVerification?.maximumHazeCount > 4 + ) { + errors.push(`invalid profile verification summary: ${JSON.stringify(battleEnvironmentProfileVerification)}`); + } + + const battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); + [ + 'battleEnvironmentProfileFor(battleScenario.id)', + 'this.drawBattleEnvironment();', + 'this.updateBattleEnvironment(delta);', + 'this.clearBattleEnvironment();', + 'ambienceKey: profile?.soundscape.ambienceKey', + 'environment: this.battleEnvironmentDebugState()' + ].forEach((requiredSource) => { + if (!battleSceneSource.includes(requiredSource)) { + errors.push(`BattleScene environment integration is missing: ${requiredSource}`); + } + }); + + if (errors.length > 0) { + console.error(`Battle environment profile verification failed with ${errors.length} issue(s):`); + errors.forEach((error) => console.error(`- ${error}`)); + process.exitCode = 1; + } else { + console.log( + `Verified ${expectedBattleIds.length} battle environment profiles, fixed-pool budgets, lookup API, and BattleScene lifecycle/debug integration.` + ); + } +} finally { + await server.close(); +} diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 2aa438b..3f5a8ea 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -97,6 +97,11 @@ try { 'critical-boom': '/audio/critical-boom.ogg', 'victory-fanfare': '/audio/victory-fanfare.ogg', 'defeat-sting': '/audio/defeat-sting.ogg', + 'ally-turn': '/audio/ally-turn.ogg', + 'enemy-turn': '/audio/enemy-turn.ogg', + 'operation-alert': '/audio/operation-alert.ogg', + 'objective-success': '/audio/objective-success.ogg', + 'objective-failure': '/audio/objective-failure.ogg', 'ui-select': '/audio/ui-select.ogg' }); director.registerEffectPools({ @@ -366,6 +371,52 @@ try { assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete'); assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements'); + const tacticalCueStart = audioHarness.instances.length; + assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play'); + assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume'); + assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced'); + assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions'); + + assert.equal(director.playOperationAlert(), true, 'the first operation alert should play'); + assertClose(audioHarness.byOriginalSrc('/audio/operation-alert.ogg').volume, 0.34, 'operation alert volume'); + assert.equal(director.playOperationAlert(), false, 'operation alerts in one event flush should be coalesced'); + + assert.equal(director.playObjectiveAchieved(), true, 'the first objective result should play'); + assertClose(audioHarness.byOriginalSrc('/audio/objective-success.ogg').volume, 0.34, 'objective success volume'); + assert.equal(director.playObjectiveFailed(), false, 'objective result cooldown should suppress re-entry'); + debug = director.getDebugState(); + assert.deepEqual( + debug.semanticCues.cooldownMs, + { turn: 900, operation: 650, objective: 1200 }, + 'semantic cue cooldowns should be visible in debug state' + ); + assert.deepEqual( + debug.semanticCues.lastSuppressed, + { group: 'objective', key: 'objective-failure', at: clock.now, remainingMs: 1200 }, + 'debug state should explain the most recently coalesced cue' + ); + assert.equal(audioHarness.instances.length, tacticalCueStart + 3, 'coalesced cues must not construct Audio'); + + clock.advance(650); + assert.equal(director.playOperationAlert(), true, 'operation alert should replay after its cooldown'); + clock.advance(250); + assert.equal(director.playEnemyTurnCue(), true, 'enemy turn cue should play after the shared turn cooldown'); + assertClose(audioHarness.byOriginalSrc('/audio/enemy-turn.ogg').volume, 0.3, 'enemy turn cue volume'); + clock.advance(300); + assert.equal(director.playObjectiveFailed(), true, 'objective failure should play after the result cooldown'); + assertClose(audioHarness.byOriginalSrc('/audio/objective-failure.ogg').volume, 0.32, 'objective failure volume'); + debug = director.getDebugState(); + assert.deepEqual( + debug.semanticCues.lastPlayed, + { group: 'objective', key: 'objective-failure', at: clock.now }, + 'debug state should expose the latest semantic cue' + ); + assert.equal(audioHarness.instances.length, tacticalCueStart + 6, 'expired cooldowns should permit new Audio'); + clock.advance(2500); + debug = director.getDebugState(); + assert.equal(debug.activeEffectCount, 0, 'tactical cues should retire after their playback windows'); + assert.equal(audioHarness.activeInstances().length, 1, 'tactical cue cleanup should leave only music active'); + const semanticEffectStart = audioHarness.instances.length; director.playMeleeSwing(true, false); assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool'); @@ -404,7 +455,7 @@ try { console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + `${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` + - 'semantic combat effects, category multipliers, and deterministic music ducking.' + 'coalesced tactical cues, semantic combat effects, category multipliers, and deterministic music ducking.' ); } finally { restoreGlobals(); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index b6f7526..f78b0da 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -29,6 +29,7 @@ const checks = [ 'scripts/verify-unit-sprite-asset-data.mjs', 'scripts/verify-battle-action-asset-budgets.mjs', 'scripts/verify-battle-map-asset-data.mjs', + 'scripts/verify-battle-environment-profiles.mjs', 'scripts/verify-audio-asset-data.mjs', 'scripts/verify-sound-director.mjs' ]; diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index 4e59223..24eeea8 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -9,6 +9,7 @@ const validDirections = new Set(['south', 'east', 'north', 'west']); const portraitlessActorIds = new Set(['rebel-leader']); const maxAdjacentBackgroundDuplicateRate = 0.02; const maxAdjacentBackgroundRun = 3; +const expectedStoryPageCount = 466; const server = await createServer({ logLevel: 'error', @@ -27,6 +28,7 @@ try { } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const { portraitAssets, + portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey, @@ -49,12 +51,18 @@ try { avoidableDuplicatePairs: 0, maxRun: 0 }; + const portraitSelectionMetrics = { + pages: 0, + dialoguePortraits: 0, + cutsceneActorPortraits: 0 + }; let pageCount = 0; let cutsceneCount = 0; validatePortraitVersionSelection({ errors, portraitAssets, + portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey @@ -99,11 +107,13 @@ try { usedCutsceneActorIds, storyBackgroundAssets, storyBackgroundKeysFor, + portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker, storyCutsceneActorProfileFor, - hasUnitSheetAsset + hasUnitSheetAsset, + portraitSelectionMetrics }); if (page.cutscene) { cutsceneCount += 1; @@ -114,6 +124,14 @@ try { validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys); validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase); validateStoryBackgroundAdjacencyMetrics(errors, adjacencyMetrics); + if (pageCount !== expectedStoryPageCount) { + errors.push(`story page selection coverage: expected ${expectedStoryPageCount} pages, received ${pageCount}`); + } + if (portraitSelectionMetrics.pages !== pageCount) { + errors.push( + `story portrait selection coverage: checked ${portraitSelectionMetrics.pages}/${pageCount} pages` + ); + } if (errors.length) { console.error(`Story asset data verification failed with ${errors.length} issue(s):`); @@ -130,7 +148,9 @@ try { `Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` + `${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` + `${selectedBackgroundKeys.size} selected backgrounds, ` + - `${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles; ` + + `${usedPortraitKeys.size} portrait keys, ${portraitSelectionMetrics.dialoguePortraits} dialogue portrait selections, ` + + `${portraitSelectionMetrics.cutsceneActorPortraits} cutscene actor portrait selections, and ` + + `${usedCutsceneActorIds.size} cutscene actor profiles; ` + `${adjacencyMetrics.duplicatePairs}/${adjacencyMetrics.pairs} adjacent background repeats ` + `(${(adjacentDuplicateRate * 100).toFixed(1)}%), max run ${adjacencyMetrics.maxRun}.` ); @@ -143,6 +163,7 @@ function validatePortraitVersionSelection(context) { const { errors, portraitAssets, + portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey @@ -217,6 +238,61 @@ function validatePortraitVersionSelection(context) { } }); + const contextualPortraitRouteCases = [ + { key: 'liuBei', id: 'oath-liu-bei', background: 'title-taoyuan', expected: 'portrait-liu-bei-yellow-turban' }, + { key: 'liuBei', id: 'seventh-liu-bei-xuzhou', background: 'story-xuzhou', expected: 'portrait-liu-bei' }, + { key: 'liuBei', id: 'seventeenth-liu-bei-third-visit', background: 'story-wolong', expected: 'portrait-liu-bei-campaign' }, + { key: 'liuBei', id: 'baidi-entrustment-liu-bei', background: 'story-baidi-entrustment', expected: 'portrait-liu-bei-shuhan' }, + { key: 'guanYu', id: 'oath-brothers', background: 'title-taoyuan', expected: 'portrait-guan-yu-yellow-turban' }, + { key: 'guanYu', id: 'seventh-guan-yu-field', background: 'story-xuzhou', expected: 'portrait-guan-yu' }, + { key: 'guanYu', id: 'eighteenth-generals-doubt', background: 'story-wolong', expected: 'portrait-guan-yu-campaign' }, + { key: 'guanYu', id: 'forty-fourth-guan-yu-resolve', background: 'story-maicheng-isolation', expected: 'portrait-guan-yu-jingzhou' }, + { key: 'zhangFei', id: 'zhang-fei-joins', background: 'story-three-heroes', expected: 'portrait-zhang-fei-yellow-turban' }, + { key: 'zhangFei', id: 'seventh-zhang-fei-charge', background: 'story-xuzhou', expected: 'portrait-zhang-fei' }, + { key: 'zhangFei', id: 'nineteenth-zhang-fei-bridge', background: 'story-red-cliffs', expected: 'portrait-zhang-fei-campaign' }, + { key: 'zhangFei', id: 'thirty-fifth-hanzhong-front', background: 'story-northern', expected: 'portrait-zhang-fei-ba-command' }, + { key: 'zhaoYun', id: 'sixteenth-zhao-yun-returns', background: 'story-wolong', expected: 'portrait-zhao-yun' }, + { key: 'zhaoYun', id: 'nineteenth-zhao-yun-rescue', background: 'story-red-cliffs', expected: 'portrait-zhao-yun-changban' }, + { key: 'zhaoYun', id: 'twenty-third-jingzhou-cause', background: 'story-yizhou', expected: 'portrait-zhao-yun-campaign' }, + { key: 'zhaoYun', id: 'northern-prep-roster-council', background: 'story-northern', expected: 'portrait-zhao-yun-veteran' }, + { key: 'jiangWei', id: 'fifty-seventh-victory-jiang-wei-joins', background: 'story-jieting-crisis', expected: 'portrait-jiang-wei-tianshui' }, + { key: 'jiangWei', id: 'fifty-eighth-jiang-wei-first-sortie', background: 'story-jieting-crisis', expected: 'portrait-jiang-wei-tianshui' }, + { key: 'jiangWei', id: 'sixty-sixth-inherited-ledger', background: 'story-northern', expected: 'portrait-jiang-wei-campaign' }, + { key: 'cao-cao', id: 'fifth-liu-bei-choice', background: 'story-anti-dong', expected: 'portrait-cao-cao' }, + { key: 'cao-cao', id: 'eleventh-arrive-cao-cao-camp', background: 'story-wandering', expected: 'portrait-cao-cao-campaign' }, + { key: 'cao-cao', id: 'twenty-second-cao-cao-retreat', background: 'story-red-cliffs', expected: 'portrait-cao-cao-redcliffs' }, + { key: 'simaYi', id: 'fourteenth-secret-edict-shadow', background: 'story-wandering', expected: 'portrait-sima-yi' }, + { key: 'simaYi', id: 'fifty-fourth-victory-meng-huo-yields', background: 'story-nanzhong-captures', expected: 'portrait-sima-yi-campaign' }, + { key: 'simaYi', id: 'sixty-second-sima-yi-delay-line', background: 'story-qishan-renewed', expected: 'portrait-sima-yi-qishan' }, + { key: 'meng-huo', id: 'forty-seventh-meng-huo-shadow', background: 'story-nanzhong', expected: 'portrait-meng-huo' }, + { key: 'meng-huo', id: 'forty-ninth-meng-huo-returns', background: 'story-nanzhong-captures', expected: 'portrait-meng-huo-campaign' }, + { key: 'meng-huo', id: 'fifty-fourth-victory-meng-huo-yields', background: 'story-nanzhong-captures', expected: 'portrait-meng-huo-final' } + ]; + + ['liuBei', 'guanYu', 'zhangFei', 'zhaoYun', 'jiangWei', 'cao-cao', 'simaYi', 'meng-huo'].forEach((key) => { + const routeCaseCount = contextualPortraitRouteCases.filter((testCase) => testCase.key === key).length; + if (routeCaseCount < 3) { + errors.push(`portrait era route coverage: ${key} requires early/middle/late assertions, received ${routeCaseCount}`); + } + }); + + contextualPortraitRouteCases.forEach(({ key, id, background, expected }) => { + const storyContext = { id, background }; + const candidates = portraitAssetEntriesForStoryContext(key, storyContext); + const selected = portraitAssetEntryForStoryContext(key, storyContext); + if (candidates.length !== 1 || candidates[0].textureKey !== expected) { + errors.push( + `portrait era route: ${key}/${id} should expose only "${expected}", received ` + + `[${candidates.map(({ textureKey }) => textureKey).join(', ')}]` + ); + } + if (selected?.textureKey !== expected) { + errors.push( + `portrait era selection: ${key}/${id} should select "${expected}", received "${selected?.textureKey ?? 'none'}"` + ); + } + }); + const zhugeLiangBaseRevisionFixture = 'zhuge-liang-v2'; const previousBaseRevision = portraitAssets[zhugeLiangBaseRevisionFixture]; portraitAssets[zhugeLiangBaseRevisionFixture] = `virtual:${zhugeLiangBaseRevisionFixture}`; @@ -511,7 +587,16 @@ function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundU } function validatePortrait(context, pageContext) { - const { errors, page, usedPortraitKeys, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker } = context; + const { + errors, + page, + usedPortraitKeys, + portraitAssetEntryForStoryContext, + portraitAssetEntriesForStoryContext, + portraitKeyForSpeaker, + portraitSelectionMetrics + } = context; + portraitSelectionMetrics.pages += 1; const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker); if (!portraitKey) { return; @@ -520,6 +605,20 @@ function validatePortrait(context, pageContext) { const entries = portraitAssetEntriesForStoryContext(portraitKey, page); if (entries.length === 0) { errors.push(`${pageContext}: missing portrait asset "${portraitKey}"`); + return; + } + + const selectedEntry = portraitAssetEntryForStoryContext(portraitKey, page); + if (!selectedEntry) { + errors.push(`${pageContext}: story portrait selector returned no entry for "${portraitKey}"`); + } else if (!entries.some(({ textureKey, url }) => + textureKey === selectedEntry.textureKey && url === selectedEntry.url + )) { + errors.push( + `${pageContext}: selected portrait "${selectedEntry.textureKey}" is not a contextual candidate for "${portraitKey}"` + ); + } else { + portraitSelectionMetrics.dialoguePortraits += 1; } usedPortraitKeys.add(portraitKey); } @@ -551,7 +650,11 @@ function validateCutsceneActor(context, pageContext, actor, actorIndex) { usedCutsceneActorIds, usedPortraitKeys, storyCutsceneActorProfileFor, + portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, + portraitAssetEntriesForStoryContext, + portraitSelectionMetrics, + page, hasUnitSheetAsset } = context; const actorContext = `${pageContext}/actor[${actorIndex}]`; @@ -589,8 +692,25 @@ function validateCutsceneActor(context, pageContext, actor, actorIndex) { if (!portraitlessActorIds.has(actor.unitId)) { assertNonEmptyString(errors, profile.portraitKey, `${actorContext}: actor profile portrait key`); - if (portraitAssetEntriesForKey(profile.portraitKey).length === 0) { + const allEntries = portraitAssetEntriesForKey(profile.portraitKey); + if (allEntries.length === 0) { errors.push(`${actorContext}: missing actor portrait "${profile.portraitKey}"`); + return; + } + const contextualEntries = portraitAssetEntriesForStoryContext(profile.portraitKey, page); + const selectedEntry = portraitAssetEntryForStoryContext( + profile.portraitKey, + page, + `${page.id}:${actor.unitId}:${actorIndex}` + ); + if (!selectedEntry) { + errors.push(`${actorContext}: contextual actor portrait selector returned no entry`); + } else if (!contextualEntries.some(({ textureKey, url }) => + textureKey === selectedEntry.textureKey && url === selectedEntry.url + )) { + errors.push(`${actorContext}: selected actor portrait "${selectedEntry.textureKey}" is not a contextual candidate`); + } else { + portraitSelectionMetrics.cutsceneActorPortraits += 1; } usedPortraitKeys.add(profile.portraitKey); } diff --git a/src/assets/audio/ambience/battle-fire.mp3 b/src/assets/audio/ambience/battle-fire.mp3 new file mode 100644 index 0000000..f3321b1 Binary files /dev/null and b/src/assets/audio/ambience/battle-fire.mp3 differ diff --git a/src/assets/audio/ambience/battle-rain.mp3 b/src/assets/audio/ambience/battle-rain.mp3 new file mode 100644 index 0000000..cf9fa4c Binary files /dev/null and b/src/assets/audio/ambience/battle-rain.mp3 differ diff --git a/src/assets/audio/sfx/ally-turn.mp3 b/src/assets/audio/sfx/ally-turn.mp3 new file mode 100644 index 0000000..d0ef883 Binary files /dev/null and b/src/assets/audio/sfx/ally-turn.mp3 differ diff --git a/src/assets/audio/sfx/enemy-turn.mp3 b/src/assets/audio/sfx/enemy-turn.mp3 new file mode 100644 index 0000000..30080e4 Binary files /dev/null and b/src/assets/audio/sfx/enemy-turn.mp3 differ diff --git a/src/assets/audio/sfx/objective-failure.mp3 b/src/assets/audio/sfx/objective-failure.mp3 new file mode 100644 index 0000000..adc30c8 Binary files /dev/null and b/src/assets/audio/sfx/objective-failure.mp3 differ diff --git a/src/assets/audio/sfx/objective-success.mp3 b/src/assets/audio/sfx/objective-success.mp3 new file mode 100644 index 0000000..642900d Binary files /dev/null and b/src/assets/audio/sfx/objective-success.mp3 differ diff --git a/src/assets/audio/sfx/operation-alert.mp3 b/src/assets/audio/sfx/operation-alert.mp3 new file mode 100644 index 0000000..00cfa1e Binary files /dev/null and b/src/assets/audio/sfx/operation-alert.mp3 differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 4a02fc7..c24623e 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -57,6 +57,8 @@ export type MusicDuckOptions = { releaseMs?: number; }; +type SemanticCueGroup = 'turn' | 'operation' | 'objective'; + type MusicDuckState = { multiplier: number; timer?: number; @@ -97,6 +99,19 @@ export class SoundDirector { active: false, endsAt: 0 }; + private readonly semanticCueCooldownMs: Record = { + turn: 900, + operation: 650, + objective: 1200 + }; + private readonly semanticCuePlayedAt = new Map(); + private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number }; + private lastSuppressedSemanticCue?: { + group: SemanticCueGroup; + key: string; + at: number; + remainingMs: number; + }; private lastEffect?: { key: string; trackKey: string; @@ -379,6 +394,46 @@ export class SoundDirector { }); } + playAllyTurnCue() { + return this.playSemanticCue('turn', 'ally-turn', { + volume: 0.32, + stopAfterMs: 2100, + duck: { multiplier: 0.62, attackMs: 36, holdMs: 1050, releaseMs: 420 } + }); + } + + playEnemyTurnCue() { + return this.playSemanticCue('turn', 'enemy-turn', { + volume: 0.3, + stopAfterMs: 2400, + duck: { multiplier: 0.56, attackMs: 36, holdMs: 1350, releaseMs: 480 } + }); + } + + playOperationAlert() { + return this.playSemanticCue('operation', 'operation-alert', { + volume: 0.34, + stopAfterMs: 2100, + duck: { multiplier: 0.48, attackMs: 28, holdMs: 820, releaseMs: 430 } + }); + } + + playObjectiveAchieved() { + return this.playSemanticCue('objective', 'objective-success', { + volume: 0.34, + stopAfterMs: 1700, + duck: { multiplier: 0.42, attackMs: 48, holdMs: 900, releaseMs: 480 } + }); + } + + playObjectiveFailed() { + return this.playSemanticCue('objective', 'objective-failure', { + volume: 0.32, + stopAfterMs: 1600, + duck: { multiplier: 0.36, attackMs: 48, holdMs: 900, releaseMs: 520 } + }); + } + playStrategyPulse() { this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 }); this.playTone(460, 0.08, 0.012, 'sine'); @@ -442,6 +497,37 @@ export class SoundDirector { return true; } + private playSemanticCue( + group: SemanticCueGroup, + key: string, + options: PlayEffectOptions & { duck: MusicDuckOptions } + ) { + if (!this.started || this.muted || !this.effectTracks[key]) { + return false; + } + + const now = performance.now(); + const lastPlayedAt = this.semanticCuePlayedAt.get(group); + const cooldownMs = this.semanticCueCooldownMs[group]; + if (lastPlayedAt !== undefined && now - lastPlayedAt < cooldownMs) { + this.lastSuppressedSemanticCue = { + group, + key, + at: now, + remainingMs: Math.ceil(cooldownMs - (now - lastPlayedAt)) + }; + return false; + } + + this.duckMusic(options.duck); + const played = this.playEffect(key, options); + if (played) { + this.semanticCuePlayedAt.set(group, now); + this.lastPlayedSemanticCue = { group, key, at: now }; + } + return played; + } + getDebugState() { const music = this.loopChannelDebugState(this.musicChannel); const ambience = this.loopChannelDebugState(this.ambienceChannel); @@ -465,6 +551,12 @@ export class SoundDirector { endsAt: this.musicDuck.endsAt, last: this.musicDuck.last ? { ...this.musicDuck.last } : null }, + semanticCues: { + cooldownMs: { ...this.semanticCueCooldownMs }, + playedAt: Object.fromEntries(this.semanticCuePlayedAt), + lastPlayed: this.lastPlayedSemanticCue ? { ...this.lastPlayedSemanticCue } : null, + lastSuppressed: this.lastSuppressedSemanticCue ? { ...this.lastSuppressedSemanticCue } : null + }, effectPools: Object.fromEntries( Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]]) ), diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index 200890e..d7f5538 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -7,6 +7,7 @@ import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3'; import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3'; import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3'; import titleThemeUrl from '../../assets/audio/bgm/title-theme.mp3'; +import allyTurnUrl from '../../assets/audio/sfx/ally-turn.mp3'; import armorImpactUrl from '../../assets/audio/sfx/armor-impact.mp3'; import arrowBodyImpactUrl from '../../assets/audio/sfx/arrow-body-impact.mp3'; import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3'; @@ -18,16 +19,22 @@ import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3'; import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3'; import criticalImpactUrl from '../../assets/audio/sfx/critical-impact.mp3'; import defeatStingUrl from '../../assets/audio/sfx/defeat-sting.wav'; +import enemyTurnUrl from '../../assets/audio/sfx/enemy-turn.mp3'; import expGainUrl from '../../assets/audio/sfx/exp-gain.mp3'; import footstepWalkUrl from '../../assets/audio/sfx/footstep-walk.mp3'; import horseGallopUrl from '../../assets/audio/sfx/horse-gallop.mp3'; import levelUpUrl from '../../assets/audio/sfx/level-up.wav'; +import objectiveFailureUrl from '../../assets/audio/sfx/objective-failure.mp3'; +import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3'; +import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3'; import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3'; import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3'; import swordSwingVariantUrl from '../../assets/audio/sfx/sword-swing-variant.mp3'; import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3'; import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav'; +import battleFireAmbienceUrl from '../../assets/audio/ambience/battle-fire.mp3'; +import battleRainAmbienceUrl from '../../assets/audio/ambience/battle-rain.mp3'; import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3'; import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3'; import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3'; @@ -46,6 +53,8 @@ export const musicTracks = { } as const; export const ambienceTracks = { + 'battle-rain': battleRainAmbienceUrl, + 'battle-fire': battleFireAmbienceUrl, 'campfire-ambience': campfireAmbienceUrl, 'river-night-ambience': riverNightAmbienceUrl, 'mountain-wind-ambience': mountainWindAmbienceUrl, @@ -54,6 +63,11 @@ export const ambienceTracks = { export const effectTracks = { 'ui-select': uiSelectUrl, + 'ally-turn': allyTurnUrl, + 'enemy-turn': enemyTurnUrl, + 'operation-alert': operationAlertUrl, + 'objective-success': objectiveSuccessUrl, + 'objective-failure': objectiveFailureUrl, 'sword-slash': swordSlashUrl, 'sword-swing-variant': swordSwingVariantUrl, 'combat-impact': combatImpactUrl, diff --git a/src/game/data/battleEnvironmentProfiles.ts b/src/game/data/battleEnvironmentProfiles.ts new file mode 100644 index 0000000..1ffca9f --- /dev/null +++ b/src/game/data/battleEnvironmentProfiles.ts @@ -0,0 +1,324 @@ +import type { AmbienceTrackKey } from '../audio/audioAssets'; +import type { BattleScenarioId } from './battles'; + +export type BattleEnvironmentEffect = + | 'night-mist' + | 'embers-smoke' + | 'rain-mist' + | 'steady-rain' + | 'snow-wind'; + +export type BattleEnvironmentParticleKind = 'ember' | 'rain' | 'snow'; +export type BattleEnvironmentHazeKind = 'mist' | 'smoke'; +export type BattleEnvironmentNumberRange = readonly [number, number]; + +export type BattleEnvironmentParticleProfile = { + kind: BattleEnvironmentParticleKind; + count: number; + colors: readonly number[]; + alpha: BattleEnvironmentNumberRange; + size: BattleEnvironmentNumberRange; + velocityX: BattleEnvironmentNumberRange; + velocityY: BattleEnvironmentNumberRange; +}; + +export type BattleEnvironmentHazeProfile = { + kind: BattleEnvironmentHazeKind; + count: number; + color: number; + alpha: BattleEnvironmentNumberRange; + widthRatio: BattleEnvironmentNumberRange; + heightRatio: BattleEnvironmentNumberRange; + velocityX: BattleEnvironmentNumberRange; + waveAmplitude: BattleEnvironmentNumberRange; +}; + +export type BattleEnvironmentProfile = { + id: string; + battleId: BattleScenarioId; + label: string; + effect: BattleEnvironmentEffect; + soundscape: { + ambienceKey: AmbienceTrackKey; + ambienceVolume: number; + }; + tint: { + color: number; + alpha: number; + }; + particles?: BattleEnvironmentParticleProfile; + haze?: BattleEnvironmentHazeProfile; +}; + +export const battleEnvironmentProfileBattleIds = [ + 'ninth-battle-xuzhou-gate-night-raid', + 'twenty-second-battle-red-cliffs-fire', + 'fortieth-battle-han-river-flood', + 'forty-sixth-battle-yiling-fire', + 'sixty-first-battle-hanzhong-rain-defense', + 'sixty-sixth-battle-wuzhang-final' +] as const satisfies readonly BattleScenarioId[]; + +const profiles = { + 'ninth-battle-xuzhou-gate-night-raid': { + id: 'xuzhou-night-mist', + battleId: 'ninth-battle-xuzhou-gate-night-raid', + label: '서주 야습의 밤안개', + effect: 'night-mist', + soundscape: { ambienceKey: 'river-night-ambience', ambienceVolume: 0.1 }, + tint: { color: 0x07111f, alpha: 0.14 }, + haze: { + kind: 'mist', + count: 4, + color: 0xa8bdc9, + alpha: [0.025, 0.055], + widthRatio: [0.28, 0.44], + heightRatio: [0.08, 0.15], + velocityX: [5, 11], + waveAmplitude: [5, 11] + } + }, + 'twenty-second-battle-red-cliffs-fire': { + id: 'red-cliffs-firestorm', + battleId: 'twenty-second-battle-red-cliffs-fire', + label: '적벽의 불티와 연기', + effect: 'embers-smoke', + soundscape: { ambienceKey: 'battle-fire', ambienceVolume: 0.13 }, + tint: { color: 0x4a170d, alpha: 0.075 }, + particles: { + kind: 'ember', + count: 22, + colors: [0xff8a2a, 0xffb13b, 0xffd36a], + alpha: [0.28, 0.62], + size: [3, 7], + velocityX: [-10, 24], + velocityY: [-52, -26] + }, + haze: { + kind: 'smoke', + count: 3, + color: 0x4b403c, + alpha: [0.025, 0.06], + widthRatio: [0.22, 0.36], + heightRatio: [0.1, 0.19], + velocityX: [3, 9], + waveAmplitude: [6, 13] + } + }, + 'fortieth-battle-han-river-flood': { + id: 'han-river-flood-rain', + battleId: 'fortieth-battle-han-river-flood', + label: '한수의 물안개와 비', + effect: 'rain-mist', + soundscape: { ambienceKey: 'battle-rain', ambienceVolume: 0.12 }, + tint: { color: 0x123148, alpha: 0.07 }, + particles: { + kind: 'rain', + count: 30, + colors: [0xa9cadb, 0xc6dde8], + alpha: [0.18, 0.38], + size: [14, 22], + velocityX: [-92, -54], + velocityY: [300, 410] + }, + haze: { + kind: 'mist', + count: 3, + color: 0xa9c1cb, + alpha: [0.02, 0.05], + widthRatio: [0.25, 0.42], + heightRatio: [0.08, 0.14], + velocityX: [4, 10], + waveAmplitude: [5, 10] + } + }, + 'forty-sixth-battle-yiling-fire': { + id: 'yiling-burning-forest', + battleId: 'forty-sixth-battle-yiling-fire', + label: '이릉 숲의 불티와 연기', + effect: 'embers-smoke', + soundscape: { ambienceKey: 'battle-fire', ambienceVolume: 0.13 }, + tint: { color: 0x52180b, alpha: 0.085 }, + particles: { + kind: 'ember', + count: 24, + colors: [0xff7924, 0xffa332, 0xffcf64], + alpha: [0.3, 0.66], + size: [3, 7], + velocityX: [-6, 28], + velocityY: [-56, -28] + }, + haze: { + kind: 'smoke', + count: 4, + color: 0x493a34, + alpha: [0.03, 0.065], + widthRatio: [0.2, 0.34], + heightRatio: [0.1, 0.2], + velocityX: [2, 8], + waveAmplitude: [7, 15] + } + }, + 'sixty-first-battle-hanzhong-rain-defense': { + id: 'hanzhong-defense-rain', + battleId: 'sixty-first-battle-hanzhong-rain-defense', + label: '한중 방어선의 장대비', + effect: 'steady-rain', + soundscape: { ambienceKey: 'battle-rain', ambienceVolume: 0.12 }, + tint: { color: 0x102b3d, alpha: 0.085 }, + particles: { + kind: 'rain', + count: 36, + colors: [0x9ec4d7, 0xc5dce8], + alpha: [0.2, 0.42], + size: [15, 24], + velocityX: [-108, -66], + velocityY: [330, 450] + }, + haze: { + kind: 'mist', + count: 2, + color: 0x9bb3bc, + alpha: [0.018, 0.04], + widthRatio: [0.28, 0.45], + heightRatio: [0.08, 0.14], + velocityX: [5, 12], + waveAmplitude: [5, 9] + } + }, + 'sixty-sixth-battle-wuzhang-final': { + id: 'wuzhang-snow-wind', + battleId: 'sixty-sixth-battle-wuzhang-final', + label: '오장원의 눈바람', + effect: 'snow-wind', + soundscape: { ambienceKey: 'mountain-wind-ambience', ambienceVolume: 0.09 }, + tint: { color: 0x25405a, alpha: 0.08 }, + particles: { + kind: 'snow', + count: 32, + colors: [0xdbe9ef, 0xf2f6f7, 0xbfd3dc], + alpha: [0.26, 0.58], + size: [3, 8], + velocityX: [34, 82], + velocityY: [34, 72] + }, + haze: { + kind: 'mist', + count: 2, + color: 0xc0d2d9, + alpha: [0.018, 0.038], + widthRatio: [0.25, 0.42], + heightRatio: [0.08, 0.14], + velocityX: [9, 17], + waveAmplitude: [7, 14] + } + } +} as const satisfies Partial>; + +export const battleEnvironmentProfiles: Readonly>> = profiles; + +export function battleEnvironmentProfileFor(battleId: BattleScenarioId | string) { + return battleEnvironmentProfiles[battleId as BattleScenarioId]; +} + +export function validateBattleEnvironmentProfiles() { + const errors: string[] = []; + const expectedIds = new Set(battleEnvironmentProfileBattleIds); + const entries = Object.entries(battleEnvironmentProfiles) as Array<[string, BattleEnvironmentProfile]>; + const profileIds = new Set(); + + entries.forEach(([battleId, profile]) => { + if (!expectedIds.has(battleId)) { + errors.push(`${battleId}: unexpected environment profile`); + } + if (profile.battleId !== battleId) { + errors.push(`${battleId}: profile battleId must match its record key`); + } + if (profileIds.has(profile.id)) { + errors.push(`${battleId}: duplicate profile id "${profile.id}"`); + } + profileIds.add(profile.id); + if (!profile.label.trim()) { + errors.push(`${battleId}: label is required`); + } + if (!profile.soundscape.ambienceKey) { + errors.push(`${battleId}: ambience key is required`); + } + if ( + !Number.isFinite(profile.soundscape.ambienceVolume) || + profile.soundscape.ambienceVolume <= 0 || + profile.soundscape.ambienceVolume > 0.16 + ) { + errors.push(`${battleId}: ambience volume must stay within 0..0.16`); + } + if (!Number.isFinite(profile.tint.alpha) || profile.tint.alpha < 0 || profile.tint.alpha > 0.16) { + errors.push(`${battleId}: tint alpha must stay within the map-legibility budget (0..0.16)`); + } + validateRange(errors, battleId, 'particle alpha', profile.particles?.alpha, 0, 0.7); + validateRange(errors, battleId, 'particle size', profile.particles?.size, 1, 28); + validateRange(errors, battleId, 'particle velocityX', profile.particles?.velocityX, -160, 160); + validateRange(errors, battleId, 'particle velocityY', profile.particles?.velocityY, -520, 520); + validateRange(errors, battleId, 'haze alpha', profile.haze?.alpha, 0, 0.08); + validateRange(errors, battleId, 'haze widthRatio', profile.haze?.widthRatio, 0.12, 0.5); + validateRange(errors, battleId, 'haze heightRatio', profile.haze?.heightRatio, 0.04, 0.24); + validateRange(errors, battleId, 'haze velocityX', profile.haze?.velocityX, -30, 30); + validateRange(errors, battleId, 'haze waveAmplitude', profile.haze?.waveAmplitude, 0, 24); + if ((profile.particles?.count ?? 0) < 0 || (profile.particles?.count ?? 0) > 40) { + errors.push(`${battleId}: particle count exceeds the fixed-pool budget of 40`); + } + if ((profile.haze?.count ?? 0) < 0 || (profile.haze?.count ?? 0) > 4) { + errors.push(`${battleId}: haze count exceeds the fixed-pool budget of 4`); + } + if ((profile.particles?.count ?? 0) + (profile.haze?.count ?? 0) <= 0) { + errors.push(`${battleId}: at least one environment effect is required`); + } + }); + + battleEnvironmentProfileBattleIds.forEach((battleId) => { + if (!battleEnvironmentProfiles[battleId]) { + errors.push(`${battleId}: missing representative environment profile`); + } + }); + + return errors; +} + +function validateRange( + errors: string[], + battleId: string, + label: string, + range: BattleEnvironmentNumberRange | undefined, + minimum: number, + maximum: number +) { + if (!range) { + return; + } + const [from, to] = range; + if ( + !Number.isFinite(from) || + !Number.isFinite(to) || + from > to || + from < minimum || + to > maximum + ) { + errors.push(`${battleId}: ${label} range must be ordered within ${minimum}..${maximum}`); + } +} + +const battleEnvironmentProfileErrors = validateBattleEnvironmentProfiles(); +if (battleEnvironmentProfileErrors.length > 0) { + throw new Error(`Invalid battle environment profiles:\n${battleEnvironmentProfileErrors.join('\n')}`); +} + +export const battleEnvironmentProfileVerification = Object.freeze({ + valid: true, + profileCount: Object.keys(battleEnvironmentProfiles).length, + representativeBattleCount: battleEnvironmentProfileBattleIds.length, + maximumParticleCount: Math.max( + ...Object.values(battleEnvironmentProfiles).map((profile) => profile?.particles?.count ?? 0) + ), + maximumHazeCount: Math.max( + ...Object.values(battleEnvironmentProfiles).map((profile) => profile?.haze?.count ?? 0) + ) +}); diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts index 2d7cb3b..af3bcee 100644 --- a/src/game/data/portraitAssets.ts +++ b/src/game/data/portraitAssets.ts @@ -101,7 +101,34 @@ export type StoryPortraitContext = { background: string; }; -const zhugeLiangNorthernBackgrounds = new Set([ +type StoryPortraitVariantRule = Readonly<{ + variant: string; + pageIds?: readonly string[]; + pageIdPrefixes?: readonly string[]; + backgrounds?: readonly string[]; +}>; + +type StoryPortraitVariantRoute = Readonly<{ + defaultVariant: string; + rules: readonly StoryPortraitVariantRule[]; +}>; + +const yellowTurbanStoryBackgrounds = [ + 'story-liu-bei', + 'story-three-heroes', + 'title-taoyuan', + 'story-militia', + 'story-sortie', + 'story-yellow-pursuit' +] as const; + +const earlyCampaignStoryBackgrounds = [ + 'story-anti-dong', + 'story-xuzhou', + 'story-wandering' +] as const; + +const zhugeLiangNorthernBackgrounds = [ 'story-tianshui-front', 'story-jieting-crisis', 'story-chencang-siege', @@ -111,7 +138,149 @@ const zhugeLiangNorthernBackgrounds = new Set([ 'story-lucheng-pursuit', 'story-weishui-camps', 'story-weishui-northbank' -]); +] as const; + +// Rules are evaluated from top to bottom. They deliberately use semantic page +// and background milestones instead of a hash so a character cannot appear in +// an outfit from a different campaign era. +const storyPortraitVariantRoutes: Readonly> = { + 'liu-bei': { + defaultVariant: 'campaign', + rules: [ + { variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds }, + { + variant: 'shuhan', + pageIdPrefixes: ['hanzhong-king-', 'shu-han-foundation-', 'forty-', 'baidi-entrustment-'], + backgrounds: [ + 'story-jingzhou-crisis', + 'story-fan-castle-flood', + 'story-maicheng-isolation', + 'story-yiling-baidi', + 'story-yiling-fire-attack', + 'story-baidi-entrustment' + ] + }, + { variant: 'base', backgrounds: earlyCampaignStoryBackgrounds } + ] + }, + 'guan-yu': { + defaultVariant: 'campaign', + rules: [ + { variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds }, + { + variant: 'jingzhou', + pageIds: ['hanzhong-king-officers', 'shu-han-foundation-generals'], + backgrounds: ['story-jingzhou-crisis', 'story-fan-castle-flood', 'story-maicheng-isolation'] + }, + { variant: 'base', backgrounds: earlyCampaignStoryBackgrounds } + ] + }, + 'zhang-fei': { + defaultVariant: 'campaign', + rules: [ + { variant: 'yellow-turban', backgrounds: yellowTurbanStoryBackgrounds }, + { + variant: 'ba-command', + backgrounds: ['story-yizhou', 'story-yizhou-luo-road', 'story-chengdu-surrender', 'story-northern'] + }, + { variant: 'base', backgrounds: earlyCampaignStoryBackgrounds } + ] + }, + 'zhao-yun': { + defaultVariant: 'campaign', + rules: [ + { + variant: 'changban', + pageIds: ['nineteenth-zhao-yun-rescue', 'nineteenth-zhao-yun-returns'] + }, + { + variant: 'veteran', + pageIdPrefixes: ['baidi-entrustment-', 'northern-prep-', 'forty-seventh-', 'fifty-', 'sixty-', 'ending-'], + backgrounds: [ + 'story-baidi-entrustment', + 'story-nanzhong', + 'story-nanzhong-captures', + 'story-tianshui-front', + 'story-jieting-crisis', + 'story-chencang-siege', + 'story-wudu-yinping', + 'story-hanzhong-rain', + 'story-qishan-renewed', + 'story-lucheng-pursuit', + 'story-weishui-camps', + 'story-weishui-northbank', + 'story-northern' + ] + }, + { variant: 'base', backgrounds: ['story-wolong'] } + ] + }, + 'jiang-wei': { + defaultVariant: 'campaign', + rules: [ + { + variant: 'tianshui', + pageIdPrefixes: [ + 'fifty-sixth-jiang-wei-', + 'fifty-seventh-victory-jiang-wei-', + 'fifty-eighth-jiang-wei-first-' + ] + } + ] + }, + 'cao-cao': { + defaultVariant: 'campaign', + rules: [ + { variant: 'redcliffs', backgrounds: ['story-red-cliffs'] }, + { variant: 'base', backgrounds: ['story-anti-dong', 'story-xuzhou'] } + ] + }, + 'sima-yi': { + defaultVariant: 'campaign', + rules: [ + { + variant: 'qishan', + backgrounds: [ + 'story-jieting-crisis', + 'story-chencang-siege', + 'story-wudu-yinping', + 'story-hanzhong-rain', + 'story-qishan-renewed', + 'story-lucheng-pursuit', + 'story-weishui-camps', + 'story-weishui-northbank', + 'story-northern', + 'story-wuzhang-jiangwei-inheritance' + ] + }, + { variant: 'base', backgrounds: ['story-wandering'] } + ] + }, + 'meng-huo': { + defaultVariant: 'base', + rules: [ + { variant: 'final', pageIds: ['fifty-fourth-victory-meng-huo-yields'] }, + { variant: 'campaign', backgrounds: ['story-nanzhong-captures'] } + ] + }, + 'zhuge-liang': { + defaultVariant: 'campaign', + rules: [ + { + variant: 'redcliffs', + pageIds: ['eighteenth-red-cliffs-wind'], + backgrounds: ['story-red-cliffs'] + }, + { variant: 'base', backgrounds: ['story-wolong'] }, + { + variant: 'northern', + pageIds: ['northern-prep-back-secured'], + pageIdPrefixes: ['fifty-fifth-'], + backgrounds: zhugeLiangNorthernBackgrounds + } + ] + } +}; export function portraitSlugForKey(key: string) { return legacyPortraitSlugs[key] ?? camelToKebab(key); @@ -169,14 +338,21 @@ export function portraitAssetEntriesForKey(key: string): PortraitAssetEntry[] { export function portraitAssetEntriesForStoryContext(key: string, context: StoryPortraitContext) { const entries = portraitAssetEntriesForKey(key); - if (portraitSlugForKey(key) !== 'zhuge-liang' || entries.length <= 1) { + if (entries.length <= 1) { return entries; } - const variant = zhugeLiangStoryVariant(context); + const portraitSlug = portraitSlugForKey(key); + const route = storyPortraitVariantRoutes[portraitSlug]; + if (!route) { + return entries; + } + + const matchedRule = route.rules.find((rule) => storyPortraitVariantRuleMatches(rule, context)); + const variant = matchedRule?.variant ?? route.defaultVariant; const textureKeyPrefix = variant === 'base' - ? 'portrait-zhuge-liang' - : `portrait-zhuge-liang-${variant}`; + ? `portrait-${portraitSlug}` + : `portrait-${portraitSlug}-${variant}`; const preferredEntries = entries.filter(({ textureKey }) => textureKey === textureKeyPrefix || (textureKey.startsWith(textureKeyPrefix) && /^-v[1-9]\d*$/.test(textureKey.slice(textureKeyPrefix.length))) @@ -184,6 +360,15 @@ export function portraitAssetEntriesForStoryContext(key: string, context: StoryP return preferredEntries.length > 0 ? preferredEntries : entries; } +export function portraitAssetEntryForStoryContext( + key: string, + context: StoryPortraitContext, + selectionSeed = context.id +): PortraitAssetEntry | undefined { + const entries = portraitAssetEntriesForStoryContext(key, context); + return entries.length > 0 ? entries[stablePortraitHash(selectionSeed) % entries.length] : undefined; +} + export function portraitTextureKeysForKey(key: string) { return portraitAssetSlugsForKey(key).map((candidate) => `portrait-${candidate}`); } @@ -192,19 +377,21 @@ export function portraitKeyForSpeaker(speaker?: string) { return speaker ? speakerPortraitKeys[speaker] : undefined; } -function zhugeLiangStoryVariant({ id, background }: StoryPortraitContext) { - if (background === 'story-red-cliffs' || id === 'eighteenth-red-cliffs-wind') { - return 'redcliffs'; - } - if (background === 'story-wolong') { - return 'base'; - } - if ( - id === 'northern-prep-back-secured' || - id.startsWith('fifty-fifth-') || - zhugeLiangNorthernBackgrounds.has(background) - ) { - return 'northern'; - } - return 'campaign'; +function storyPortraitVariantRuleMatches( + rule: StoryPortraitVariantRule, + { id, background }: StoryPortraitContext +) { + return Boolean( + rule.pageIds?.includes(id) || + rule.pageIdPrefixes?.some((prefix) => id.startsWith(prefix)) || + rule.backgrounds?.includes(background) + ); +} + +function stablePortraitHash(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cb9b942..19bef2d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -12,6 +12,13 @@ import { type BattleScenarioId, type BattleTacticalGuideEventKey } from '../data/battles'; +import { + battleEnvironmentProfileFor, + battleEnvironmentProfileVerification, + type BattleEnvironmentHazeProfile, + type BattleEnvironmentParticleProfile, + type BattleEnvironmentProfile +} from '../data/battleEnvironmentProfiles'; import { getSortieFlow } from '../data/campaignFlow'; import { enemyIntentOpeningGuide, @@ -164,6 +171,9 @@ import { startLazyScene } from './lazyScenes'; const battleReferenceWidth = 1280; const battleReferenceHeight = 720; const battleFhdUiScale = 1.5; +const battleEnvironmentTintDepth = 3.04; +const battleEnvironmentHazeDepth = 3.12; +const battleEnvironmentParticleDepth = 3.3; const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls'; const unitTexture: Record = { @@ -1251,6 +1261,28 @@ type TerrainTileView = { tile: Phaser.GameObjects.Rectangle; }; +type BattleEnvironmentParticleView = { + object: Phaser.GameObjects.Rectangle | Phaser.GameObjects.Ellipse; + profile: BattleEnvironmentParticleProfile; + velocityX: number; + velocityY: number; + angularVelocity: number; + ageMs: number; + swayPhase: number; +}; + +type BattleEnvironmentHazeView = { + object: Phaser.GameObjects.Graphics; + profile: BattleEnvironmentHazeProfile; + width: number; + baseY: number; + velocityX: number; + ageMs: number; + waveAmplitude: number; + wavePhase: number; + pulsePhase: number; +}; + type MiniMapLayout = { x: number; y: number; @@ -3683,6 +3715,11 @@ export class BattleScene extends Phaser.Scene { private debugOverlay?: Phaser.GameObjects.Text; private mapBackground?: Phaser.GameObjects.Image; private mapMask?: Phaser.Display.Masks.GeometryMask; + private battleEnvironmentProfile?: BattleEnvironmentProfile; + private battleEnvironmentObjects: Phaser.GameObjects.GameObject[] = []; + private battleEnvironmentParticles: BattleEnvironmentParticleView[] = []; + private battleEnvironmentHaze: BattleEnvironmentHazeView[] = []; + private battleEnvironmentRandomState = 1; private terrainTileViews: TerrainTileView[] = []; private miniMapLayout?: MiniMapLayout; private miniMapObjects: Phaser.GameObjects.GameObject[] = []; @@ -3778,6 +3815,11 @@ export class BattleScene extends Phaser.Scene { this.facingIndicatorUnitId = undefined; this.mapBackground = undefined; this.mapMask = undefined; + this.battleEnvironmentProfile = undefined; + this.battleEnvironmentObjects = []; + this.battleEnvironmentParticles = []; + this.battleEnvironmentHaze = []; + this.battleEnvironmentRandomState = 1; this.terrainTileViews = []; this.miniMapLayout = undefined; this.miniMapObjects = []; @@ -3861,6 +3903,7 @@ export class BattleScene extends Phaser.Scene { this.hideFirstBattleTutorial(); this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); + this.clearBattleEnvironment(); this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); this.mapResultPopupObjects = []; }); @@ -3922,7 +3965,12 @@ export class BattleScene extends Phaser.Scene { this.combatPresentationCount = { full: 0, map: 0 }; this.combatPresentationLast = undefined; this.fastForwardHeld = false; - soundDirector.playMusic('battle-prep'); + const environmentProfile = battleEnvironmentProfileFor(battleScenario.id); + soundDirector.playSoundscape({ + musicKey: 'battle-prep', + ambienceKey: environmentProfile?.soundscape.ambienceKey, + ambienceVolume: environmentProfile?.soundscape.ambienceVolume + }); this.input.mouse?.disableContextMenu(); this.installBattleAudioUnlock(); this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { @@ -4110,6 +4158,7 @@ export class BattleScene extends Phaser.Scene { private drawBattleScene() { this.drawMap(); + this.drawBattleEnvironment(); this.renderObjectiveMapMarkers(); this.drawUnits(); this.drawSidePanel(); @@ -5245,6 +5294,224 @@ export class BattleScene extends Phaser.Scene { this.updateCameraView(); } + private drawBattleEnvironment() { + this.clearBattleEnvironment(); + const profile = battleEnvironmentProfileFor(battleScenario.id); + this.battleEnvironmentProfile = profile; + if (!profile || !this.mapMask) { + return; + } + + this.battleEnvironmentRandomState = this.battleEnvironmentSeed(profile.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); + tint.setOrigin(0); + tint.setDepth(battleEnvironmentTintDepth); + tint.setMask(this.mapMask); + tint.setName(`battle-environment-${profile.id}-tint`); + this.battleEnvironmentObjects.push(tint); + } + + if (profile.haze) { + this.createBattleEnvironmentHaze(profile.haze); + } + if (profile.particles) { + this.createBattleEnvironmentParticles(profile.particles); + } + } + + private createBattleEnvironmentHaze(profile: BattleEnvironmentHazeProfile) { + const { mapX, mapY, mapWidth, mapHeight } = this.layout; + for (let index = 0; index < profile.count; index += 1) { + const width = mapWidth * this.battleEnvironmentRange(profile.widthRatio); + const height = mapHeight * this.battleEnvironmentRange(profile.heightRatio); + const alpha = this.battleEnvironmentRange(profile.alpha); + const haze = this.add.graphics(); + [ + { scale: 1, alpha: 0.16 }, + { scale: 0.82, alpha: 0.24 }, + { scale: 0.62, alpha: 0.34 } + ].forEach((layer) => { + haze.fillStyle(profile.color, alpha * layer.alpha); + haze.fillEllipse(0, 0, width * layer.scale, height * layer.scale); + }); + const baseY = this.battleEnvironmentBetween(mapY + height * 0.45, mapY + mapHeight - height * 0.45); + haze.setPosition( + this.battleEnvironmentBetween(mapX - width * 0.15, mapX + mapWidth + width * 0.15), + baseY + ); + haze.setDepth(battleEnvironmentHazeDepth); + haze.setMask(this.mapMask!); + haze.setName(`battle-environment-${profile.kind}-${index + 1}`); + const view: BattleEnvironmentHazeView = { + object: haze, + profile, + width, + baseY, + velocityX: this.battleEnvironmentRange(profile.velocityX), + ageMs: this.battleEnvironmentBetween(0, 9000), + waveAmplitude: this.battleEnvironmentRange(profile.waveAmplitude), + wavePhase: this.battleEnvironmentBetween(0, Math.PI * 2), + pulsePhase: this.battleEnvironmentBetween(0, Math.PI * 2) + }; + this.battleEnvironmentHaze.push(view); + this.battleEnvironmentObjects.push(haze); + } + } + + private createBattleEnvironmentParticles(profile: BattleEnvironmentParticleProfile) { + for (let index = 0; index < profile.count; index += 1) { + const object = profile.kind === 'rain' + ? this.add.rectangle(0, 0, 2, 18, 0xffffff, 1) + : this.add.ellipse(0, 0, 5, 5, 0xffffff, 1); + object.setOrigin(0.5); + object.setDepth(battleEnvironmentParticleDepth); + object.setMask(this.mapMask!); + object.setBlendMode(profile.kind === 'ember' ? Phaser.BlendModes.ADD : Phaser.BlendModes.NORMAL); + object.setName(`battle-environment-${profile.kind}-${index + 1}`); + const view: BattleEnvironmentParticleView = { + object, + profile, + velocityX: 0, + velocityY: 0, + angularVelocity: 0, + ageMs: 0, + swayPhase: 0 + }; + this.resetBattleEnvironmentParticle(view, true); + this.battleEnvironmentParticles.push(view); + this.battleEnvironmentObjects.push(object); + } + } + + private resetBattleEnvironmentParticle(view: BattleEnvironmentParticleView, initial: boolean) { + const { mapX, mapY, mapWidth, mapHeight } = this.layout; + const profile = view.profile; + const size = this.battleEnvironmentRange(profile.size); + const alpha = this.battleEnvironmentRange(profile.alpha); + const colorIndex = Math.floor(this.battleEnvironmentRandom() * profile.colors.length); + const color = profile.colors[colorIndex] ?? 0xffffff; + view.velocityX = this.battleEnvironmentRange(profile.velocityX); + view.velocityY = this.battleEnvironmentRange(profile.velocityY); + view.ageMs = this.battleEnvironmentBetween(0, 5000); + view.swayPhase = this.battleEnvironmentBetween(0, Math.PI * 2); + view.angularVelocity = profile.kind === 'rain' ? 0 : this.battleEnvironmentBetween(-0.7, 0.7); + view.object.setFillStyle(color, alpha); + + if (profile.kind === 'rain') { + view.object.setDisplaySize(Math.max(1.2, size * 0.09), size); + view.object.setRotation(Math.atan2(view.velocityY, view.velocityX) - Math.PI / 2); + } else if (profile.kind === 'ember') { + view.object.setDisplaySize(size, size * 1.45); + view.object.setRotation(this.battleEnvironmentBetween(-0.45, 0.45)); + } else { + view.object.setDisplaySize(size, Math.max(2, size * 0.68)); + view.object.setRotation(this.battleEnvironmentBetween(-Math.PI, Math.PI)); + } + + if (initial) { + view.object.setPosition( + this.battleEnvironmentBetween(mapX, mapX + mapWidth), + this.battleEnvironmentBetween(mapY, mapY + mapHeight) + ); + return; + } + + if (profile.kind === 'ember') { + view.object.setPosition( + this.battleEnvironmentBetween(mapX - 12, mapX + mapWidth + 12), + mapY + mapHeight + 18 + ); + return; + } + + view.object.setPosition( + this.battleEnvironmentBetween(mapX - 24, mapX + mapWidth + 24), + mapY - 18 + ); + } + + private updateBattleEnvironment(delta: number) { + if (!this.battleEnvironmentProfile || this.battleEnvironmentObjects.length === 0) { + return; + } + const elapsedMs = Phaser.Math.Clamp(delta, 0, 50); + const elapsedSeconds = elapsedMs / 1000; + const { mapX, mapY, mapWidth, mapHeight } = this.layout; + const right = mapX + mapWidth; + const bottom = mapY + mapHeight; + + this.battleEnvironmentParticles.forEach((view) => { + view.ageMs += elapsedMs; + const sway = view.profile.kind === 'snow' + ? Math.sin(view.ageMs / 420 + view.swayPhase) * 22 + : view.profile.kind === 'ember' + ? Math.sin(view.ageMs / 560 + view.swayPhase) * 9 + : 0; + view.object.x += (view.velocityX + sway) * elapsedSeconds; + view.object.y += view.velocityY * elapsedSeconds; + view.object.rotation += view.angularVelocity * elapsedSeconds; + if ( + view.object.x < mapX - 48 || + view.object.x > right + 48 || + view.object.y < mapY - 48 || + view.object.y > bottom + 48 + ) { + this.resetBattleEnvironmentParticle(view, false); + } + }); + + this.battleEnvironmentHaze.forEach((view) => { + view.ageMs += elapsedMs; + view.object.x += view.velocityX * elapsedSeconds; + view.object.y = view.baseY + Math.sin(view.ageMs / 2600 + view.wavePhase) * view.waveAmplitude; + view.object.setAlpha(0.82 + Math.sin(view.ageMs / 3100 + view.pulsePhase) * 0.12); + const beyondRight = view.velocityX >= 0 && view.object.x - view.width / 2 > right + 24; + const beyondLeft = view.velocityX < 0 && view.object.x + view.width / 2 < mapX - 24; + if (beyondRight || beyondLeft) { + view.object.x = view.velocityX >= 0 ? mapX - view.width / 2 : right + view.width / 2; + view.baseY = this.battleEnvironmentBetween(mapY + 30, bottom - 30); + } + }); + } + + private clearBattleEnvironment() { + this.battleEnvironmentObjects.forEach((object) => { + if (object.active) { + object.destroy(); + } + }); + this.battleEnvironmentObjects = []; + this.battleEnvironmentParticles = []; + this.battleEnvironmentHaze = []; + this.battleEnvironmentProfile = undefined; + } + + private battleEnvironmentSeed(value: string) { + let seed = 2166136261; + for (let index = 0; index < value.length; index += 1) { + seed ^= value.charCodeAt(index); + seed = Math.imul(seed, 16777619); + } + return seed >>> 0 || 1; + } + + private battleEnvironmentRandom() { + this.battleEnvironmentRandomState = ( + Math.imul(this.battleEnvironmentRandomState, 1664525) + 1013904223 + ) >>> 0; + return this.battleEnvironmentRandomState / 0x100000000; + } + + private battleEnvironmentBetween(minimum: number, maximum: number) { + return minimum + (maximum - minimum) * this.battleEnvironmentRandom(); + } + + private battleEnvironmentRange(range: readonly [number, number]) { + return this.battleEnvironmentBetween(range[0], range[1]); + } + private drawUnits() { battleUnits.forEach((unit) => { const sizeRatio = this.unitDisplaySizeRatio(unit); @@ -6408,6 +6675,7 @@ export class BattleScene extends Phaser.Scene { } update(_time: number, delta: number) { + this.updateBattleEnvironment(delta); this.updateEdgeScroll(delta); this.positionEnemyIntentVisuals(); this.refreshSideQuickTabAvailability(); @@ -17036,6 +17304,7 @@ export class BattleScene extends Phaser.Scene { } this.triggeredBattleEvents.add(key); + soundDirector.playOperationAlert(); const message = `${title}\n${lines.join('\n')}`; this.pushBattleLog(message); this.renderSituationPanel(message); @@ -17152,6 +17421,8 @@ export class BattleScene extends Phaser.Scene { } if (state.status === 'done') { this.triggerObjectiveAchievedFeedback(objective, state); + } else if (state.status === 'failed') { + this.triggerObjectiveFailedFeedback(state); } }); } @@ -17345,6 +17616,9 @@ export class BattleScene extends Phaser.Scene { } this.triggeredBattleEvents.add(key); + if (!this.battleOutcome) { + soundDirector.playObjectiveAchieved(); + } const rewardText = objective.rewardGold > 0 ? `보상 +${objective.rewardGold}` : '보상 없음'; this.pushBattleLog(`목표 달성: ${state.label}\n${state.detail} · ${rewardText}`); this.showObjectiveMapFeedback(objective, state, [`목표 달성`, objective.rewardGold > 0 ? `+${objective.rewardGold}` : '완료'], '#a8ffd0', '#072416'); @@ -17357,6 +17631,9 @@ export class BattleScene extends Phaser.Scene { } this.triggeredBattleEvents.add(key); + if (!this.battleOutcome) { + soundDirector.playObjectiveFailed(); + } const rewardText = objective.rewardGold > 0 ? `보상 미획득 +${objective.rewardGold}` : '보상 없음'; this.pushBattleLog(`목표 미달: ${objective.label}\n${objective.failureReason ?? objective.detail} · ${rewardText}`); } @@ -18498,6 +18775,7 @@ export class BattleScene extends Phaser.Scene { return; } const recoveryMessage = this.applyTerrainRecovery('enemy'); + soundDirector.playEnemyTurnCue(); this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); void this.runEnemyTurn(); } @@ -22554,7 +22832,11 @@ export class BattleScene extends Phaser.Scene { this.updateTurnText(); this.renderBattleSpeedControl(); this.updateObjectiveTracker(); + const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size; this.checkBattleEvents(); + if (this.triggeredBattleEvents.size === eventCountBeforeTurnCheck) { + soundDirector.playAllyTurnCue(); + } this.centerCameraOnAllyLine(); const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'] .filter(Boolean) @@ -27336,6 +27618,45 @@ export class BattleScene extends Phaser.Scene { }; } + private battleEnvironmentDebugState() { + const profile = this.battleEnvironmentProfile; + const activeObjects = this.battleEnvironmentObjects.filter((object) => object.active); + const depths = activeObjects.map((object) => ( + object as Phaser.GameObjects.GameObject & { depth: number } + ).depth); + const maskedObjectCount = activeObjects.filter((object) => ( + (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, + active: Boolean(profile && 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, + objectCount: activeObjects.length, + maskedObjectCount, + masked: activeObjects.length === 0 ? true : maskedObjectCount === activeObjects.length, + depthRange: depths.length > 0 + ? { minimum: Math.min(...depths), maximum: Math.max(...depths) } + : null, + mapBounds: this.layout + ? { + x: this.layout.mapX, + y: this.layout.mapY, + width: this.layout.mapWidth, + height: this.layout.mapHeight + } + : null, + tintAlpha: profile?.tint.alpha ?? 0, + fixedPool: true, + verification: battleEnvironmentProfileVerification + }; + } + getDebugState() { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); @@ -27371,6 +27692,7 @@ export class BattleScene extends Phaser.Scene { scene: this.scene.key, battleId: battleScenario.id, battleTitle: battleScenario.title, + environment: this.battleEnvironmentDebugState(), victoryConditionLabel: battleScenario.victoryConditionLabel, defeatConditionLabel: battleScenario.defeatConditionLabel, selectedSortieUnitIds: effectiveSortieUnitIds, diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index b168bdb..c2e465d 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -9,8 +9,7 @@ import { type BattleUiIconKey } from '../data/battleUiIcons'; import { - portraitAssetEntriesForKey, - portraitAssetEntriesForStoryContext, + portraitAssetEntryForStoryContext, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets'; @@ -473,7 +472,7 @@ export class StoryScene extends Phaser.Scene { const pagePortrait = this.pagePortraitAssetEntry(page); const actorPortraits = page.cutscene?.actors - ?.map((actor, index) => this.actorPortraitAssetEntry(actor, index)) + ?.map((actor, index) => this.actorPortraitAssetEntry(actor, index, page)) .filter((entry): entry is PortraitAssetEntry => entry !== undefined) ?? []; const portraits = [pagePortrait, ...actorPortraits] .filter((entry): entry is PortraitAssetEntry => entry !== undefined) @@ -777,12 +776,7 @@ export class StoryScene extends Phaser.Scene { return undefined; } - const entries = portraitAssetEntriesForStoryContext(portraitKey, page); - if (entries.length === 0) { - return undefined; - } - - return entries[this.hashString(page.id) % entries.length]; + return portraitAssetEntryForStoryContext(portraitKey, page); } private pageBackgroundKey(page: StoryPage) { @@ -846,7 +840,7 @@ export class StoryScene extends Phaser.Scene { this.renderCutsceneStage(layer, cutscene); const boardBounds = this.renderTacticalBoard(layer, cutscene); - this.renderCutsceneActorCards(layer, cutscene, boardBounds); + this.renderCutsceneActorCards(layer, cutscene, boardBounds, page); this.renderCutsceneRewards(layer, this.rewardDisplay); } @@ -1032,7 +1026,8 @@ export class StoryScene extends Phaser.Scene { private renderCutsceneActorCards( layer: Phaser.GameObjects.Container, cutscene: StoryCutscene, - board: CutsceneBoardBounds + board: CutsceneBoardBounds, + page: StoryPage ) { const stage = this.cutsceneStageBounds(); const actors = (cutscene.actors ?? []).filter((actor) => actor.unitId !== 'rebel-leader').slice(0, 3); @@ -1043,7 +1038,7 @@ export class StoryScene extends Phaser.Scene { actors.forEach((actor, index) => { const y = firstY + index * (cardH + ui(10)); - this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index); + this.renderCutsceneActorCard(layer, actor, cardX, y, cardW, cardH, index, page); }); if ((cutscene.briefing?.lines ?? []).length > 0) { @@ -1062,7 +1057,8 @@ export class StoryScene extends Phaser.Scene { y: number, width: number, height: number, - index: number + index: number, + page: StoryPage ) { const profile = storyCutsceneActorProfileFor(actor.unitId); if (!profile) { @@ -1093,7 +1089,7 @@ export class StoryScene extends Phaser.Scene { card.fillCircle(x + width - ui(24), y + height / 2, ui(7)); layer.add(card); - const portraitTexture = this.actorPortraitTextureKey(actor, index); + const portraitTexture = this.actorPortraitTextureKey(actor, index, page); const portraitX = x + ui(38); const portraitY = y + height / 2; const portraitFrame = this.add.graphics(); @@ -1611,23 +1607,22 @@ export class StoryScene extends Phaser.Scene { }; } - private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number) { - const entry = this.actorPortraitAssetEntry(actor, index); + private actorPortraitTextureKey(actor: StoryCutsceneActor, index: number, page: StoryPage) { + const entry = this.actorPortraitAssetEntry(actor, index, page); return entry && this.textures.exists(entry.textureKey) ? entry.textureKey : undefined; } - private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number) { + private actorPortraitAssetEntry(actor: StoryCutsceneActor, index: number, page: StoryPage) { const profile = storyCutsceneActorProfileFor(actor.unitId); if (!profile) { return undefined; } - const entries = portraitAssetEntriesForKey(profile.portraitKey); - if (entries.length === 0) { - return undefined; - } - - return entries[this.hashString(`${actor.unitId}-${index}`) % entries.length]; + return portraitAssetEntryForStoryContext( + profile.portraitKey, + page, + `${page.id}:${actor.unitId}:${index}` + ); } private storyUnitFrameIndex(direction: UnitDirection, frame = 0) { @@ -1638,14 +1633,6 @@ export class StoryScene extends Phaser.Scene { return this.textures.exists(textureKey) ? textureKey : 'story-fallback'; } - private hashString(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - return hash; - } - private advance() { if (this.transitioning) { return;