feat: strengthen semantic battle feedback
This commit is contained in:
@@ -55,6 +55,18 @@ The source file in this section was downloaded from its official Pixabay detail
|
||||
|
||||
The narrative cue was resampled to 48 kHz stereo MP3, silence-trimmed to an approximately 1.0-second excerpt, fade-in/out processed, and high-pass and low-pass filtered for unobtrusive playback over story music.
|
||||
|
||||
## Semantic Feedback Cues
|
||||
|
||||
The source files in this section were downloaded from their official Pixabay detail pages on 2026-07-22 and use the Pixabay Content License linked above.
|
||||
|
||||
| Project file | Role | Pixabay title | Creator | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src/assets/audio/sfx/recovery-cue.mp3` | Healing, supply, and terrain recovery feedback | Holy Healing Spell | Coghezzi | https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/ |
|
||||
| `src/assets/audio/sfx/reward-reveal.mp3` | Victory reward arrival feedback | Reward Chime | Universfield | https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/ |
|
||||
| `src/assets/audio/sfx/burn-tick.mp3` | Batched burn-status damage feedback | Electronic element burn spark 1 | FxProSound | https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/ |
|
||||
|
||||
All three cues were silence-trimmed, resampled to 48 kHz stereo MP3, fade-in/out processed, and high-pass and low-pass filtered. Recovery was reduced to approximately 1.9 seconds, reward reveal to 1.4 seconds, and burn damage to 0.7 seconds so feedback begins promptly and does not mask the next action.
|
||||
|
||||
## Sound Effects
|
||||
|
||||
| Project file | Pixabay title | Creator | Source |
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"verify:loader-lifecycle": "node scripts/verify-loader-lifecycle.mjs",
|
||||
"verify:battle-map-assets": "node scripts/verify-battle-map-asset-data.mjs",
|
||||
"verify:audio-assets": "node scripts/verify-audio-asset-data.mjs",
|
||||
"verify:av-feedback": "node scripts/verify-audiovisual-feedback.mjs",
|
||||
"verify:av-feedback:browser": "node scripts/verify-audiovisual-feedback-browser.mjs",
|
||||
"verify:desktop-viewport": "node scripts/verify-desktop-browser-viewport.mjs",
|
||||
"verify:static-data": "node scripts/verify-static-data.mjs",
|
||||
"verify:flow": "node scripts/verify-flow-segments.mjs",
|
||||
@@ -55,7 +57,7 @@
|
||||
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
|
||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:release && pnpm run verify:performance",
|
||||
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
|
||||
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
|
||||
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
|
||||
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
||||
|
||||
@@ -20,6 +20,7 @@ const requiredTacticalCueKeys = [
|
||||
'objective-failure'
|
||||
];
|
||||
const requiredNarrativeCueKeys = ['story-page-turn'];
|
||||
const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick'];
|
||||
const requiredBattleSceneAudioMethods = [
|
||||
'playSoundscape',
|
||||
'playAllyTurnCue',
|
||||
@@ -64,6 +65,23 @@ const narrativeSourceRecord = {
|
||||
'high-pass and low-pass filtered'
|
||||
]
|
||||
};
|
||||
const semanticFeedbackSourceRecords = [
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/recovery-cue.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/',
|
||||
requiredDocumentation: ['Holy Healing Spell', 'Coghezzi', 'approximately 1.9 seconds']
|
||||
},
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/reward-reveal.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/',
|
||||
requiredDocumentation: ['Reward Chime', 'Universfield', '1.4 seconds']
|
||||
},
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/burn-tick.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/',
|
||||
requiredDocumentation: ['Electronic element burn spark 1', 'FxProSound', '0.7 seconds']
|
||||
}
|
||||
];
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
@@ -85,8 +103,10 @@ try {
|
||||
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
||||
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys);
|
||||
validateBattlefieldSourceDocumentation(errors);
|
||||
validateNarrativeSourceDocumentation(errors);
|
||||
validateSemanticFeedbackSourceDocumentation(errors);
|
||||
validateBattleSceneAudioIntegration(errors);
|
||||
validateEffectPools(errors, effectPools, effectTracks);
|
||||
validateEffectPoolRegistration(errors, effectPools);
|
||||
@@ -177,6 +197,39 @@ function validateNarrativeSourceDocumentation(errors) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateSemanticFeedbackSourceDocumentation(errors) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
const sectionHeading = '## Semantic Feedback Cues';
|
||||
const sectionStart = docs.indexOf(sectionHeading);
|
||||
if (sectionStart < 0) {
|
||||
errors.push('docs/audio-sources.md must include the Semantic Feedback Cues section');
|
||||
return;
|
||||
}
|
||||
const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length);
|
||||
const semanticDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart);
|
||||
if (!semanticDocs.includes('2026-07-22')) {
|
||||
errors.push('docs/audio-sources.md must record the 2026-07-22 semantic feedback download date');
|
||||
}
|
||||
['48 kHz stereo MP3', 'silence-trimmed', 'fade-in/out', 'high-pass and low-pass filtered'].forEach((detail) => {
|
||||
if (!semanticDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback processing detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
semanticFeedbackSourceRecords.forEach(({ projectFile, source, requiredDocumentation }) => {
|
||||
if (!semanticDocs.includes(projectFile)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback file "${projectFile}"`);
|
||||
}
|
||||
if (!semanticDocs.includes(source)) {
|
||||
errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`);
|
||||
}
|
||||
requiredDocumentation.forEach((detail) => {
|
||||
if (!semanticDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateBattleSceneAudioIntegration(errors) {
|
||||
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
|
||||
const source = readFileSync(path, 'utf8');
|
||||
|
||||
328
scripts/verify-audiovisual-feedback-browser.mjs
Normal file
328
scripts/verify-audiovisual-feedback-browser.mjs
Normal file
@@ -0,0 +1,328 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const explicitUrl = process.env.AV_FEEDBACK_QA_URL;
|
||||
const targetUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41741/heros_web/');
|
||||
targetUrl.searchParams.set('debug', '1');
|
||||
targetUrl.searchParams.set('renderer', 'canvas');
|
||||
targetUrl.searchParams.set('debugBattleSetup', 'status-ui');
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
if (!explicitUrl) {
|
||||
serverProcess = spawn(
|
||||
process.execPath,
|
||||
['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41741', '--strictPort'],
|
||||
{ cwd: process.cwd(), stdio: 'ignore', windowsHide: true }
|
||||
);
|
||||
await waitForUrl(targetUrl, 20000);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage(desktopBrowserContextOptions);
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__), undefined, { timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
await window.__HEROS_DEBUG__.goToBattle('first-battle-zhuo-commandery');
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return battle?.scene === 'BattleScene' && battle.units?.some((unit) => unit.statusBadges?.length > 0);
|
||||
}, undefined, { timeout: 90000 });
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const battle = debug.battle();
|
||||
const ally = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'burn'));
|
||||
const enemy = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'confusion'));
|
||||
const allyView = ally ? scene.unitViews.get(ally.id) : undefined;
|
||||
const enemyView = enemy ? scene.unitViews.get(enemy.id) : undefined;
|
||||
return {
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
scale: window.visualViewport?.scale ?? 1
|
||||
},
|
||||
canvas: (() => {
|
||||
const bounds = document.querySelector('canvas')?.getBoundingClientRect();
|
||||
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
|
||||
})(),
|
||||
ally: ally && allyView
|
||||
? {
|
||||
id: ally.id,
|
||||
hp: ally.hp,
|
||||
spriteX: allyView.sprite.x,
|
||||
badge: ally.statusBadges.find((badge) => badge.kind === 'burn'),
|
||||
roleSealBounds: objectBounds(allyView.roleSeal),
|
||||
badgeBounds: objectBounds(allyView.statusBadges[0]?.container)
|
||||
}
|
||||
: null,
|
||||
enemy: enemy && enemyView
|
||||
? {
|
||||
id: enemy.id,
|
||||
spriteX: enemyView.sprite.x,
|
||||
badge: enemy.statusBadges.find((badge) => badge.kind === 'confusion'),
|
||||
badgeBounds: objectBounds(enemyView.statusBadges[0]?.container)
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
function objectBounds(object) {
|
||||
if (!object?.active) {
|
||||
return null;
|
||||
}
|
||||
const bounds = object.getBounds();
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(initial.viewport, {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height,
|
||||
dpr: desktopBrowserDeviceScaleFactor,
|
||||
scale: 1
|
||||
});
|
||||
assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 });
|
||||
assert(initial.ally?.badge?.visible, `Expected a visible allied burn badge: ${JSON.stringify(initial.ally)}`);
|
||||
assert(initial.enemy?.badge?.visible, `Expected a visible enemy confusion badge: ${JSON.stringify(initial.enemy)}`);
|
||||
assert(initial.ally.badge.x > initial.ally.spriteX, 'allied status badges should occupy the side opposite the role seal');
|
||||
assert(initial.enemy.badge.x < initial.enemy.spriteX, 'enemy status badges should occupy the side opposite the intent marker');
|
||||
if (initial.ally.roleSealBounds) {
|
||||
assert(!boundsIntersect(initial.ally.roleSealBounds, initial.ally.badgeBounds), 'allied status badge must not overlap the role seal');
|
||||
}
|
||||
await page.screenshot({ path: 'dist/qa-av-status-badges-1920x1080.png', fullPage: true });
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'deployment' &&
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status) &&
|
||||
Boolean(battle.battleHud?.deploymentPanel?.startButtonBounds)
|
||||
);
|
||||
}, undefined, { timeout: 90000 });
|
||||
const startPoint = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const bounds = debug.battle().battleHud.deploymentPanel.startButtonBounds;
|
||||
const canvasBounds = document.querySelector('canvas').getBoundingClientRect();
|
||||
return {
|
||||
x: canvasBounds.left + (bounds.x + bounds.width / 2) * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + (bounds.y + bounds.height / 2) * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
});
|
||||
await page.mouse.click(4, 4);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.started === true, undefined, { timeout: 5000 });
|
||||
await page.mouse.click(startPoint.x, startPoint.y);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.phase === 'idle', undefined, { timeout: 90000 });
|
||||
|
||||
const openingAudio = await page.evaluate(() => window.__HEROS_DEBUG__.battle().audio);
|
||||
assert.equal(openingAudio.started, true, 'the deployment click should unlock audio');
|
||||
assert.equal(openingAudio.semanticCues.lastPlayed?.group, 'operation');
|
||||
assert.equal(openingAudio.semanticCues.lastPlayed?.key, 'operation-alert');
|
||||
assert.equal(openingAudio.lastEffect?.trackKey, 'operation-alert', 'the opening alert must not be overwritten by ui-select');
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 5000 });
|
||||
|
||||
const pairedBadges = await page.evaluate((unitId) => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const unit = scene.debugUnitById(unitId);
|
||||
scene.applyBattleStatus(unit, 'confusion', 2, 1);
|
||||
const view = scene.unitViews.get(unitId);
|
||||
const state = debug.battle().units.find((candidate) => candidate.id === unitId);
|
||||
return {
|
||||
spriteX: view.sprite.x,
|
||||
badges: state.statusBadges,
|
||||
roleSealBounds: objectBounds(view.roleSeal),
|
||||
badgeBounds: view.statusBadges.map((badge) => objectBounds(badge.container))
|
||||
};
|
||||
|
||||
function objectBounds(object) {
|
||||
if (!object?.active) {
|
||||
return null;
|
||||
}
|
||||
const bounds = object.getBounds();
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
}, initial.ally.id);
|
||||
assert.deepEqual(pairedBadges.badges.map((badge) => badge.kind), ['burn', 'confusion']);
|
||||
assert(pairedBadges.badges.every((badge) => badge.visible && badge.x > pairedBadges.spriteX));
|
||||
assert(
|
||||
pairedBadges.badges[1].x > pairedBadges.badges[0].x,
|
||||
'the second allied badge should fan outward instead of folding back over the unit'
|
||||
);
|
||||
pairedBadges.badgeBounds.forEach((bounds) => {
|
||||
assert(!boundsIntersect(pairedBadges.roleSealBounds, bounds), 'paired allied status badges must not overlap the role seal');
|
||||
});
|
||||
|
||||
const tickResult = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_DEBUG__.scene('BattleScene');
|
||||
scene.hideBattleEventBanner();
|
||||
return scene.tickBattleStatuses('ally');
|
||||
});
|
||||
assert.match(tickResult.message, /화상/);
|
||||
await page.waitForFunction(() => {
|
||||
const audio = window.__HEROS_DEBUG__?.audio();
|
||||
return audio?.lastEffect?.trackKey === 'burn-tick' && audio.activeEffectCount > 0;
|
||||
}, undefined, { timeout: 3000 });
|
||||
|
||||
const afterTick = await page.evaluate((unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__.battle();
|
||||
return {
|
||||
feedback: battle.mapResultFeedback,
|
||||
audio: battle.audio,
|
||||
unit: battle.units.find((candidate) => candidate.id === unitId)
|
||||
};
|
||||
}, initial.ally.id);
|
||||
assert(afterTick.feedback.activeCount > 0 && afterTick.feedback.activeCount <= afterTick.feedback.maxActive);
|
||||
assert.deepEqual(afterTick.feedback.last.lines, ['화상 -3', `HP ${initial.ally.hp}→${initial.ally.hp - 3}`]);
|
||||
assert.equal(afterTick.audio.semanticCues.lastPlayed?.group, 'status');
|
||||
assert.equal(afterTick.audio.semanticCues.lastPlayed?.key, 'burn-tick');
|
||||
assert(afterTick.audio.activeEffectCount > 0, 'the browser must keep the decoded burn cue active after playback starts');
|
||||
assert.equal(afterTick.unit.hp, initial.ally.hp - 3);
|
||||
assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'burn')?.turns, 1);
|
||||
assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'confusion')?.turns, 1);
|
||||
await page.screenshot({ path: 'dist/qa-av-status-tick-1920x1080.png', fullPage: true });
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0, undefined, {
|
||||
timeout: 3000
|
||||
});
|
||||
const offscreenTick = await page.evaluate((unitId) => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const unit = scene.debugUnitById(unitId);
|
||||
const camera = scene.getDebugState().camera;
|
||||
const candidates = [
|
||||
{ x: camera.x + camera.visibleColumns, y: unit.y },
|
||||
{ x: camera.x - 1, y: unit.y },
|
||||
{ x: unit.x, y: camera.y + camera.visibleRows },
|
||||
{ x: unit.x, y: camera.y - 1 }
|
||||
];
|
||||
const destination = candidates.find(
|
||||
(tile) =>
|
||||
tile.x >= 0 &&
|
||||
tile.y >= 0 &&
|
||||
tile.x < camera.mapWidth &&
|
||||
tile.y < camera.mapHeight &&
|
||||
!scene.isTileVisible(tile.x, tile.y)
|
||||
);
|
||||
if (!destination) {
|
||||
throw new Error(`No offscreen tile is available for camera ${JSON.stringify(camera)}`);
|
||||
}
|
||||
unit.x = destination.x;
|
||||
unit.y = destination.y;
|
||||
scene.positionUnitView(unit);
|
||||
const result = scene.tickBattleStatuses('ally');
|
||||
const battle = debug.battle();
|
||||
return {
|
||||
message: result.message,
|
||||
tileVisible: scene.isTileVisible(unit.x, unit.y),
|
||||
feedback: battle.mapResultFeedback,
|
||||
unit: battle.units.find((candidate) => candidate.id === unitId)
|
||||
};
|
||||
}, initial.ally.id);
|
||||
assert.match(offscreenTick.message, /화상/);
|
||||
assert.equal(offscreenTick.tileVisible, false);
|
||||
assert.equal(offscreenTick.feedback.activeCount, 0, 'offscreen burn damage must not clamp a popup to the map edge');
|
||||
assert.equal(offscreenTick.unit.hp, initial.ally.hp - 6);
|
||||
assert.equal(offscreenTick.unit.statusBadges.length, 0, 'expired status badges must be removed after the offscreen tick');
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 });
|
||||
const prioritizedBurn = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const recoveryPlayedAt = debug.audio().semanticCues.playedAt.recovery ?? null;
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', true, true);
|
||||
return { selected, recoveryPlayedAt, audio: debug.audio() };
|
||||
});
|
||||
assert.equal(prioritizedBurn.selected, 'status');
|
||||
assert.equal(prioritizedBurn.audio.semanticCues.lastPlayed.group, 'status');
|
||||
assert.equal(prioritizedBurn.audio.lastEffect.trackKey, 'burn-tick');
|
||||
assert.equal(prioritizedBurn.audio.activeEffectCount, 1, 'burn must be the only cue selected when burn and recovery coincide');
|
||||
assert.equal(
|
||||
prioritizedBurn.audio.semanticCues.playedAt.recovery ?? null,
|
||||
prioritizedBurn.recoveryPlayedAt,
|
||||
'lower-priority recovery must not be layered under burn'
|
||||
);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 });
|
||||
const operationPriority = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const before = debug.audio().semanticCues.lastPlayed;
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', true, true, true);
|
||||
return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount };
|
||||
});
|
||||
assert.equal(operationPriority.selected, 'operation');
|
||||
assert.deepEqual(operationPriority.after, operationPriority.before, 'an existing operation cue must suppress all additional turn-start cues');
|
||||
assert.equal(operationPriority.activeEffectCount, 0);
|
||||
|
||||
const recoveryCue = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', false, true);
|
||||
return { selected, audio: debug.audio() };
|
||||
});
|
||||
assert.equal(recoveryCue.selected, 'recovery');
|
||||
assert.equal(recoveryCue.audio.semanticCues.lastPlayed.group, 'recovery');
|
||||
assert.equal(recoveryCue.audio.lastEffect.trackKey, 'recovery-cue');
|
||||
assert.equal(recoveryCue.audio.activeEffectCount, 1, 'the standalone recovery cue must decode and play in the browser');
|
||||
|
||||
assert.deepEqual(pageErrors, [], `Unexpected page errors: ${pageErrors.join('\n')}`);
|
||||
assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`);
|
||||
console.log(
|
||||
'Verified 1920x1080 DPR1 paired status badges, decoded and prioritized cues, offscreen popup suppression, expiry, and opening isolation.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
serverProcess?.kill();
|
||||
}
|
||||
|
||||
function boundsIntersect(left, right) {
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
left.x + left.width <= right.x ||
|
||||
right.x + right.width <= left.x ||
|
||||
left.y + left.height <= right.y ||
|
||||
right.y + right.height <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForUrl(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (serverProcess?.exitCode !== null) {
|
||||
throw new Error(`Preview server exited early with code ${serverProcess.exitCode}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Preview startup is still in progress.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url.href}`);
|
||||
}
|
||||
117
scripts/verify-audiovisual-feedback.mjs
Normal file
117
scripts/verify-audiovisual-feedback.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
||||
const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
|
||||
|
||||
const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses');
|
||||
assert.match(tickStatuses, /let burnTicked = false/);
|
||||
assert.match(tickStatuses, /showMapResultPopup\s*\(/);
|
||||
assert.match(tickStatuses, /`\$\{status\.label\} -\$\{damage\}`/);
|
||||
assert.match(tickStatuses, /flashDamage\s*\(unit, previousHp\)/);
|
||||
assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, unit\.y\)/);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.showMapResultPopup/s);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/s);
|
||||
assert.match(tickStatuses, /if \(playCue && burnTicked\)\s*\{\s*soundDirector\.playBurnTickCue\(\)/s);
|
||||
assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
|
||||
|
||||
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
|
||||
const beginNextAllyTurn = privateMethodBody(battleSource, 'beginNextAllyTurn');
|
||||
const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue');
|
||||
[endAllyTurn, beginNextAllyTurn].forEach((body) => {
|
||||
assert.match(body, /tickBattleStatuses\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /applyTerrainRecovery\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /playTurnStartFeedbackCue/);
|
||||
assert.match(body, /statusOutcomeDelayMs = statusResult\.burnTicked \? 720 : 0/);
|
||||
assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/);
|
||||
assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return');
|
||||
});
|
||||
const cuePriorityMarkers = ['if (operationCuePlayed)', 'if (burnTicked)', 'if (recoveryApplied)', "if (faction === 'ally')"];
|
||||
cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
|
||||
assert.deepEqual(
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)).toSorted((left, right) => left - right),
|
||||
'turn-start cues must prioritize operation, burn, recovery, then the generic faction cue'
|
||||
);
|
||||
assert.doesNotMatch(turnStartCue, /delayedCall/, 'turn-start feedback must select one cue instead of layering delayed cues');
|
||||
|
||||
const supportMap = privateMethodBody(battleSource, 'playSupportMapPresentation');
|
||||
const supportCutIn = privateMethodBody(battleSource, 'playSupportCutIn');
|
||||
const terrainRecovery = privateMethodBody(battleSource, 'applyTerrainRecovery');
|
||||
const tacticalInitiative = privateMethodBody(battleSource, 'activateTacticalInitiativeCommand');
|
||||
const campSupply = privateMethodBody(campSource, 'useSupply');
|
||||
[supportMap, supportCutIn].forEach((body) => {
|
||||
assert.match(body, /result\.usable\.effect === 'heal'/);
|
||||
assert.match(body, /soundDirector\.playRecoveryCue\(\)/);
|
||||
});
|
||||
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(terrainRecovery, /if \(playCue\)/);
|
||||
assert.match(tacticalInitiative, /role === 'support'/);
|
||||
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
|
||||
|
||||
const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement');
|
||||
assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once');
|
||||
|
||||
const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment');
|
||||
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
|
||||
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue');
|
||||
|
||||
const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
|
||||
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups');
|
||||
|
||||
const statusBadges = privateMethodBody(battleSource, 'syncUnitStatusBadges');
|
||||
assert.match(statusBadges, /\.slice\(0, 2\)/, 'map status badges must remain capped at two per unit');
|
||||
assert.match(statusBadges, /createBattleUiIcon/);
|
||||
assert.match(statusBadges, /statusEffectIcon/);
|
||||
assert.match(statusBadges, /statusEffectTone/);
|
||||
assert.match(statusBadges, /setMask\(this\.mapMask\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyBattleStatus'), /syncUnitStatusBadges/);
|
||||
assert.match(tickStatuses, /syncUnitStatusBadges/);
|
||||
assert.match(tacticalInitiative, /battleStatuses\.delete\(supportTarget\.id\)[\s\S]*syncUnitStatusBadges\(supportTarget\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /syncUnitStatusBadges/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34 \+ index \* spacing/, 'paired badges should fan away from the unit instead of folding inward');
|
||||
assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
|
||||
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/);
|
||||
|
||||
for (const [method, group, key] of [
|
||||
['playRecoveryCue', 'recovery', 'recovery-cue'],
|
||||
['playBurnTickCue', 'status', 'burn-tick'],
|
||||
['playRewardRevealCue', 'reward', 'reward-reveal']
|
||||
]) {
|
||||
const body = publicMethodBody(soundSource, method);
|
||||
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`));
|
||||
}
|
||||
assert.match(soundSource, /recovery:\s*1900/, 'recovery cue cooldown should cover its playback window');
|
||||
|
||||
console.log('Verified semantic sound cues, map status feedback, capped status badges, and non-overlapping battle opening audio.');
|
||||
|
||||
function privateMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`);
|
||||
}
|
||||
|
||||
function publicMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ (?:async )?${name}\\b`, 'm'), name);
|
||||
}
|
||||
|
||||
function methodBody(source, pattern, label) {
|
||||
const match = pattern.exec(source);
|
||||
assert(match, `missing method marker: ${label}`);
|
||||
const start = match.index;
|
||||
const next = source.indexOf('\n private ', start + match[0].length);
|
||||
const nextPublic = source.indexOf('\n play', start + match[0].length);
|
||||
const candidates = [next, nextPublic].filter((index) => index > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function countMatches(source, pattern) {
|
||||
return Array.from(source.matchAll(pattern)).length;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -103,6 +103,9 @@ try {
|
||||
'objective-success': '/audio/objective-success.ogg',
|
||||
'objective-failure': '/audio/objective-failure.ogg',
|
||||
'story-page-turn': '/audio/story-page-turn.ogg',
|
||||
'recovery-cue': '/audio/recovery-cue.ogg',
|
||||
'burn-tick': '/audio/burn-tick.ogg',
|
||||
'reward-reveal': '/audio/reward-reveal.ogg',
|
||||
'ui-select': '/audio/ui-select.ogg'
|
||||
});
|
||||
director.registerEffectPools({
|
||||
@@ -388,7 +391,7 @@ try {
|
||||
debug = director.getDebugState();
|
||||
assert.deepEqual(
|
||||
debug.semanticCues.cooldownMs,
|
||||
{ turn: 900, operation: 650, objective: 1200, narrative: 220 },
|
||||
{ turn: 900, operation: 650, objective: 1200, narrative: 220, recovery: 1900, status: 280, reward: 1100 },
|
||||
'semantic cue cooldowns should be visible in debug state'
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -447,6 +450,41 @@ try {
|
||||
clock.advance(1100);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'story advance cues should retire after playback');
|
||||
|
||||
const feedbackCueStart = audioHarness.instances.length;
|
||||
const musicDuckRevisionBeforeFeedback = director.getDebugState().musicDuck.revision;
|
||||
assert.equal(director.playRecoveryCue(), true, 'the first recovery cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/recovery-cue.ogg').volume, 0.26, 'recovery cue volume');
|
||||
assert.equal(director.playRecoveryCue(), false, 'rapid recovery cues should be coalesced');
|
||||
assert.equal(director.playBurnTickCue(), true, 'the first batched burn cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/burn-tick.ogg').volume, 0.24, 'burn cue volume');
|
||||
assert.equal(director.playBurnTickCue(), false, 'rapid burn cues should be coalesced');
|
||||
assert.equal(director.playRewardRevealCue(), true, 'the first reward reveal cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/reward-reveal.ogg').volume, 0.26, 'reward reveal cue volume');
|
||||
assert.equal(director.playRewardRevealCue(), false, 'rapid reward reveal cues should be coalesced');
|
||||
debug = director.getDebugState();
|
||||
assert.equal(
|
||||
debug.musicDuck.revision,
|
||||
musicDuckRevisionBeforeFeedback + 1,
|
||||
'only the reward reveal cue should apply a short music duck'
|
||||
);
|
||||
assert.deepEqual(
|
||||
debug.semanticCues.lastSuppressed,
|
||||
{ group: 'reward', key: 'reward-reveal', at: clock.now, remainingMs: 1100 },
|
||||
'debug state should expose a coalesced reward reveal cue'
|
||||
);
|
||||
assert.equal(audioHarness.instances.length, feedbackCueStart + 3, 'coalesced feedback cues must not construct Audio');
|
||||
clock.advance(280);
|
||||
assert.equal(director.playRecoveryCue(), false, 'recovery cues should remain coalesced for the clip playback window');
|
||||
assert.equal(director.playBurnTickCue(), true, 'burn cues should replay after their cooldown');
|
||||
assert.equal(director.playRewardRevealCue(), false, 'reward cues should retain their longer arrival cooldown');
|
||||
clock.advance(820);
|
||||
assert.equal(director.playRewardRevealCue(), true, 'reward cues should replay after the arrival cooldown');
|
||||
assert.equal(director.playRecoveryCue(), false, 'recovery cues should still be coalesced before the clip finishes');
|
||||
clock.advance(800);
|
||||
assert.equal(director.playRecoveryCue(), true, 'recovery cues should replay after the full clip playback window');
|
||||
clock.advance(2000);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'semantic feedback cues should retire after playback');
|
||||
|
||||
const semanticEffectStart = audioHarness.instances.length;
|
||||
director.playMeleeSwing(true, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||
@@ -485,7 +523,7 @@ try {
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
|
||||
'coalesced tactical and narrative cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
|
||||
@@ -33,6 +33,7 @@ const checks = [
|
||||
'scripts/verify-battle-map-asset-data.mjs',
|
||||
'scripts/verify-battle-environment-profiles.mjs',
|
||||
'scripts/verify-audio-asset-data.mjs',
|
||||
'scripts/verify-audiovisual-feedback.mjs',
|
||||
'scripts/verify-sound-director.mjs'
|
||||
];
|
||||
|
||||
|
||||
BIN
src/assets/audio/sfx/burn-tick.mp3
Normal file
BIN
src/assets/audio/sfx/burn-tick.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/recovery-cue.mp3
Normal file
BIN
src/assets/audio/sfx/recovery-cue.mp3
Normal file
Binary file not shown.
BIN
src/assets/audio/sfx/reward-reveal.mp3
Normal file
BIN
src/assets/audio/sfx/reward-reveal.mp3
Normal file
Binary file not shown.
@@ -57,7 +57,7 @@ export type MusicDuckOptions = {
|
||||
releaseMs?: number;
|
||||
};
|
||||
|
||||
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative';
|
||||
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative' | 'recovery' | 'status' | 'reward';
|
||||
|
||||
type MusicDuckState = {
|
||||
multiplier: number;
|
||||
@@ -103,7 +103,10 @@ export class SoundDirector {
|
||||
turn: 900,
|
||||
operation: 650,
|
||||
objective: 1200,
|
||||
narrative: 220
|
||||
narrative: 220,
|
||||
recovery: 1900,
|
||||
status: 280,
|
||||
reward: 1100
|
||||
};
|
||||
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
|
||||
private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number };
|
||||
@@ -442,6 +445,28 @@ export class SoundDirector {
|
||||
});
|
||||
}
|
||||
|
||||
playRecoveryCue() {
|
||||
return this.playSemanticCue('recovery', 'recovery-cue', {
|
||||
volume: 0.26,
|
||||
stopAfterMs: 1900
|
||||
});
|
||||
}
|
||||
|
||||
playBurnTickCue() {
|
||||
return this.playSemanticCue('status', 'burn-tick', {
|
||||
volume: 0.24,
|
||||
stopAfterMs: 680
|
||||
});
|
||||
}
|
||||
|
||||
playRewardRevealCue() {
|
||||
return this.playSemanticCue('reward', 'reward-reveal', {
|
||||
volume: 0.26,
|
||||
stopAfterMs: 1400,
|
||||
duck: { multiplier: 0.76, attackMs: 36, holdMs: 420, releaseMs: 260 }
|
||||
});
|
||||
}
|
||||
|
||||
playStrategyPulse() {
|
||||
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
|
||||
this.playTone(460, 0.08, 0.012, 'sine');
|
||||
|
||||
@@ -14,6 +14,7 @@ import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3';
|
||||
import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3';
|
||||
import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav';
|
||||
import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3';
|
||||
import burnTickUrl from '../../assets/audio/sfx/burn-tick.mp3';
|
||||
import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3';
|
||||
import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3';
|
||||
import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3';
|
||||
@@ -27,6 +28,8 @@ 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 recoveryCueUrl from '../../assets/audio/sfx/recovery-cue.mp3';
|
||||
import rewardRevealUrl from '../../assets/audio/sfx/reward-reveal.mp3';
|
||||
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
|
||||
import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
|
||||
import storyPageTurnUrl from '../../assets/audio/sfx/story-page-turn.mp3';
|
||||
@@ -69,6 +72,9 @@ export const effectTracks = {
|
||||
'operation-alert': operationAlertUrl,
|
||||
'objective-success': objectiveSuccessUrl,
|
||||
'objective-failure': objectiveFailureUrl,
|
||||
'recovery-cue': recoveryCueUrl,
|
||||
'burn-tick': burnTickUrl,
|
||||
'reward-reveal': rewardRevealUrl,
|
||||
'sword-slash': swordSlashUrl,
|
||||
'sword-swing-variant': swordSwingVariantUrl,
|
||||
'combat-impact': combatImpactUrl,
|
||||
|
||||
@@ -1401,11 +1401,21 @@ type TerrainPanelLayout = {
|
||||
messageBounds: HudBounds;
|
||||
};
|
||||
|
||||
type UnitStatusBadgeView = {
|
||||
container: Phaser.GameObjects.Container;
|
||||
background: Phaser.GameObjects.Arc;
|
||||
icon: Phaser.GameObjects.Image;
|
||||
turnsText: Phaser.GameObjects.Text;
|
||||
kind: BattleStatusKind;
|
||||
};
|
||||
|
||||
type UnitView = {
|
||||
sprite: Phaser.GameObjects.Sprite;
|
||||
hitZone: Phaser.GameObjects.Zone;
|
||||
label: Phaser.GameObjects.Text;
|
||||
roleSeal?: Phaser.GameObjects.Text;
|
||||
statusBadges: UnitStatusBadgeView[];
|
||||
statusBadgeSide: -1 | 1;
|
||||
textureBase: string;
|
||||
direction: UnitDirection;
|
||||
baseX: number;
|
||||
@@ -1426,6 +1436,8 @@ type DamageCommand = Exclude<BattleCommand, 'wait'>;
|
||||
type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar';
|
||||
type RosterTab = 'ally' | 'enemy';
|
||||
type ActiveFaction = 'ally' | 'enemy';
|
||||
type BattleStatusTickResult = { message?: string; burnTicked: boolean };
|
||||
type TerrainRecoveryResult = { message?: string; applied: boolean };
|
||||
const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const;
|
||||
type BattleSpeed = (typeof battleSpeedOptions)[number];
|
||||
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close';
|
||||
@@ -5563,6 +5575,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
sprite,
|
||||
hitZone,
|
||||
label,
|
||||
statusBadges: [],
|
||||
statusBadgeSide: unit.faction === 'ally' ? 1 : -1,
|
||||
textureBase,
|
||||
direction: 'south' as UnitDirection,
|
||||
baseX: sprite.x,
|
||||
@@ -6238,6 +6252,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const statusesCleared = this.battleStatuses.get(supportTarget.id)?.length ?? 0;
|
||||
supportTarget.hp += healAmount;
|
||||
this.battleStatuses.delete(supportTarget.id);
|
||||
this.syncUnitStatusBadges(supportTarget);
|
||||
this.tacticalCommand.effectValue = healAmount;
|
||||
this.tacticalCommand.statusesCleared = statusesCleared;
|
||||
this.tacticalCommand.effectPending = false;
|
||||
@@ -6253,7 +6268,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
const previousPhase: BattlePhase = this.phase === 'moving' ? 'moving' : 'idle';
|
||||
this.phase = 'animating';
|
||||
this.hideTacticalInitiativePanel();
|
||||
soundDirector.playStrategyPulse();
|
||||
if (role === 'support') {
|
||||
soundDirector.playRecoveryCue();
|
||||
} else {
|
||||
soundDirector.playStrategyPulse();
|
||||
}
|
||||
this.pushBattleLog(message);
|
||||
this.renderTacticalInitiativeChip();
|
||||
this.renderSortieOrderHud();
|
||||
@@ -7124,6 +7143,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.syncUnitRoleSeal(unit, view);
|
||||
this.syncUnitStatusBadges(unit, view);
|
||||
this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y));
|
||||
view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52);
|
||||
this.setUnitRoleSealPosition(view, view.baseX, view.baseY);
|
||||
@@ -7132,6 +7152,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.setUnitHitZoneEnabled(view, visible);
|
||||
view.label.setVisible(visible);
|
||||
view.roleSeal?.setVisible(visible);
|
||||
view.statusBadges.forEach((badge) => badge.container.setVisible(visible));
|
||||
this.syncUnitMotion(unit, view);
|
||||
this.applyUnitLegibilityStyle(unit, view);
|
||||
}
|
||||
@@ -7174,6 +7195,56 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.setUnitRoleSealPosition(view, view.baseX, view.baseY);
|
||||
}
|
||||
|
||||
private syncUnitStatusBadges(unit: UnitData, view = this.unitViews.get(unit.id)) {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = unit.hp > 0 ? this.unitStatuses(unit).slice(0, 2) : [];
|
||||
while (view.statusBadges.length > statuses.length) {
|
||||
view.statusBadges.pop()?.container.destroy();
|
||||
}
|
||||
|
||||
statuses.forEach((status, index) => {
|
||||
let badge = view.statusBadges[index];
|
||||
const size = Math.max(this.battleUiLength(16), Math.round(this.layout.tileSize * 0.3));
|
||||
if (!badge?.container.active) {
|
||||
const container = this.add.container(0, 0);
|
||||
const background = this.add.circle(0, 0, size * 0.56, 0x071018, 0.96);
|
||||
const icon = this.createBattleUiIcon(0, 0, this.statusEffectIcon(status.kind), size);
|
||||
const turnsText = this.add.text(size * 0.42, size * 0.34, `${status.turns}`, {
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSize: this.battleUiFontSize(7),
|
||||
color: '#fff8e8',
|
||||
fontStyle: '700',
|
||||
backgroundColor: 'rgba(57, 14, 10, 0.96)',
|
||||
stroke: '#120704',
|
||||
strokeThickness: this.battleUiLength(1),
|
||||
padding: { x: this.battleUiLength(1), y: 0 }
|
||||
});
|
||||
turnsText.setOrigin(0.5);
|
||||
container.add([background, icon, turnsText]);
|
||||
container.setDepth(11.22);
|
||||
container.setName(`unit-status-${unit.id}-${index}`);
|
||||
if (this.mapMask) {
|
||||
container.setMask(this.mapMask);
|
||||
}
|
||||
badge = { container, background, icon, turnsText, kind: status.kind };
|
||||
view.statusBadges[index] = badge;
|
||||
}
|
||||
|
||||
const tone = this.statusEffectTone(status.kind);
|
||||
badge.kind = status.kind;
|
||||
badge.background.setStrokeStyle(this.battleUiLength(2), tone, 0.96);
|
||||
badge.icon.setFrame(battleUiIconFrames[this.statusEffectIcon(status.kind)]);
|
||||
badge.turnsText.setText(`${status.turns}`);
|
||||
});
|
||||
|
||||
this.setUnitStatusBadgePositions(view, view.baseX, view.baseY);
|
||||
const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y);
|
||||
view.statusBadges.forEach((badge) => badge.container.setVisible(visible));
|
||||
}
|
||||
|
||||
private refreshUnitLegibilityStyles() {
|
||||
battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit));
|
||||
}
|
||||
@@ -7201,6 +7272,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
label.setBackgroundColor(this.unitLabelBackground(unit));
|
||||
});
|
||||
view.roleSeal?.setVisible(visible).setAlpha(1);
|
||||
this.syncUnitStatusBadges(unit, view);
|
||||
view.statusBadges.forEach((badge) => badge.container.setVisible(visible).setAlpha(1));
|
||||
}
|
||||
|
||||
private unitLabelBackground(unit: UnitData) {
|
||||
@@ -8944,7 +9017,6 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.refreshEnemyIntentForecast();
|
||||
this.showOpeningBattleEvent();
|
||||
this.scheduleFirstBattleTutorial();
|
||||
soundDirector.playSelect();
|
||||
}
|
||||
|
||||
private deploymentSignature() {
|
||||
@@ -18777,12 +18849,18 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.updateTurnText();
|
||||
this.renderBattleSpeedControl();
|
||||
this.updateObjectiveTracker();
|
||||
const statusMessage = this.tickBattleStatuses('enemy');
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
|
||||
const statusResult = this.tickBattleStatuses('enemy', false);
|
||||
const statusMessage = statusResult.message;
|
||||
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
|
||||
if (statusResult.burnTicked) {
|
||||
soundDirector.playBurnTickCue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const recoveryMessage = this.applyTerrainRecovery('enemy');
|
||||
soundDirector.playEnemyTurnCue();
|
||||
const recoveryResult = this.applyTerrainRecovery('enemy', false);
|
||||
const recoveryMessage = recoveryResult.message;
|
||||
this.playTurnStartFeedbackCue('enemy', statusResult.burnTicked, recoveryResult.applied);
|
||||
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
|
||||
void this.runEnemyTurn();
|
||||
}
|
||||
@@ -19854,10 +19932,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
|
||||
volume: result.usable.command === 'strategy' ? 0.3 : 0.24,
|
||||
stopAfterMs: 620
|
||||
});
|
||||
if (result.usable.effect === 'heal') {
|
||||
soundDirector.playRecoveryCue();
|
||||
} else {
|
||||
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
|
||||
volume: result.usable.command === 'strategy' ? 0.3 : 0.24,
|
||||
stopAfterMs: 620
|
||||
});
|
||||
}
|
||||
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.18, this.layout.tileSize * 0.3);
|
||||
ring.setStrokeStyle(4, accent, 0.92);
|
||||
ring.setDepth(12.8);
|
||||
@@ -20629,10 +20711,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
effect.setDepth(depth + 9);
|
||||
this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true });
|
||||
this.showSortieSupportCombatEffect(result, left + panelWidth / 2, top + 156, depth + 10);
|
||||
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
|
||||
volume: result.usable.command === 'strategy' ? 0.38 : 0.28,
|
||||
stopAfterMs: 900
|
||||
});
|
||||
if (result.usable.effect === 'heal') {
|
||||
soundDirector.playRecoveryCue();
|
||||
} else {
|
||||
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
|
||||
volume: result.usable.command === 'strategy' ? 0.38 : 0.28,
|
||||
stopAfterMs: 900
|
||||
});
|
||||
}
|
||||
|
||||
const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 202, 392);
|
||||
targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1);
|
||||
@@ -22780,6 +22866,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (view.roleSeal) {
|
||||
this.tweens.killTweensOf(view.roleSeal);
|
||||
}
|
||||
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
|
||||
this.setUnitBasePosition(view, startX, startY);
|
||||
this.resetUnitSpritePose(view);
|
||||
view.label.setPosition(startX, startY + this.layout.tileSize * 0.52);
|
||||
@@ -22816,6 +22903,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
view.statusBadges.forEach((badge, index) => {
|
||||
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
|
||||
this.tweens.add({
|
||||
targets: badge.container,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
duration: movementDuration,
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22825,8 +22922,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.turnNumber += 1;
|
||||
const statusMessage = this.tickBattleStatuses('ally');
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
|
||||
const statusResult = this.tickBattleStatuses('ally', false);
|
||||
const statusMessage = statusResult.message;
|
||||
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
|
||||
if (statusResult.burnTicked) {
|
||||
soundDirector.playBurnTickCue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const expiredBuffMessage = this.tickBattleBuffs();
|
||||
@@ -22834,16 +22936,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.fastForwardHeld = false;
|
||||
this.actedUnitIds.clear();
|
||||
this.attackIntents = this.emptyAllyTurnAttackIntents();
|
||||
const recoveryMessage = this.applyTerrainRecovery('ally');
|
||||
const recoveryResult = this.applyTerrainRecovery('ally', false);
|
||||
const recoveryMessage = recoveryResult.message;
|
||||
this.resetActedStyles();
|
||||
this.updateTurnText();
|
||||
this.renderBattleSpeedControl();
|
||||
this.updateObjectiveTracker();
|
||||
const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size;
|
||||
this.checkBattleEvents();
|
||||
if (this.triggeredBattleEvents.size === eventCountBeforeTurnCheck) {
|
||||
soundDirector.playAllyTurnCue();
|
||||
}
|
||||
const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck;
|
||||
this.playTurnStartFeedbackCue('ally', statusResult.burnTicked, recoveryResult.applied, operationCuePlayed);
|
||||
this.centerCameraOnAllyLine();
|
||||
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
|
||||
.filter(Boolean)
|
||||
@@ -22871,7 +22973,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
return [];
|
||||
}
|
||||
|
||||
private applyTerrainRecovery(faction: ActiveFaction) {
|
||||
private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult {
|
||||
const messages: string[] = [];
|
||||
|
||||
battleUnits
|
||||
@@ -22902,13 +23004,40 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return undefined;
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 });
|
||||
if (playCue) {
|
||||
soundDirector.playRecoveryCue();
|
||||
}
|
||||
const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`;
|
||||
this.pushBattleLog(message);
|
||||
return message;
|
||||
return { message, applied: true };
|
||||
}
|
||||
|
||||
private playTurnStartFeedbackCue(
|
||||
faction: ActiveFaction,
|
||||
burnTicked: boolean,
|
||||
recoveryApplied: boolean,
|
||||
operationCuePlayed = false
|
||||
) {
|
||||
if (operationCuePlayed) {
|
||||
return 'operation';
|
||||
}
|
||||
if (burnTicked) {
|
||||
soundDirector.playBurnTickCue();
|
||||
return 'status';
|
||||
}
|
||||
if (recoveryApplied) {
|
||||
soundDirector.playRecoveryCue();
|
||||
return 'recovery';
|
||||
}
|
||||
if (faction === 'ally') {
|
||||
soundDirector.playAllyTurnCue();
|
||||
} else {
|
||||
soundDirector.playEnemyTurnCue();
|
||||
}
|
||||
return 'turn';
|
||||
}
|
||||
|
||||
private turnStartEquipmentRecovery(unit: UnitData) {
|
||||
@@ -23516,12 +23645,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.battleStatuses.set(unit.id, unitStatuses);
|
||||
this.syncUnitStatusBadges(unit);
|
||||
return { ...nextStatus };
|
||||
}
|
||||
|
||||
private tickBattleStatuses(faction: ActiveFaction) {
|
||||
private tickBattleStatuses(faction: ActiveFaction, playCue = true): BattleStatusTickResult {
|
||||
const messages: string[] = [];
|
||||
const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0);
|
||||
let burnTicked = false;
|
||||
|
||||
units.forEach((unit) => {
|
||||
const statuses = this.unitStatuses(unit);
|
||||
@@ -23535,10 +23666,25 @@ export class BattleScene extends Phaser.Scene {
|
||||
const previousHp = unit.hp;
|
||||
const damage = Math.min(unit.hp, Math.max(1, status.power));
|
||||
unit.hp = Math.max(0, unit.hp - damage);
|
||||
burnTicked = true;
|
||||
messages.push(`${unit.name} ${status.label}: 지속 피해 ${damage} · HP ${previousHp}→${unit.hp}`);
|
||||
const visibleOnMap = this.isTileVisible(unit.x, unit.y);
|
||||
if (visibleOnMap) {
|
||||
this.showMapResultPopup(
|
||||
unit,
|
||||
[`${status.label} -${damage}`, `HP ${previousHp}→${unit.hp}`],
|
||||
'#ffb07c',
|
||||
'#2b0b05',
|
||||
17,
|
||||
30
|
||||
);
|
||||
}
|
||||
if (unit.hp <= 0) {
|
||||
this.applyDefeatedStyle(unit);
|
||||
} else {
|
||||
if (visibleOnMap) {
|
||||
this.flashDamage(unit, previousHp);
|
||||
}
|
||||
this.syncUnitMotion(unit);
|
||||
}
|
||||
}
|
||||
@@ -23565,16 +23711,20 @@ export class BattleScene extends Phaser.Scene {
|
||||
} else {
|
||||
this.battleStatuses.delete(unit.id);
|
||||
}
|
||||
this.syncUnitStatusBadges(unit);
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return undefined;
|
||||
return { burnTicked: false };
|
||||
}
|
||||
|
||||
if (playCue && burnTicked) {
|
||||
soundDirector.playBurnTickCue();
|
||||
}
|
||||
this.updateMiniMap();
|
||||
const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}건` : ''}`;
|
||||
this.pushBattleLog(message);
|
||||
return message;
|
||||
return { message, burnTicked };
|
||||
}
|
||||
|
||||
private tickBattleBuffs() {
|
||||
@@ -23697,6 +23847,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (view.roleSeal) {
|
||||
this.tweens.killTweensOf(view.roleSeal);
|
||||
}
|
||||
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
|
||||
this.setUnitBasePosition(view, startX, startY);
|
||||
this.resetUnitSpritePose(view);
|
||||
view.label.setPosition(startX, startY + this.layout.tileSize * 0.52);
|
||||
@@ -23732,6 +23883,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
view.statusBadges.forEach((badge, index) => {
|
||||
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
|
||||
this.tweens.add({
|
||||
targets: badge.container,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
duration: movementDuration,
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private movementTileDistance(unit: UnitData, x: number, y: number) {
|
||||
@@ -23973,6 +24134,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
view.sprite.setPosition(x, y);
|
||||
view.hitZone.setPosition(x, y);
|
||||
view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize);
|
||||
this.setUnitStatusBadgePositions(view, x, y);
|
||||
}
|
||||
|
||||
private setUnitRoleSealPosition(view: UnitView, x: number, y: number) {
|
||||
@@ -23982,6 +24144,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
private setUnitStatusBadgePositions(view: UnitView, x: number, y: number) {
|
||||
view.statusBadges.forEach((badge, index) => {
|
||||
const position = this.unitStatusBadgePosition(view, x, y, index);
|
||||
badge.container.setPosition(position.x, position.y);
|
||||
});
|
||||
}
|
||||
|
||||
private unitStatusBadgePosition(view: UnitView, x: number, y: number, index: number) {
|
||||
const spacing = this.layout.tileSize * 0.29;
|
||||
return {
|
||||
x: x + view.statusBadgeSide * (this.layout.tileSize * 0.34 + index * spacing),
|
||||
y: y - this.layout.tileSize * 0.38
|
||||
};
|
||||
}
|
||||
|
||||
private setUnitHitZoneEnabled(view: UnitView, enabled: boolean) {
|
||||
view.hitZone.setVisible(enabled);
|
||||
if (view.hitZone.input) {
|
||||
@@ -24283,6 +24460,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
this.syncUnitStatusBadges(unit, view);
|
||||
|
||||
view.sprite.stop();
|
||||
view.sprite.disableInteractive();
|
||||
@@ -24297,6 +24475,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
label.setBackgroundColor('');
|
||||
});
|
||||
view.roleSeal?.setAlpha(0).setVisible(false);
|
||||
view.statusBadges.forEach((badge) => badge.container.setAlpha(0).setVisible(false));
|
||||
}
|
||||
|
||||
private faceUnitToward(unit: UnitData, target: UnitData) {
|
||||
@@ -24462,7 +24641,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) {
|
||||
const view = this.unitViews.get(unit.id);
|
||||
if (!view) {
|
||||
if (!view || !this.isTileVisible(unit.x, unit.y)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -28012,6 +28191,13 @@ export class BattleScene extends Phaser.Scene {
|
||||
roleSealPresent: Boolean(this.unitViews.get(unit.id)?.roleSeal),
|
||||
roleSealText: this.unitViews.get(unit.id)?.roleSeal?.text ?? null,
|
||||
roleSealVisible: this.unitViews.get(unit.id)?.roleSeal?.visible ?? false,
|
||||
statusBadges: (this.unitViews.get(unit.id)?.statusBadges ?? []).map((badge) => ({
|
||||
kind: badge.kind,
|
||||
visible: badge.container.visible,
|
||||
x: badge.container.x,
|
||||
y: badge.container.y,
|
||||
turns: Number.parseInt(badge.turnsText.text, 10) || 0
|
||||
})),
|
||||
level: unit.level,
|
||||
exp: unit.exp,
|
||||
hp: unit.hp,
|
||||
|
||||
@@ -13352,6 +13352,7 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
this.victoryRewardActions.push({ id: definition.id, label: definition.label, button });
|
||||
});
|
||||
soundDirector.playRewardRevealCue();
|
||||
}
|
||||
|
||||
private completeVictoryRewardAcknowledgement(
|
||||
@@ -22875,7 +22876,7 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
this.campaign = saveCampaignState(campaign);
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });
|
||||
soundDirector.playRecoveryCue();
|
||||
|
||||
const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
|
||||
const bondText = bondGain > 0 ? `연결 공명 총 +${bondGain}` : '';
|
||||
|
||||
Reference in New Issue
Block a user