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;
|
||||
|
||||
@@ -6,6 +6,8 @@ export type AudioCategoryMultipliers = Record<AudioCategory, number>;
|
||||
|
||||
export type EffectPoolMap = Record<string, readonly string[]>;
|
||||
|
||||
export type StrategyImpactKind = 'fire' | 'roar' | 'arcane';
|
||||
|
||||
export type SoundscapeOptions = {
|
||||
musicKey?: string;
|
||||
ambienceKey?: string;
|
||||
@@ -74,6 +76,16 @@ type MusicDuckState = {
|
||||
};
|
||||
};
|
||||
|
||||
type PlayedEffectDebug = {
|
||||
key: string;
|
||||
trackKey: string;
|
||||
poolKey: string | null;
|
||||
at: number;
|
||||
baseVolume: number;
|
||||
volume: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
export class SoundDirector {
|
||||
private context?: AudioContext;
|
||||
private master?: GainNode;
|
||||
@@ -116,15 +128,8 @@ export class SoundDirector {
|
||||
at: number;
|
||||
remainingMs: number;
|
||||
};
|
||||
private lastEffect?: {
|
||||
key: string;
|
||||
trackKey: string;
|
||||
poolKey: string | null;
|
||||
at: number;
|
||||
baseVolume: number;
|
||||
volume: number;
|
||||
rate: number;
|
||||
};
|
||||
private lastEffect?: PlayedEffectDebug;
|
||||
private readonly recentEffects: PlayedEffectDebug[] = [];
|
||||
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
|
||||
private readonly effectVolume = 0.42;
|
||||
|
||||
@@ -385,6 +390,35 @@ export class SoundDirector {
|
||||
}
|
||||
}
|
||||
|
||||
playStrategyImpact(kind: StrategyImpactKind, critical: boolean) {
|
||||
if (kind === 'fire') {
|
||||
this.playEffect('burn-tick', {
|
||||
volume: critical ? 0.34 : 0.28,
|
||||
rate: critical ? 0.86 : 0.94,
|
||||
stopAfterMs: 680
|
||||
});
|
||||
this.playTone(critical ? 150 : 185, 0.11, critical ? 0.026 : 0.018, 'sawtooth');
|
||||
} else if (kind === 'roar') {
|
||||
this.playEffect('combat-impact', {
|
||||
volume: critical ? 0.4 : 0.3,
|
||||
rate: critical ? 0.72 : 0.8,
|
||||
stopAfterMs: 980
|
||||
});
|
||||
this.playTone(critical ? 92 : 118, 0.16, critical ? 0.03 : 0.022, 'triangle');
|
||||
} else {
|
||||
this.playEffect('strategy-cast', {
|
||||
volume: critical ? 0.34 : 0.26,
|
||||
rate: critical ? 1.12 : 1.2,
|
||||
stopAfterMs: 720
|
||||
});
|
||||
this.playTone(critical ? 820 : 690, 0.12, critical ? 0.022 : 0.016, 'sine');
|
||||
}
|
||||
|
||||
if (critical) {
|
||||
this.playCriticalAccent();
|
||||
}
|
||||
}
|
||||
|
||||
playBattleOutcome(outcome: 'victory' | 'defeat') {
|
||||
this.duckMusic({
|
||||
multiplier: outcome === 'victory' ? 0.34 : 0.24,
|
||||
@@ -496,7 +530,7 @@ export class SoundDirector {
|
||||
effect.muted = this.muted;
|
||||
this.activeEffects.add(effect);
|
||||
this.effectBaseVolumes.set(effect, baseVolume);
|
||||
this.lastEffect = {
|
||||
const playedEffect = {
|
||||
key,
|
||||
trackKey: resolvedTrack.trackKey,
|
||||
poolKey: resolvedTrack.poolKey,
|
||||
@@ -505,6 +539,11 @@ export class SoundDirector {
|
||||
volume: effect.volume,
|
||||
rate: effect.playbackRate
|
||||
};
|
||||
this.lastEffect = playedEffect;
|
||||
this.recentEffects.push(playedEffect);
|
||||
if (this.recentEffects.length > 12) {
|
||||
this.recentEffects.shift();
|
||||
}
|
||||
effect.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
@@ -597,6 +636,7 @@ export class SoundDirector {
|
||||
),
|
||||
activeEffectCount: this.activeEffects.size,
|
||||
lastEffect: this.lastEffect ?? null,
|
||||
recentEffects: this.recentEffects.map((effect) => ({ ...effect })),
|
||||
lastMovementStep: this.lastMovementStep ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from 'phaser';
|
||||
import { soundDirector } from '../audio/SoundDirector';
|
||||
import { soundDirector, type StrategyImpactKind } from '../audio/SoundDirector';
|
||||
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { battleMapAssets } from '../data/battleMapAssets';
|
||||
@@ -1414,6 +1414,7 @@ type UnitView = {
|
||||
hitZone: Phaser.GameObjects.Zone;
|
||||
label: Phaser.GameObjects.Text;
|
||||
roleSeal?: Phaser.GameObjects.Text;
|
||||
actedMarker?: Phaser.GameObjects.Container;
|
||||
statusBadges: UnitStatusBadgeView[];
|
||||
statusBadgeSide: -1 | 1;
|
||||
textureBase: string;
|
||||
@@ -1436,8 +1437,36 @@ 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 };
|
||||
type BattleCaptionSource = { x: number; y: number };
|
||||
type BattleRecoveryCaptionSource = BattleCaptionSource & { name: string; label: string };
|
||||
type BattleStatusTickResult = {
|
||||
message?: string;
|
||||
burnTicked: boolean;
|
||||
offscreenBurnSources: BattleCaptionSource[];
|
||||
};
|
||||
type TerrainRecoveryResult = {
|
||||
message?: string;
|
||||
applied: boolean;
|
||||
offscreenRecoverySources: BattleRecoveryCaptionSource[];
|
||||
};
|
||||
type BattleSoundCaptionKind = 'status' | 'recovery' | 'objective';
|
||||
type BattleSoundCaptionDirection =
|
||||
| 'north'
|
||||
| 'north-east'
|
||||
| 'east'
|
||||
| 'south-east'
|
||||
| 'south'
|
||||
| 'south-west'
|
||||
| 'west'
|
||||
| 'north-west'
|
||||
| 'multiple';
|
||||
type BattleSoundCaptionSnapshot = {
|
||||
kind: BattleSoundCaptionKind;
|
||||
text: string;
|
||||
direction: BattleSoundCaptionDirection;
|
||||
sourceCount: number;
|
||||
bounds: HudBounds;
|
||||
};
|
||||
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';
|
||||
@@ -3668,6 +3697,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
delay: number;
|
||||
lift: number;
|
||||
};
|
||||
private battleSoundCaption?: Phaser.GameObjects.Container;
|
||||
private battleSoundCaptionTimer?: Phaser.Time.TimerEvent;
|
||||
private battleSoundCaptionRevision = 0;
|
||||
private battleSoundCaptionLast?: BattleSoundCaptionSnapshot;
|
||||
private combatCutInRoot?: Phaser.GameObjects.Container;
|
||||
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
private combatCutInStageActive?: CombatCutInStageSnapshot;
|
||||
@@ -3737,6 +3770,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private miniMapLayout?: MiniMapLayout;
|
||||
private miniMapObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>();
|
||||
private miniMapObjectiveMarkers: Phaser.GameObjects.Rectangle[] = [];
|
||||
private miniMapViewport?: Phaser.GameObjects.Rectangle;
|
||||
private miniMapVisible = true;
|
||||
private recentActionLogLayout?: RecentActionLogLayout;
|
||||
@@ -3837,6 +3871,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.miniMapLayout = undefined;
|
||||
this.miniMapObjects = [];
|
||||
this.miniMapUnitDots.clear();
|
||||
this.miniMapObjectiveMarkers = [];
|
||||
this.miniMapViewport = undefined;
|
||||
this.miniMapVisible = true;
|
||||
this.recentActionLogLayout = undefined;
|
||||
@@ -3904,6 +3939,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.mapResultPopupObjects = [];
|
||||
this.mapResultPopupPeakCount = 0;
|
||||
this.mapResultPopupLast = undefined;
|
||||
this.hideBattleSoundCaption();
|
||||
this.battleSoundCaptionRevision = 0;
|
||||
this.battleSoundCaptionLast = undefined;
|
||||
this.combatCutInStageActive = undefined;
|
||||
this.combatCutInStageLast = undefined;
|
||||
this.tacticalCommandCutInCount = 0;
|
||||
@@ -3919,6 +3957,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.clearBattleEnvironment();
|
||||
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
|
||||
this.mapResultPopupObjects = [];
|
||||
this.hideBattleSoundCaption();
|
||||
});
|
||||
this.resultFormationReviewVisible = false;
|
||||
this.resultFormationReviewFeedback = '';
|
||||
@@ -5571,10 +5610,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
label.setMask(this.mapMask);
|
||||
}
|
||||
|
||||
const actedMarker = unit.faction === 'ally'
|
||||
? this.createUnitActedMarker(unit, centerX, centerY)
|
||||
: undefined;
|
||||
|
||||
const view: UnitView = {
|
||||
sprite,
|
||||
hitZone,
|
||||
label,
|
||||
actedMarker,
|
||||
statusBadges: [],
|
||||
statusBadgeSide: unit.faction === 'ally' ? 1 : -1,
|
||||
textureBase,
|
||||
@@ -5591,6 +5635,27 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private createUnitActedMarker(unit: UnitData, x: number, y: number) {
|
||||
const container = this.add.container(
|
||||
x - this.layout.tileSize * 0.31,
|
||||
y + this.layout.tileSize * 0.3
|
||||
);
|
||||
container.setName(`unit-acted-${unit.id}`);
|
||||
container.setDepth(11.18);
|
||||
container.setVisible(false);
|
||||
|
||||
const radius = this.layout.tileSize * 0.115;
|
||||
const background = this.add.circle(0, 0, radius, 0x081016, 0.96);
|
||||
background.setStrokeStyle(this.battleUiLength(2), 0xf4dfaa, 0.96);
|
||||
const iconSize = Math.max(this.battleUiLength(13), Math.round(this.layout.tileSize * 0.17));
|
||||
const icon = this.createBattleUiIcon(0, 0, 'success', iconSize);
|
||||
container.add([background, icon]);
|
||||
if (this.mapMask) {
|
||||
container.setMask(this.mapMask);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
private unitDisplaySizeRatio(unit: UnitData) {
|
||||
const baseRatio = unit.faction === 'ally' ? 1.32 : 1.24;
|
||||
return unit.id === 'guan-yu' ? baseRatio * 1.03 : baseRatio;
|
||||
@@ -6823,6 +6888,30 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
});
|
||||
|
||||
battleScenario.objectives
|
||||
.filter((objective) => objective.kind === 'secure-terrain' && objective.targetTile)
|
||||
.forEach((objective) => {
|
||||
const target = objective.targetTile!;
|
||||
const markerSize = Math.max(this.battleUiLength(6), cellSize + this.battleUiLength(3));
|
||||
const marker = this.add.rectangle(
|
||||
x + target.x * cellSize + cellSize / 2,
|
||||
y + target.y * cellSize + cellSize / 2,
|
||||
markerSize,
|
||||
markerSize,
|
||||
0x0a1014,
|
||||
0.16
|
||||
);
|
||||
marker.setName(`mini-map-objective-${objective.id}`);
|
||||
marker.setData('objectiveId', objective.id);
|
||||
marker.setData('tileX', target.x);
|
||||
marker.setData('tileY', target.y);
|
||||
marker.setAngle(45);
|
||||
marker.setStrokeStyle(this.battleUiLength(2), palette.gold, 1);
|
||||
marker.setDepth(27);
|
||||
this.miniMapObjectiveMarkers.push(marker);
|
||||
this.miniMapObjects.push(marker);
|
||||
});
|
||||
|
||||
battleUnits.forEach((unit) => {
|
||||
const dot = this.add.rectangle(
|
||||
0,
|
||||
@@ -6989,6 +7078,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, true));
|
||||
|
||||
const { x, y, cellSize } = this.miniMapLayout;
|
||||
this.miniMapObjectiveMarkers.forEach((marker) => {
|
||||
const tileX = marker.getData('tileX') as number;
|
||||
const tileY = marker.getData('tileY') as number;
|
||||
marker.setPosition(x + tileX * cellSize + cellSize / 2, y + tileY * cellSize + cellSize / 2);
|
||||
marker.setVisible(true);
|
||||
});
|
||||
this.miniMapUnitDots.forEach((dot, unitId) => {
|
||||
const unit = battleUnits.find((candidate) => candidate.id === unitId);
|
||||
if (!unit) {
|
||||
@@ -7260,20 +7355,22 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
const visible = this.isTileVisible(unit.x, unit.y);
|
||||
const acted = this.actedUnitIds.has(unit.id);
|
||||
this.clearUnitSpriteEffects(view.sprite);
|
||||
view.sprite.setAlpha(1);
|
||||
view.sprite.setAlpha(acted ? 0.62 : 1);
|
||||
view.sprite.setVisible(visible);
|
||||
this.setUnitHitZoneEnabled(view, visible);
|
||||
|
||||
this.updateUnitLabel(view, (label) => {
|
||||
label.setVisible(visible);
|
||||
label.setAlpha(1);
|
||||
label.setAlpha(acted ? 0.76 : 1);
|
||||
label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7');
|
||||
label.setBackgroundColor(this.unitLabelBackground(unit));
|
||||
});
|
||||
view.roleSeal?.setVisible(visible).setAlpha(1);
|
||||
view.roleSeal?.setVisible(visible).setAlpha(acted ? 0.78 : 1);
|
||||
this.syncUnitStatusBadges(unit, view);
|
||||
view.statusBadges.forEach((badge) => badge.container.setVisible(visible).setAlpha(1));
|
||||
view.statusBadges.forEach((badge) => badge.container.setVisible(visible).setAlpha(acted ? 0.78 : 1));
|
||||
view.actedMarker?.setVisible(visible && acted).setAlpha(1);
|
||||
}
|
||||
|
||||
private unitLabelBackground(unit: UnitData) {
|
||||
@@ -11873,7 +11970,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.triggerBattleEvent('first-engagement', '첫 교전', [
|
||||
`${attacker.name}이 ${target.name}을 공격합니다.`,
|
||||
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.'
|
||||
]);
|
||||
], { playCue: false });
|
||||
this.phase = 'animating';
|
||||
const result = this.resolveCombatAction(attacker, target, action, false, usable);
|
||||
this.clearMarkers();
|
||||
@@ -13063,6 +13160,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private showBattleResult(outcome: BattleOutcome) {
|
||||
this.hideBattleResult();
|
||||
this.hideBattleSoundCaption();
|
||||
this.resultAnimationToken += 1;
|
||||
this.resultGaugeAnimations = [];
|
||||
this.resultContinueLockedUntil = 0;
|
||||
@@ -17377,13 +17475,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.triggerBattleEvent(key, event?.title ?? fallbackTitle, event?.lines ?? fallbackLines);
|
||||
}
|
||||
|
||||
private triggerBattleEvent(key: string, title: string, lines: string[]) {
|
||||
private triggerBattleEvent(key: string, title: string, lines: string[], options: { playCue?: boolean } = {}) {
|
||||
if (this.triggeredBattleEvents.has(key) || this.battleOutcome) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggeredBattleEvents.add(key);
|
||||
soundDirector.playOperationAlert();
|
||||
if (options.playCue !== false) {
|
||||
soundDirector.playOperationAlert();
|
||||
}
|
||||
const message = `${title}\n${lines.join('\n')}`;
|
||||
this.pushBattleLog(message);
|
||||
this.renderSituationPanel(message);
|
||||
@@ -18854,13 +18954,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
|
||||
if (statusResult.burnTicked) {
|
||||
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
|
||||
soundDirector.playBurnTickCue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const recoveryResult = this.applyTerrainRecovery('enemy', false);
|
||||
const recoveryMessage = recoveryResult.message;
|
||||
this.playTurnStartFeedbackCue('enemy', statusResult.burnTicked, recoveryResult.applied);
|
||||
this.playTurnStartFeedbackCue('enemy', statusResult, recoveryResult);
|
||||
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
|
||||
void this.runEnemyTurn();
|
||||
}
|
||||
@@ -19804,6 +19905,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
const targetY = defenderView?.sprite.y ?? this.tileCenterY(result.defender.y);
|
||||
const accent = result.critical || result.defeated ? 0xff8f5f : result.action === 'strategy' ? 0x83d6ff : 0xffdf7b;
|
||||
const duration = this.scaledBattleDuration(result.critical || result.defeated ? 300 : 220, 95);
|
||||
const contactDelayMs = Math.max(70, Math.round(duration * 0.42));
|
||||
const impactDuration = Math.max(80, duration - contactDelayMs);
|
||||
const objects: Phaser.GameObjects.GameObject[] = [];
|
||||
let actorFramePromise: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -19836,11 +19939,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
} else {
|
||||
soundDirector.playMeleeSwing(result.hit, result.critical);
|
||||
}
|
||||
if (rangedAttack) {
|
||||
soundDirector.playArrowImpact(result.hit, result.critical);
|
||||
} else if (result.hit) {
|
||||
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
|
||||
}
|
||||
await this.waitSceneDuration(contactDelayMs);
|
||||
this.playCombatContactSound(result);
|
||||
|
||||
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28);
|
||||
ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94);
|
||||
@@ -19883,15 +19983,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
objects.push(badge);
|
||||
|
||||
this.tweens.add({ targets: ring, scale: result.critical || result.defeated ? 2.2 : 1.7, alpha: 0, duration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) });
|
||||
this.tweens.add({ targets: ring, scale: result.critical || result.defeated ? 2.2 : 1.7, alpha: 0, duration: impactDuration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration: impactDuration, ease: 'Sine.easeOut' });
|
||||
this.tweens.add({
|
||||
targets: badge,
|
||||
y: badge.y - this.battleUiLength(10),
|
||||
alpha: 0,
|
||||
delay: Math.round(impactDuration * 0.45),
|
||||
duration: Math.round(impactDuration * 0.55)
|
||||
});
|
||||
if (result.critical || result.defeated) {
|
||||
this.shakeCamera(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
|
||||
}
|
||||
|
||||
this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth);
|
||||
await Promise.all([this.delay(duration + 20), actorFramePromise]);
|
||||
await Promise.all([this.waitSceneDuration(impactDuration + 20), actorFramePromise]);
|
||||
if (attackerView) {
|
||||
this.syncUnitMotion(result.attacker, attackerView);
|
||||
}
|
||||
@@ -22647,13 +22753,31 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.tweens.add({ targets: spark, angle: 180, scale: 1.18, alpha: 0.08, duration: 230, ease: 'Sine.easeOut' });
|
||||
}
|
||||
|
||||
private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) {
|
||||
if (this.isRangedAttack(result)) {
|
||||
soundDirector.playArrowImpact(true, result.critical);
|
||||
} else {
|
||||
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
|
||||
private playCombatContactSound(result: CombatResult) {
|
||||
if (result.action === 'strategy') {
|
||||
if (result.hit) {
|
||||
const motionKind = this.specialDamageMotionKind(result);
|
||||
const strategyKind: StrategyImpactKind = motionKind === 'fire' || motionKind === 'roar'
|
||||
? motionKind
|
||||
: 'arcane';
|
||||
soundDirector.playStrategyImpact(strategyKind, result.critical);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRangedAttack(result)) {
|
||||
soundDirector.playArrowImpact(result.hit, result.critical);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.hit) {
|
||||
soundDirector.playCombatImpact(result.critical);
|
||||
}
|
||||
}
|
||||
|
||||
private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) {
|
||||
this.playCombatContactSound(result);
|
||||
|
||||
const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92));
|
||||
impact.setDepth(depth);
|
||||
this.tweens.add({
|
||||
@@ -22859,6 +22983,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const direction = this.directionFromDelta(x - fromX, y - fromY);
|
||||
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
|
||||
const movementDuration = this.movementDuration(duration, movementDistance);
|
||||
if (this.facingIndicatorUnitId === unit.id) {
|
||||
this.clearFacingIndicator();
|
||||
}
|
||||
if (showSortieTrail) {
|
||||
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
|
||||
}
|
||||
@@ -22866,6 +22993,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (view.roleSeal) {
|
||||
this.tweens.killTweensOf(view.roleSeal);
|
||||
}
|
||||
if (view.actedMarker) {
|
||||
this.tweens.killTweensOf(view.actedMarker);
|
||||
}
|
||||
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
|
||||
this.setUnitBasePosition(view, startX, startY);
|
||||
this.resetUnitSpritePose(view);
|
||||
@@ -22903,6 +23033,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
if (view.actedMarker) {
|
||||
this.tweens.add({
|
||||
targets: view.actedMarker,
|
||||
x: targetX - this.layout.tileSize * 0.31,
|
||||
y: targetY + this.layout.tileSize * 0.3,
|
||||
duration: movementDuration,
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
view.statusBadges.forEach((badge, index) => {
|
||||
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
|
||||
this.tweens.add({
|
||||
@@ -22927,6 +23066,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
|
||||
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
|
||||
if (statusResult.burnTicked) {
|
||||
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
|
||||
soundDirector.playBurnTickCue();
|
||||
}
|
||||
return;
|
||||
@@ -22945,7 +23085,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size;
|
||||
this.checkBattleEvents();
|
||||
const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck;
|
||||
this.playTurnStartFeedbackCue('ally', statusResult.burnTicked, recoveryResult.applied, operationCuePlayed);
|
||||
this.playTurnStartFeedbackCue('ally', statusResult, recoveryResult, operationCuePlayed);
|
||||
this.centerCameraOnAllyLine();
|
||||
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
|
||||
.filter(Boolean)
|
||||
@@ -22975,6 +23115,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult {
|
||||
const messages: string[] = [];
|
||||
const offscreenRecoverySources: BattleRecoveryCaptionSource[] = [];
|
||||
|
||||
battleUnits
|
||||
.filter((unit) => unit.faction === faction && unit.hp > 0)
|
||||
@@ -23001,10 +23142,17 @@ export class BattleScene extends Phaser.Scene {
|
||||
].filter(Boolean);
|
||||
messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`);
|
||||
this.showTerrainRecoveryPopup(unit, parts.join(' '));
|
||||
if (!this.isTileVisible(unit.x, unit.y)) {
|
||||
offscreenRecoverySources.push({ x: unit.x, y: unit.y, name: unit.name, label: parts.join(' · ') });
|
||||
}
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { applied: false };
|
||||
return { applied: false, offscreenRecoverySources };
|
||||
}
|
||||
|
||||
if (playCue) {
|
||||
this.showOffscreenRecoveryCaption(offscreenRecoverySources);
|
||||
}
|
||||
|
||||
if (playCue) {
|
||||
@@ -23012,23 +23160,42 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}명` : ''}`;
|
||||
this.pushBattleLog(message);
|
||||
return { message, applied: true };
|
||||
return { message, applied: true, offscreenRecoverySources };
|
||||
}
|
||||
|
||||
private showOffscreenRecoveryCaption(sources: readonly BattleRecoveryCaptionSource[]) {
|
||||
if (sources.length === 0) {
|
||||
return;
|
||||
}
|
||||
const [first] = sources;
|
||||
const captionText = sources.length === 1
|
||||
? `회복 · ${first.name} ${first.label}`
|
||||
: `거점 회복 · 화면 밖 ${sources.length}명`;
|
||||
this.showBattleSoundCaption('recovery', captionText, sources);
|
||||
}
|
||||
|
||||
private showOffscreenBurnCaption(sources: readonly BattleCaptionSource[]) {
|
||||
if (sources.length > 0) {
|
||||
this.showBattleSoundCaption('status', `화상 피해 · 화면 밖 ${sources.length}명`, sources);
|
||||
}
|
||||
}
|
||||
|
||||
private playTurnStartFeedbackCue(
|
||||
faction: ActiveFaction,
|
||||
burnTicked: boolean,
|
||||
recoveryApplied: boolean,
|
||||
statusResult: BattleStatusTickResult,
|
||||
recoveryResult: TerrainRecoveryResult,
|
||||
operationCuePlayed = false
|
||||
) {
|
||||
if (operationCuePlayed) {
|
||||
return 'operation';
|
||||
}
|
||||
if (burnTicked) {
|
||||
if (statusResult.burnTicked) {
|
||||
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
|
||||
soundDirector.playBurnTickCue();
|
||||
return 'status';
|
||||
}
|
||||
if (recoveryApplied) {
|
||||
if (recoveryResult.applied) {
|
||||
this.showOffscreenRecoveryCaption(recoveryResult.offscreenRecoverySources);
|
||||
soundDirector.playRecoveryCue();
|
||||
return 'recovery';
|
||||
}
|
||||
@@ -23112,19 +23279,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.applyDefeatedStyle(unit);
|
||||
return;
|
||||
}
|
||||
this.clearUnitSpriteEffects(view.sprite);
|
||||
view.sprite.setAlpha(1);
|
||||
if (unit) {
|
||||
this.syncUnitMotion(unit, view);
|
||||
this.applyUnitLegibilityStyle(unit, view);
|
||||
} else {
|
||||
this.stopUnitWalk(view, view.direction);
|
||||
}
|
||||
this.updateUnitLabel(view, (label) => {
|
||||
label.setAlpha(1);
|
||||
label.setColor(unit?.faction === 'ally' ? '#e7edf7' : '#ffebe7');
|
||||
label.setBackgroundColor(unit ? this.unitLabelBackground(unit) : 'rgba(5, 22, 33, 0.88)');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23652,6 +23812,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private tickBattleStatuses(faction: ActiveFaction, playCue = true): BattleStatusTickResult {
|
||||
const messages: string[] = [];
|
||||
const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0);
|
||||
const offscreenBurnSources: BattleCaptionSource[] = [];
|
||||
let burnTicked = false;
|
||||
|
||||
units.forEach((unit) => {
|
||||
@@ -23678,6 +23839,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
17,
|
||||
30
|
||||
);
|
||||
} else {
|
||||
offscreenBurnSources.push({ x: unit.x, y: unit.y });
|
||||
}
|
||||
if (unit.hp <= 0) {
|
||||
this.applyDefeatedStyle(unit);
|
||||
@@ -23715,7 +23878,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { burnTicked: false };
|
||||
return { burnTicked: false, offscreenBurnSources };
|
||||
}
|
||||
|
||||
if (playCue) {
|
||||
this.showOffscreenBurnCaption(offscreenBurnSources);
|
||||
}
|
||||
|
||||
if (playCue && burnTicked) {
|
||||
@@ -23724,7 +23891,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.updateMiniMap();
|
||||
const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}건` : ''}`;
|
||||
this.pushBattleLog(message);
|
||||
return { message, burnTicked };
|
||||
return { message, burnTicked, offscreenBurnSources };
|
||||
}
|
||||
|
||||
private tickBattleBuffs() {
|
||||
@@ -23840,6 +24007,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const direction = this.directionFromDelta(x - fromX, y - fromY);
|
||||
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
|
||||
const movementDuration = this.movementDuration(duration, movementDistance);
|
||||
if (this.facingIndicatorUnitId === unit.id) {
|
||||
this.clearFacingIndicator();
|
||||
}
|
||||
if (showSortieTrail) {
|
||||
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
|
||||
}
|
||||
@@ -23847,6 +24017,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (view.roleSeal) {
|
||||
this.tweens.killTweensOf(view.roleSeal);
|
||||
}
|
||||
if (view.actedMarker) {
|
||||
this.tweens.killTweensOf(view.actedMarker);
|
||||
}
|
||||
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
|
||||
this.setUnitBasePosition(view, startX, startY);
|
||||
this.resetUnitSpritePose(view);
|
||||
@@ -23883,6 +24056,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
if (view.actedMarker) {
|
||||
this.tweens.add({
|
||||
targets: view.actedMarker,
|
||||
x: targetX - this.layout.tileSize * 0.31,
|
||||
y: targetY + this.layout.tileSize * 0.3,
|
||||
duration: movementDuration,
|
||||
ease: 'Sine.easeInOut'
|
||||
});
|
||||
}
|
||||
view.statusBadges.forEach((badge, index) => {
|
||||
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
|
||||
this.tweens.add({
|
||||
@@ -24044,9 +24226,75 @@ export class BattleScene extends Phaser.Scene {
|
||||
private showFacingIndicator(unit: UnitData) {
|
||||
this.clearFacingIndicator();
|
||||
this.facingIndicatorUnitId = unit.id;
|
||||
if (unit.hp <= 0 || !this.isTileVisible(unit.x, unit.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = this.tileCenterX(unit.x);
|
||||
const centerY = this.tileCenterY(unit.y) + this.layout.tileSize * 0.2;
|
||||
const outer = this.add.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
this.layout.tileSize * 0.76,
|
||||
this.layout.tileSize * 0.42,
|
||||
0x05090d,
|
||||
0.12
|
||||
);
|
||||
outer.setName(`facing-ring-outer-${unit.id}`);
|
||||
outer.setStrokeStyle(this.battleUiLength(5), 0x05090d, 0.92);
|
||||
outer.setDepth(5.14);
|
||||
|
||||
const inner = this.add.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
this.layout.tileSize * 0.7,
|
||||
this.layout.tileSize * 0.36,
|
||||
palette.gold,
|
||||
0.07
|
||||
);
|
||||
inner.setName(`facing-ring-inner-${unit.id}`);
|
||||
inner.setStrokeStyle(this.battleUiLength(2), palette.gold, 1);
|
||||
inner.setDepth(5.15);
|
||||
|
||||
const vector = this.directionVector(this.currentUnitDirection(unit));
|
||||
const arrow = this.add.text(
|
||||
centerX + vector.x * this.layout.tileSize * 0.29,
|
||||
centerY + vector.y * this.layout.tileSize * 0.22,
|
||||
this.directionSymbol(this.currentUnitDirection(unit)),
|
||||
{
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSize: this.battleUiFontSize(18),
|
||||
color: '#ffe69a',
|
||||
fontStyle: '700',
|
||||
stroke: '#05090d',
|
||||
strokeThickness: this.battleUiLength(4)
|
||||
}
|
||||
);
|
||||
arrow.setName(`facing-arrow-${unit.id}`);
|
||||
arrow.setOrigin(0.5);
|
||||
arrow.setDepth(5.17);
|
||||
|
||||
this.facingIndicatorObjects = [outer, inner, arrow];
|
||||
if (this.mapMask) {
|
||||
outer.setMask(this.mapMask);
|
||||
inner.setMask(this.mapMask);
|
||||
arrow.setMask(this.mapMask);
|
||||
}
|
||||
if (!isVisualMotionReduced()) {
|
||||
this.tweens.add({
|
||||
targets: [inner, arrow],
|
||||
alpha: 0.68,
|
||||
scale: 1.045,
|
||||
duration: 720,
|
||||
ease: 'Sine.easeInOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearFacingIndicator() {
|
||||
this.tweens?.killTweensOf(this.facingIndicatorObjects);
|
||||
this.facingIndicatorObjects.forEach((object) => object.destroy());
|
||||
this.facingIndicatorObjects = [];
|
||||
this.facingIndicatorUnitId = undefined;
|
||||
@@ -24134,9 +24382,17 @@ 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.setUnitActedMarkerPosition(view, x, y);
|
||||
this.setUnitStatusBadgePositions(view, x, y);
|
||||
}
|
||||
|
||||
private setUnitActedMarkerPosition(view: UnitView, x: number, y: number) {
|
||||
view.actedMarker?.setPosition(
|
||||
x - this.layout.tileSize * 0.31,
|
||||
y + this.layout.tileSize * 0.3
|
||||
);
|
||||
}
|
||||
|
||||
private setUnitRoleSealPosition(view: UnitView, x: number, y: number) {
|
||||
view.roleSeal?.setPosition(
|
||||
x - this.layout.tileSize * 0.34,
|
||||
@@ -24180,6 +24436,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private startUnitIdleMotion(unit: UnitData, view: UnitView, direction: UnitDirection) {
|
||||
if (isVisualMotionReduced()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mounted = this.isMountedUnit(unit);
|
||||
const seed = this.hashString(unit.id) % 160;
|
||||
const bob = mounted ? 2.35 : 1.9;
|
||||
@@ -24475,6 +24735,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
label.setBackgroundColor('');
|
||||
});
|
||||
view.roleSeal?.setAlpha(0).setVisible(false);
|
||||
view.actedMarker?.setAlpha(0).setVisible(false);
|
||||
view.statusBadges.forEach((badge) => badge.container.setAlpha(0).setVisible(false));
|
||||
}
|
||||
|
||||
@@ -24486,6 +24747,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y);
|
||||
this.syncUnitMotion(unit, view, direction);
|
||||
if (this.facingIndicatorUnitId === unit.id) {
|
||||
this.refreshFacingIndicator(unit.id);
|
||||
}
|
||||
}
|
||||
|
||||
private flashDamage(unit: UnitData, displayedHp = unit.hp) {
|
||||
@@ -24720,6 +24984,131 @@ export class BattleScene extends Phaser.Scene {
|
||||
return duration + delay;
|
||||
}
|
||||
|
||||
private showBattleSoundCaption(
|
||||
kind: BattleSoundCaptionKind,
|
||||
text: string,
|
||||
sourceTiles: ReadonlyArray<{ x: number; y: number }>
|
||||
) {
|
||||
if (sourceTiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideBattleSoundCaption();
|
||||
const direction = this.battleSoundCaptionDirection(sourceTiles);
|
||||
const displayText = `${this.battleSoundCaptionDirectionLabel(direction)} ${text}`;
|
||||
const width = Phaser.Math.Clamp(
|
||||
this.battleUiLength(220) + displayText.length * this.battleUiLength(5),
|
||||
this.battleUiLength(330),
|
||||
this.layout.gridWidth - this.battleUiLength(32)
|
||||
);
|
||||
const height = this.battleUiLength(46);
|
||||
const x = this.layout.gridX + this.layout.gridWidth / 2;
|
||||
const y = this.layout.gridY + this.layout.gridHeight - this.battleUiLength(36);
|
||||
const container = this.add.container(x, y);
|
||||
container.setName('battle-sound-caption');
|
||||
container.setDepth(44);
|
||||
|
||||
const tone = kind === 'status' ? 0xd8732c : kind === 'recovery' ? 0x59d18c : palette.gold;
|
||||
const background = this.add.rectangle(0, 0, width, height, 0x071018, 0.96);
|
||||
background.setStrokeStyle(this.battleUiLength(2), tone, 0.96);
|
||||
const icon = this.createBattleUiIcon(
|
||||
-width / 2 + this.battleUiLength(27),
|
||||
0,
|
||||
kind === 'status' ? 'burn' : kind === 'recovery' ? 'heal' : 'success',
|
||||
this.battleUiLength(24)
|
||||
);
|
||||
const caption = this.add.text(-width / 2 + this.battleUiLength(48), 0, displayText, {
|
||||
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
||||
fontSize: this.battleUiFontSize(14),
|
||||
color: '#fff4d6',
|
||||
fontStyle: '700',
|
||||
fixedWidth: width - this.battleUiLength(62),
|
||||
stroke: '#05080b',
|
||||
strokeThickness: this.battleUiLength(3)
|
||||
});
|
||||
caption.setOrigin(0, 0.5);
|
||||
container.add([background, icon, caption]);
|
||||
this.battleSoundCaption = container;
|
||||
|
||||
const bounds = container.getBounds();
|
||||
this.battleSoundCaptionLast = {
|
||||
kind,
|
||||
text: displayText,
|
||||
direction,
|
||||
sourceCount: sourceTiles.length,
|
||||
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
||||
};
|
||||
const revision = this.battleSoundCaptionRevision;
|
||||
this.battleSoundCaptionTimer = this.time.delayedCall(1800, () => {
|
||||
if (revision !== this.battleSoundCaptionRevision || !container.active) {
|
||||
return;
|
||||
}
|
||||
if (isVisualMotionReduced()) {
|
||||
this.hideBattleSoundCaption();
|
||||
return;
|
||||
}
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
alpha: 0,
|
||||
duration: 220,
|
||||
ease: 'Sine.easeOut',
|
||||
onComplete: () => {
|
||||
if (revision === this.battleSoundCaptionRevision) {
|
||||
this.hideBattleSoundCaption();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private hideBattleSoundCaption() {
|
||||
this.battleSoundCaptionRevision += 1;
|
||||
this.battleSoundCaptionTimer?.remove(false);
|
||||
this.battleSoundCaptionTimer = undefined;
|
||||
if (this.battleSoundCaption?.active) {
|
||||
this.tweens?.killTweensOf(this.battleSoundCaption);
|
||||
this.battleSoundCaption.destroy();
|
||||
}
|
||||
this.battleSoundCaption = undefined;
|
||||
}
|
||||
|
||||
private battleSoundCaptionDirection(sourceTiles: ReadonlyArray<{ x: number; y: number }>): BattleSoundCaptionDirection {
|
||||
const directions = new Set(
|
||||
sourceTiles
|
||||
.map((tile) => this.offscreenTileDirection(tile))
|
||||
.filter((direction): direction is Exclude<BattleSoundCaptionDirection, 'multiple'> => Boolean(direction))
|
||||
);
|
||||
return directions.size === 1 ? [...directions][0] : 'multiple';
|
||||
}
|
||||
|
||||
private offscreenTileDirection(tile: { x: number; y: number }) {
|
||||
const left = this.cameraTileX;
|
||||
const right = this.cameraTileX + this.layout.visibleColumns - 1;
|
||||
const top = this.cameraTileY;
|
||||
const bottom = this.cameraTileY + this.layout.visibleRows - 1;
|
||||
const horizontal = tile.x < left ? 'west' : tile.x > right ? 'east' : '';
|
||||
const vertical = tile.y < top ? 'north' : tile.y > bottom ? 'south' : '';
|
||||
if (vertical && horizontal) {
|
||||
return `${vertical}-${horizontal}` as Exclude<BattleSoundCaptionDirection, 'multiple'>;
|
||||
}
|
||||
return (vertical || horizontal || undefined) as Exclude<BattleSoundCaptionDirection, 'multiple'> | undefined;
|
||||
}
|
||||
|
||||
private battleSoundCaptionDirectionLabel(direction: BattleSoundCaptionDirection) {
|
||||
const labels: Record<BattleSoundCaptionDirection, string> = {
|
||||
north: '↑',
|
||||
'north-east': '↗',
|
||||
east: '→',
|
||||
'south-east': '↘',
|
||||
south: '↓',
|
||||
'south-west': '↙',
|
||||
west: '←',
|
||||
'north-west': '↖',
|
||||
multiple: '여러 방향 ·'
|
||||
};
|
||||
return labels[direction];
|
||||
}
|
||||
|
||||
private showObjectiveMapFeedback(
|
||||
objective: BattleObjectiveDefinition,
|
||||
state: BattleObjectiveState,
|
||||
@@ -24728,7 +25117,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
stroke: string
|
||||
) {
|
||||
const anchor = this.objectiveFeedbackAnchor(objective, state);
|
||||
if (!anchor || !this.isTileVisible(anchor.x, anchor.y)) {
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
if (!this.isTileVisible(anchor.x, anchor.y)) {
|
||||
this.showBattleSoundCaption('objective', lines.filter(Boolean).join(' · '), [anchor]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27761,6 +28154,16 @@ export class BattleScene extends Phaser.Scene {
|
||||
objectiveDot.lineWidth >= this.battleUiLength(2) &&
|
||||
objectiveDot.strokeColor === palette.gold
|
||||
),
|
||||
terrainObjectiveMarkers: this.miniMapObjectiveMarkers.map((marker) => ({
|
||||
objectiveId: marker.getData('objectiveId') as string,
|
||||
tile: {
|
||||
x: marker.getData('tileX') as number,
|
||||
y: marker.getData('tileY') as number
|
||||
},
|
||||
visible: marker.visible,
|
||||
angle: marker.angle,
|
||||
bounds: this.gameObjectBoundsDebug(marker)
|
||||
})),
|
||||
selectedUnitId: this.selectedUnit?.id ?? null,
|
||||
selectedHighlighted: Boolean(
|
||||
selectedDot?.visible &&
|
||||
@@ -28093,6 +28496,21 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
: null
|
||||
},
|
||||
soundCaption: this.battleSoundCaptionLast
|
||||
? {
|
||||
...this.battleSoundCaptionLast,
|
||||
bounds: { ...this.battleSoundCaptionLast.bounds },
|
||||
visible: Boolean(this.battleSoundCaption?.active && this.battleSoundCaption.visible)
|
||||
}
|
||||
: null,
|
||||
facingIndicator: {
|
||||
unitId: this.facingIndicatorUnitId ?? null,
|
||||
visible: this.facingIndicatorObjects.some((object) => (
|
||||
object.active && (object as Phaser.GameObjects.GameObject & { visible: boolean }).visible
|
||||
)),
|
||||
objectCount: this.facingIndicatorObjects.filter((object) => object.active).length,
|
||||
bounds: this.gameObjectCollectionBoundsDebug(this.facingIndicatorObjects)
|
||||
},
|
||||
pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
|
||||
pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null,
|
||||
keyboardBattleCursor: this.keyboardBattleCursor ? { ...this.keyboardBattleCursor } : null,
|
||||
@@ -28191,6 +28609,14 @@ 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,
|
||||
actedMarker: this.unitViews.get(unit.id)?.actedMarker
|
||||
? {
|
||||
visible: this.unitViews.get(unit.id)!.actedMarker!.visible,
|
||||
x: this.unitViews.get(unit.id)!.actedMarker!.x,
|
||||
y: this.unitViews.get(unit.id)!.actedMarker!.y,
|
||||
bounds: this.gameObjectBoundsDebug(this.unitViews.get(unit.id)!.actedMarker)
|
||||
}
|
||||
: null,
|
||||
statusBadges: (this.unitViews.get(unit.id)?.statusBadges ?? []).map((badge) => ({
|
||||
kind: badge.kind,
|
||||
visible: badge.container.visible,
|
||||
|
||||
Reference in New Issue
Block a user