From 8bda36d46d4b95d67d1abed3173a3a9f2e6004f7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 22 Jul 2026 20:08:23 +0900 Subject: [PATCH] feat: improve tactical audiovisual clarity --- .../verify-audiovisual-feedback-browser.mjs | 48 +- scripts/verify-audiovisual-feedback.mjs | 71 ++- scripts/verify-sound-director.mjs | 32 ++ src/game/audio/SoundDirector.ts | 60 ++- src/game/scenes/BattleScene.ts | 510 ++++++++++++++++-- 5 files changed, 663 insertions(+), 58 deletions(-) diff --git a/scripts/verify-audiovisual-feedback-browser.mjs b/scripts/verify-audiovisual-feedback-browser.mjs index 5b5dce0..579e60a 100644 --- a/scripts/verify-audiovisual-feedback-browser.mjs +++ b/scripts/verify-audiovisual-feedback-browser.mjs @@ -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(); diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index a349447..3ebdb15 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -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}`); diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 6370152..08411f3 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -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; diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 435024a..390efdb 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -6,6 +6,8 @@ export type AudioCategoryMultipliers = Record; export type EffectPoolMap = Record; +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 }; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 70f0205..000e22e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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; 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(); + 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 = 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 => 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; + } + return (vertical || horizontal || undefined) as Exclude | undefined; + } + + private battleSoundCaptionDirectionLabel(direction: BattleSoundCaptionDirection) { + const labels: Record = { + 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,