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;

View File

@@ -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
};
}

View File

@@ -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,