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

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