feat: improve tactical audiovisual clarity
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user