feat: improve tactical audiovisual clarity

This commit is contained in:
2026-07-22 20:08:23 +09:00
parent d3af88c2a9
commit 8bda36d46d
5 changed files with 663 additions and 58 deletions

View File

@@ -237,12 +237,27 @@ try {
message: result.message,
tileVisible: scene.isTileVisible(unit.x, unit.y),
feedback: battle.mapResultFeedback,
soundCaption: battle.soundCaption,
expectedDirection: scene.offscreenTileDirection(destination),
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.soundCaption.visible, true, 'offscreen burn damage should create one fixed sound caption');
assert.equal(offscreenTick.soundCaption.kind, 'status');
assert.match(offscreenTick.soundCaption.text, /화상 피해/);
assert.equal(offscreenTick.soundCaption.direction, offscreenTick.expectedDirection);
assert(
offscreenTick.soundCaption.bounds.x >= offscreenTick.feedback.mapBounds.x &&
offscreenTick.soundCaption.bounds.y >= offscreenTick.feedback.mapBounds.y &&
offscreenTick.soundCaption.bounds.x + offscreenTick.soundCaption.bounds.width <=
offscreenTick.feedback.mapBounds.x + offscreenTick.feedback.mapBounds.width &&
offscreenTick.soundCaption.bounds.y + offscreenTick.soundCaption.bounds.height <=
offscreenTick.feedback.mapBounds.y + offscreenTick.feedback.mapBounds.height,
'the fixed sound caption must remain inside the battle map'
);
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');
@@ -251,7 +266,11 @@ try {
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);
const selected = scene.playTurnStartFeedbackCue(
'ally',
{ burnTicked: true, offscreenBurnSources: [] },
{ applied: true, offscreenRecoverySources: [] }
);
return { selected, recoveryPlayedAt, audio: debug.audio() };
});
assert.equal(prioritizedBurn.selected, 'status');
@@ -268,7 +287,12 @@ try {
const debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene');
const before = debug.audio().semanticCues.lastPlayed;
const selected = scene.playTurnStartFeedbackCue('ally', true, true, true);
const selected = scene.playTurnStartFeedbackCue(
'ally',
{ burnTicked: true, offscreenBurnSources: [] },
{ applied: true, offscreenRecoverySources: [] },
true
);
return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount };
});
assert.equal(operationPriority.selected, 'operation');
@@ -278,7 +302,11 @@ try {
const recoveryCue = await page.evaluate(() => {
const debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene');
const selected = scene.playTurnStartFeedbackCue('ally', false, true);
const selected = scene.playTurnStartFeedbackCue(
'ally',
{ burnTicked: false, offscreenBurnSources: [] },
{ applied: true, offscreenRecoverySources: [] }
);
return { selected, audio: debug.audio() };
});
assert.equal(recoveryCue.selected, 'recovery');
@@ -286,10 +314,22 @@ try {
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');
const delayedOutcomeCaption = await page.evaluate((unitId) => {
const debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene');
const unit = scene.debugUnitById(unitId);
scene.completeBattle('defeat', 720);
scene.showOffscreenBurnCaption([{ x: unit.x, y: unit.y }]);
return debug.battle().soundCaption;
}, initial.ally.id);
assert.equal(delayedOutcomeCaption.visible, true, 'lethal offscreen burn must remain captioned before the result reveal');
assert.equal(delayedOutcomeCaption.kind, 'status');
assert.match(delayedOutcomeCaption.text, /화상 피해/);
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.'
'Verified 1920x1080 DPR1 paired status badges, offscreen sound captions, decoded and prioritized cues, popup suppression, expiry, and opening isolation.'
);
} finally {
await browser?.close();

View File

@@ -14,6 +14,8 @@ assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, u
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.match(tickStatuses, /offscreenBurnSources\.push/);
assert.match(tickStatuses, /showOffscreenBurnCaption/);
assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
@@ -27,7 +29,12 @@ const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue')
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')"];
const cuePriorityMarkers = [
'if (operationCuePlayed)',
'if (statusResult.burnTicked)',
'if (recoveryResult.applied)',
"if (faction === 'ally')"
];
cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
assert.deepEqual(
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
@@ -47,6 +54,8 @@ const campSupply = privateMethodBody(campSource, 'useSupply');
});
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
assert.match(terrainRecovery, /if \(playCue\)/);
assert.match(terrainRecovery, /offscreenRecoverySources\.push/);
assert.match(terrainRecovery, /showOffscreenRecoveryCaption/);
assert.match(tacticalInitiative, /role === 'support'/);
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
@@ -58,6 +67,26 @@ const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattle
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue');
const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget');
assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]*\{ playCue: false \}\)/);
const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent');
assert.match(triggerBattleEvent, /options\.playCue !== false/);
const mapCombat = privateMethodBody(battleSource, 'playCombatMapPresentation');
assert.match(mapCombat, /contactDelayMs = Math\.max\(70, Math\.round\(duration \* 0\.42\)\)/);
assert.match(mapCombat, /await this\.waitSceneDuration\(contactDelayMs\);\s*this\.playCombatContactSound\(result\)/s);
assert(
mapCombat.indexOf('playMeleeSwing') < mapCombat.indexOf('await this.waitSceneDuration(contactDelayMs)') &&
mapCombat.indexOf('await this.waitSceneDuration(contactDelayMs)') < mapCombat.indexOf('playCombatContactSound(result)'),
'map combat must play launch motion before delayed contact audio'
);
assert.match(mapCombat, /await Promise\.all\(\[this\.waitSceneDuration\(impactDuration \+ 20\), actorFramePromise\]\)/);
const contactSound = privateMethodBody(battleSource, 'playCombatContactSound');
assert.match(contactSound, /soundDirector\.playStrategyImpact/);
assert.match(contactSound, /soundDirector\.playArrowImpact\(result\.hit, result\.critical\)/);
assert.match(contactSound, /soundDirector\.playCombatImpact\(result\.critical\)/);
assert.match(privateMethodBody(battleSource, 'showCombatImpact'), /playCombatContactSound\(result\)/);
const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups');
@@ -77,6 +106,37 @@ assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34
assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/);
const facingIndicator = privateMethodBody(battleSource, 'showFacingIndicator');
assert.match(facingIndicator, /add\.ellipse/);
assert.match(facingIndicator, /directionSymbol/);
assert.match(facingIndicator, /strokeThickness: this\.battleUiLength\(4\)/);
assert.match(facingIndicator, /if \(!isVisualMotionReduced\(\)\)/);
assert.match(privateMethodBody(battleSource, 'startUnitIdleMotion'), /if \(isVisualMotionReduced\(\)\)\s*\{\s*return;/s);
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /facingIndicatorUnitId === unit\.id[\s\S]*clearFacingIndicator\(\)/);
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /facingIndicatorUnitId === unit\.id[\s\S]*clearFacingIndicator\(\)/);
assert.match(privateMethodBody(battleSource, 'faceUnitToward'), /syncUnitMotion[\s\S]*refreshFacingIndicator\(unit\.id\)/);
const actedStyle = privateMethodBody(battleSource, 'applyUnitLegibilityStyle');
assert.match(actedStyle, /const acted = this\.actedUnitIds\.has\(unit\.id\)/);
assert.match(actedStyle, /view\.sprite\.setAlpha\(acted \? 0\.62 : 1\)/);
assert.match(actedStyle, /view\.actedMarker\?\.setVisible\(visible && acted\)/);
assert.match(privateMethodBody(battleSource, 'createUnitActedMarker'), /'success'/);
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /view\.actedMarker\?\.setAlpha\(0\)\.setVisible\(false\)/);
const drawMiniMap = privateMethodBody(battleSource, 'drawMiniMap');
assert.match(drawMiniMap, /objective\.kind === 'secure-terrain'/);
assert.match(drawMiniMap, /marker\.setAngle\(45\)/);
assert.match(drawMiniMap, /miniMapObjectiveMarkers\.push\(marker\)/);
const soundCaption = privateMethodBody(battleSource, 'showBattleSoundCaption');
assert.match(soundCaption, /battle-sound-caption/);
assert.match(soundCaption, /kind === 'status' \? 'burn' : kind === 'recovery' \? 'heal' : 'success'/);
assert.match(soundCaption, /this\.layout\.gridY \+ this\.layout\.gridHeight/);
assert.match(privateMethodBody(battleSource, 'offscreenTileDirection'), /cameraTileX/);
assert.match(privateMethodBody(battleSource, 'battleSoundCaptionDirectionLabel'), /'north-east': '↗'/);
assert.match(privateMethodBody(battleSource, 'showObjectiveMapFeedback'), /showBattleSoundCaption\('objective'/);
assert.doesNotMatch(soundCaption, /battleOutcome/, 'a lethal offscreen cue must remain captioned during the delayed result reveal');
for (const [method, group, key] of [
['playRecoveryCue', 'recovery', 'recovery-cue'],
['playBurnTickCue', 'status', 'burn-tick'],
@@ -85,9 +145,16 @@ for (const [method, group, key] of [
const body = publicMethodBody(soundSource, method);
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`));
}
const strategyImpact = publicMethodBody(soundSource, 'playStrategyImpact');
assert.match(strategyImpact, /kind === 'fire'/);
assert.match(strategyImpact, /kind === 'roar'/);
assert.match(strategyImpact, /playEffect\('burn-tick'/);
assert.match(strategyImpact, /playEffect\('combat-impact'/);
assert.match(strategyImpact, /playEffect\('strategy-cast'/);
assert.match(soundSource, /recentEffects: this\.recentEffects\.map/);
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.');
console.log('Verified synchronized combat audio, offscreen captions, tactical focus markers, mini-map objectives, status badges, and semantic cues.');
function privateMethodBody(source, name) {
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`);

View File

@@ -106,6 +106,7 @@ try {
'recovery-cue': '/audio/recovery-cue.ogg',
'burn-tick': '/audio/burn-tick.ogg',
'reward-reveal': '/audio/reward-reveal.ogg',
'strategy-cast': '/audio/strategy-cast.ogg',
'ui-select': '/audio/ui-select.ogg'
});
director.registerEffectPools({
@@ -500,6 +501,33 @@ try {
assert(criticalBoom, 'critical accents should layer the delayed boom track');
director.playCombatImpact(false, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-impact', 'combat API should use the impact pool');
director.playStrategyImpact('fire', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'burn-tick', trackKey: 'burn-tick', rate: 0.94 },
'fire tactics should use the sharp burn contact layer'
);
director.playStrategyImpact('roar', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'combat-impact', trackKey: 'combat-impact', rate: 0.8 },
'roar tactics should use a low-pitched force impact'
);
director.playStrategyImpact('arcane', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'strategy-cast', trackKey: 'strategy-cast', rate: 1.2 },
'arcane tactics should use a brighter magical contact layer'
);
assert.deepEqual(
director.getDebugState().recentEffects.slice(-3).map(pickEffect),
[
{ key: 'burn-tick', trackKey: 'burn-tick', rate: 0.94 },
{ key: 'combat-impact', trackKey: 'combat-impact', rate: 0.8 },
{ key: 'strategy-cast', trackKey: 'strategy-cast', rate: 1.2 }
],
'recent effect history should retain semantic contact ordering for browser diagnostics'
);
director.playBattleOutcome('victory');
assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track');
director.playBattleOutcome('defeat');
@@ -608,6 +636,10 @@ function assertClose(actual, expected, context) {
assert(Math.abs(actual - expected) < 1e-9, `${context}: expected ${expected}, received ${actual}`);
}
function pickEffect(effect) {
return effect ? { key: effect.key, trackKey: effect.trackKey, rate: effect.rate } : null;
}
function createAudioHarness() {
const instances = [];
let shouldRejectNextPlay = false;