feat: deepen battle atmosphere and portrait eras

This commit is contained in:
2026-07-21 23:32:34 +09:00
parent f39de4f47d
commit 3a41f8b671
20 changed files with 1471 additions and 58 deletions

View File

@@ -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 |

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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'
];

View File

@@ -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);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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<SemanticCueGroup, number> = {
turn: 900,
operation: 650,
objective: 1200
};
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
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]])
),

View File

@@ -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,

View File

@@ -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<Record<BattleScenarioId, BattleEnvironmentProfile>>;
export const battleEnvironmentProfiles: Readonly<Partial<Record<BattleScenarioId, BattleEnvironmentProfile>>> = profiles;
export function battleEnvironmentProfileFor(battleId: BattleScenarioId | string) {
return battleEnvironmentProfiles[battleId as BattleScenarioId];
}
export function validateBattleEnvironmentProfiles() {
const errors: string[] = [];
const expectedIds = new Set<string>(battleEnvironmentProfileBattleIds);
const entries = Object.entries(battleEnvironmentProfiles) as Array<[string, BattleEnvironmentProfile]>;
const profileIds = new Set<string>();
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)
)
});

View File

@@ -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<Record<string, StoryPortraitVariantRoute>> = {
'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;
}

View File

@@ -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<string, string> = {
@@ -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,

View File

@@ -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;