feat: deepen battle atmosphere and portrait eras
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
168
scripts/verify-battle-environment-profiles.mjs
Normal file
168
scripts/verify-battle-environment-profiles.mjs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
BIN
src/assets/audio/ambience/battle-fire.mp3
Normal file
BIN
src/assets/audio/ambience/battle-fire.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/ambience/battle-rain.mp3
Normal file
BIN
src/assets/audio/ambience/battle-rain.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/ally-turn.mp3
Normal file
BIN
src/assets/audio/sfx/ally-turn.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/enemy-turn.mp3
Normal file
BIN
src/assets/audio/sfx/enemy-turn.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/objective-failure.mp3
Normal file
BIN
src/assets/audio/sfx/objective-failure.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/objective-success.mp3
Normal file
BIN
src/assets/audio/sfx/objective-success.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/operation-alert.mp3
Normal file
BIN
src/assets/audio/sfx/operation-alert.mp3
Normal file
Binary file not shown.
@@ -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]])
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
324
src/game/data/battleEnvironmentProfiles.ts
Normal file
324
src/game/data/battleEnvironmentProfiles.ts
Normal 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)
|
||||
)
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user