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, message: result.message,
tileVisible: scene.isTileVisible(unit.x, unit.y), tileVisible: scene.isTileVisible(unit.x, unit.y),
feedback: battle.mapResultFeedback, feedback: battle.mapResultFeedback,
soundCaption: battle.soundCaption,
expectedDirection: scene.offscreenTileDirection(destination),
unit: battle.units.find((candidate) => candidate.id === unitId) unit: battle.units.find((candidate) => candidate.id === unitId)
}; };
}, initial.ally.id); }, initial.ally.id);
assert.match(offscreenTick.message, /화상/); assert.match(offscreenTick.message, /화상/);
assert.equal(offscreenTick.tileVisible, false); 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.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.hp, initial.ally.hp - 6);
assert.equal(offscreenTick.unit.statusBadges.length, 0, 'expired status badges must be removed after the offscreen tick'); 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 debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene'); const scene = debug.scene('BattleScene');
const recoveryPlayedAt = debug.audio().semanticCues.playedAt.recovery ?? null; 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() }; return { selected, recoveryPlayedAt, audio: debug.audio() };
}); });
assert.equal(prioritizedBurn.selected, 'status'); assert.equal(prioritizedBurn.selected, 'status');
@@ -268,7 +287,12 @@ try {
const debug = window.__HEROS_DEBUG__; const debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene'); const scene = debug.scene('BattleScene');
const before = debug.audio().semanticCues.lastPlayed; 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 }; return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount };
}); });
assert.equal(operationPriority.selected, 'operation'); assert.equal(operationPriority.selected, 'operation');
@@ -278,7 +302,11 @@ try {
const recoveryCue = await page.evaluate(() => { const recoveryCue = await page.evaluate(() => {
const debug = window.__HEROS_DEBUG__; const debug = window.__HEROS_DEBUG__;
const scene = debug.scene('BattleScene'); 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() }; return { selected, audio: debug.audio() };
}); });
assert.equal(recoveryCue.selected, 'recovery'); assert.equal(recoveryCue.selected, 'recovery');
@@ -286,10 +314,22 @@ try {
assert.equal(recoveryCue.audio.lastEffect.trackKey, 'recovery-cue'); 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'); 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(pageErrors, [], `Unexpected page errors: ${pageErrors.join('\n')}`);
assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`); assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`);
console.log( 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 { } finally {
await browser?.close(); 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\.showMapResultPopup/s);
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/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, /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'); assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn'); const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
@@ -27,7 +29,12 @@ const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue')
assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/); assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/);
assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return'); 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)))); cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
assert.deepEqual( assert.deepEqual(
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)), cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
@@ -47,6 +54,8 @@ const campSupply = privateMethodBody(campSource, 'useSupply');
}); });
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/); assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
assert.match(terrainRecovery, /if \(playCue\)/); assert.match(terrainRecovery, /if \(playCue\)/);
assert.match(terrainRecovery, /offscreenRecoverySources\.push/);
assert.match(terrainRecovery, /showOffscreenRecoveryCaption/);
assert.match(tacticalInitiative, /role === 'support'/); assert.match(tacticalInitiative, /role === 'support'/);
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/); assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/); assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
@@ -58,6 +67,26 @@ const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattle
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/); assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue'); 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'); const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups'); 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, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/); 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 [ for (const [method, group, key] of [
['playRecoveryCue', 'recovery', 'recovery-cue'], ['playRecoveryCue', 'recovery', 'recovery-cue'],
['playBurnTickCue', 'status', 'burn-tick'], ['playBurnTickCue', 'status', 'burn-tick'],
@@ -85,9 +145,16 @@ for (const [method, group, key] of [
const body = publicMethodBody(soundSource, method); const body = publicMethodBody(soundSource, method);
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`)); 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'); 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) { function privateMethodBody(source, name) {
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${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', 'recovery-cue': '/audio/recovery-cue.ogg',
'burn-tick': '/audio/burn-tick.ogg', 'burn-tick': '/audio/burn-tick.ogg',
'reward-reveal': '/audio/reward-reveal.ogg', 'reward-reveal': '/audio/reward-reveal.ogg',
'strategy-cast': '/audio/strategy-cast.ogg',
'ui-select': '/audio/ui-select.ogg' 'ui-select': '/audio/ui-select.ogg'
}); });
director.registerEffectPools({ director.registerEffectPools({
@@ -500,6 +501,33 @@ try {
assert(criticalBoom, 'critical accents should layer the delayed boom track'); assert(criticalBoom, 'critical accents should layer the delayed boom track');
director.playCombatImpact(false, false); director.playCombatImpact(false, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-impact', 'combat API should use the impact pool'); 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'); director.playBattleOutcome('victory');
assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track'); assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track');
director.playBattleOutcome('defeat'); director.playBattleOutcome('defeat');
@@ -608,6 +636,10 @@ function assertClose(actual, expected, context) {
assert(Math.abs(actual - expected) < 1e-9, `${context}: expected ${expected}, received ${actual}`); 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() { function createAudioHarness() {
const instances = []; const instances = [];
let shouldRejectNextPlay = false; let shouldRejectNextPlay = false;

View File

@@ -6,6 +6,8 @@ export type AudioCategoryMultipliers = Record<AudioCategory, number>;
export type EffectPoolMap = Record<string, readonly string[]>; export type EffectPoolMap = Record<string, readonly string[]>;
export type StrategyImpactKind = 'fire' | 'roar' | 'arcane';
export type SoundscapeOptions = { export type SoundscapeOptions = {
musicKey?: string; musicKey?: string;
ambienceKey?: 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 { export class SoundDirector {
private context?: AudioContext; private context?: AudioContext;
private master?: GainNode; private master?: GainNode;
@@ -116,15 +128,8 @@ export class SoundDirector {
at: number; at: number;
remainingMs: number; remainingMs: number;
}; };
private lastEffect?: { private lastEffect?: PlayedEffectDebug;
key: string; private readonly recentEffects: PlayedEffectDebug[] = [];
trackKey: string;
poolKey: string | null;
at: number;
baseVolume: number;
volume: number;
rate: number;
};
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number }; private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
private readonly effectVolume = 0.42; 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') { playBattleOutcome(outcome: 'victory' | 'defeat') {
this.duckMusic({ this.duckMusic({
multiplier: outcome === 'victory' ? 0.34 : 0.24, multiplier: outcome === 'victory' ? 0.34 : 0.24,
@@ -496,7 +530,7 @@ export class SoundDirector {
effect.muted = this.muted; effect.muted = this.muted;
this.activeEffects.add(effect); this.activeEffects.add(effect);
this.effectBaseVolumes.set(effect, baseVolume); this.effectBaseVolumes.set(effect, baseVolume);
this.lastEffect = { const playedEffect = {
key, key,
trackKey: resolvedTrack.trackKey, trackKey: resolvedTrack.trackKey,
poolKey: resolvedTrack.poolKey, poolKey: resolvedTrack.poolKey,
@@ -505,6 +539,11 @@ export class SoundDirector {
volume: effect.volume, volume: effect.volume,
rate: effect.playbackRate rate: effect.playbackRate
}; };
this.lastEffect = playedEffect;
this.recentEffects.push(playedEffect);
if (this.recentEffects.length > 12) {
this.recentEffects.shift();
}
effect.addEventListener( effect.addEventListener(
'ended', 'ended',
() => { () => {
@@ -597,6 +636,7 @@ export class SoundDirector {
), ),
activeEffectCount: this.activeEffects.size, activeEffectCount: this.activeEffects.size,
lastEffect: this.lastEffect ?? null, lastEffect: this.lastEffect ?? null,
recentEffects: this.recentEffects.map((effect) => ({ ...effect })),
lastMovementStep: this.lastMovementStep ?? null lastMovementStep: this.lastMovementStep ?? null
}; };
} }

View File

@@ -1,5 +1,5 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector'; import { soundDirector, type StrategyImpactKind } from '../audio/SoundDirector';
import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences'; import { loadAudioPreferences, saveAudioPreferences } from '../settings/audioPreferences';
import { isVisualMotionReduced } from '../settings/visualMotion'; import { isVisualMotionReduced } from '../settings/visualMotion';
import { battleMapAssets } from '../data/battleMapAssets'; import { battleMapAssets } from '../data/battleMapAssets';
@@ -1414,6 +1414,7 @@ type UnitView = {
hitZone: Phaser.GameObjects.Zone; hitZone: Phaser.GameObjects.Zone;
label: Phaser.GameObjects.Text; label: Phaser.GameObjects.Text;
roleSeal?: Phaser.GameObjects.Text; roleSeal?: Phaser.GameObjects.Text;
actedMarker?: Phaser.GameObjects.Container;
statusBadges: UnitStatusBadgeView[]; statusBadges: UnitStatusBadgeView[];
statusBadgeSide: -1 | 1; statusBadgeSide: -1 | 1;
textureBase: string; textureBase: string;
@@ -1436,8 +1437,36 @@ type DamageCommand = Exclude<BattleCommand, 'wait'>;
type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar'; type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar';
type RosterTab = 'ally' | 'enemy'; type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy';
type BattleStatusTickResult = { message?: string; burnTicked: boolean }; type BattleCaptionSource = { x: number; y: number };
type TerrainRecoveryResult = { message?: string; applied: boolean }; 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; const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const;
type BattleSpeed = (typeof battleSpeedOptions)[number]; type BattleSpeed = (typeof battleSpeedOptions)[number];
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close'; 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; delay: number;
lift: 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 combatCutInRoot?: Phaser.GameObjects.Container;
private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private combatCutInStageActive?: CombatCutInStageSnapshot; private combatCutInStageActive?: CombatCutInStageSnapshot;
@@ -3737,6 +3770,7 @@ export class BattleScene extends Phaser.Scene {
private miniMapLayout?: MiniMapLayout; private miniMapLayout?: MiniMapLayout;
private miniMapObjects: Phaser.GameObjects.GameObject[] = []; private miniMapObjects: Phaser.GameObjects.GameObject[] = [];
private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>(); private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>();
private miniMapObjectiveMarkers: Phaser.GameObjects.Rectangle[] = [];
private miniMapViewport?: Phaser.GameObjects.Rectangle; private miniMapViewport?: Phaser.GameObjects.Rectangle;
private miniMapVisible = true; private miniMapVisible = true;
private recentActionLogLayout?: RecentActionLogLayout; private recentActionLogLayout?: RecentActionLogLayout;
@@ -3837,6 +3871,7 @@ export class BattleScene extends Phaser.Scene {
this.miniMapLayout = undefined; this.miniMapLayout = undefined;
this.miniMapObjects = []; this.miniMapObjects = [];
this.miniMapUnitDots.clear(); this.miniMapUnitDots.clear();
this.miniMapObjectiveMarkers = [];
this.miniMapViewport = undefined; this.miniMapViewport = undefined;
this.miniMapVisible = true; this.miniMapVisible = true;
this.recentActionLogLayout = undefined; this.recentActionLogLayout = undefined;
@@ -3904,6 +3939,9 @@ export class BattleScene extends Phaser.Scene {
this.mapResultPopupObjects = []; this.mapResultPopupObjects = [];
this.mapResultPopupPeakCount = 0; this.mapResultPopupPeakCount = 0;
this.mapResultPopupLast = undefined; this.mapResultPopupLast = undefined;
this.hideBattleSoundCaption();
this.battleSoundCaptionRevision = 0;
this.battleSoundCaptionLast = undefined;
this.combatCutInStageActive = undefined; this.combatCutInStageActive = undefined;
this.combatCutInStageLast = undefined; this.combatCutInStageLast = undefined;
this.tacticalCommandCutInCount = 0; this.tacticalCommandCutInCount = 0;
@@ -3919,6 +3957,7 @@ export class BattleScene extends Phaser.Scene {
this.clearBattleEnvironment(); this.clearBattleEnvironment();
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
this.mapResultPopupObjects = []; this.mapResultPopupObjects = [];
this.hideBattleSoundCaption();
}); });
this.resultFormationReviewVisible = false; this.resultFormationReviewVisible = false;
this.resultFormationReviewFeedback = ''; this.resultFormationReviewFeedback = '';
@@ -5571,10 +5610,15 @@ export class BattleScene extends Phaser.Scene {
label.setMask(this.mapMask); label.setMask(this.mapMask);
} }
const actedMarker = unit.faction === 'ally'
? this.createUnitActedMarker(unit, centerX, centerY)
: undefined;
const view: UnitView = { const view: UnitView = {
sprite, sprite,
hitZone, hitZone,
label, label,
actedMarker,
statusBadges: [], statusBadges: [],
statusBadgeSide: unit.faction === 'ally' ? 1 : -1, statusBadgeSide: unit.faction === 'ally' ? 1 : -1,
textureBase, 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) { private unitDisplaySizeRatio(unit: UnitData) {
const baseRatio = unit.faction === 'ally' ? 1.32 : 1.24; const baseRatio = unit.faction === 'ally' ? 1.32 : 1.24;
return unit.id === 'guan-yu' ? baseRatio * 1.03 : baseRatio; 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) => { battleUnits.forEach((unit) => {
const dot = this.add.rectangle( const dot = this.add.rectangle(
0, 0,
@@ -6989,6 +7078,12 @@ export class BattleScene extends Phaser.Scene {
this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, true)); this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, true));
const { x, y, cellSize } = this.miniMapLayout; 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) => { this.miniMapUnitDots.forEach((dot, unitId) => {
const unit = battleUnits.find((candidate) => candidate.id === unitId); const unit = battleUnits.find((candidate) => candidate.id === unitId);
if (!unit) { if (!unit) {
@@ -7260,20 +7355,22 @@ export class BattleScene extends Phaser.Scene {
} }
const visible = this.isTileVisible(unit.x, unit.y); const visible = this.isTileVisible(unit.x, unit.y);
const acted = this.actedUnitIds.has(unit.id);
this.clearUnitSpriteEffects(view.sprite); this.clearUnitSpriteEffects(view.sprite);
view.sprite.setAlpha(1); view.sprite.setAlpha(acted ? 0.62 : 1);
view.sprite.setVisible(visible); view.sprite.setVisible(visible);
this.setUnitHitZoneEnabled(view, visible); this.setUnitHitZoneEnabled(view, visible);
this.updateUnitLabel(view, (label) => { this.updateUnitLabel(view, (label) => {
label.setVisible(visible); label.setVisible(visible);
label.setAlpha(1); label.setAlpha(acted ? 0.76 : 1);
label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7'); label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7');
label.setBackgroundColor(this.unitLabelBackground(unit)); label.setBackgroundColor(this.unitLabelBackground(unit));
}); });
view.roleSeal?.setVisible(visible).setAlpha(1); view.roleSeal?.setVisible(visible).setAlpha(acted ? 0.78 : 1);
this.syncUnitStatusBadges(unit, view); 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) { private unitLabelBackground(unit: UnitData) {
@@ -11873,7 +11970,7 @@ export class BattleScene extends Phaser.Scene {
this.triggerBattleEvent('first-engagement', '첫 교전', [ this.triggerBattleEvent('first-engagement', '첫 교전', [
`${attacker.name}${target.name}을 공격합니다.`, `${attacker.name}${target.name}을 공격합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.' '공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.'
]); ], { playCue: false });
this.phase = 'animating'; this.phase = 'animating';
const result = this.resolveCombatAction(attacker, target, action, false, usable); const result = this.resolveCombatAction(attacker, target, action, false, usable);
this.clearMarkers(); this.clearMarkers();
@@ -13063,6 +13160,7 @@ export class BattleScene extends Phaser.Scene {
private showBattleResult(outcome: BattleOutcome) { private showBattleResult(outcome: BattleOutcome) {
this.hideBattleResult(); this.hideBattleResult();
this.hideBattleSoundCaption();
this.resultAnimationToken += 1; this.resultAnimationToken += 1;
this.resultGaugeAnimations = []; this.resultGaugeAnimations = [];
this.resultContinueLockedUntil = 0; this.resultContinueLockedUntil = 0;
@@ -17377,13 +17475,15 @@ export class BattleScene extends Phaser.Scene {
this.triggerBattleEvent(key, event?.title ?? fallbackTitle, event?.lines ?? fallbackLines); 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) { if (this.triggeredBattleEvents.has(key) || this.battleOutcome) {
return; return;
} }
this.triggeredBattleEvents.add(key); this.triggeredBattleEvents.add(key);
soundDirector.playOperationAlert(); if (options.playCue !== false) {
soundDirector.playOperationAlert();
}
const message = `${title}\n${lines.join('\n')}`; const message = `${title}\n${lines.join('\n')}`;
this.pushBattleLog(message); this.pushBattleLog(message);
this.renderSituationPanel(message); this.renderSituationPanel(message);
@@ -18854,13 +18954,14 @@ export class BattleScene extends Phaser.Scene {
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0; const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) { if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
if (statusResult.burnTicked) { if (statusResult.burnTicked) {
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
soundDirector.playBurnTickCue(); soundDirector.playBurnTickCue();
} }
return; return;
} }
const recoveryResult = this.applyTerrainRecovery('enemy', false); const recoveryResult = this.applyTerrainRecovery('enemy', false);
const recoveryMessage = recoveryResult.message; const recoveryMessage = recoveryResult.message;
this.playTurnStartFeedbackCue('enemy', statusResult.burnTicked, recoveryResult.applied); this.playTurnStartFeedbackCue('enemy', statusResult, recoveryResult);
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
void this.runEnemyTurn(); void this.runEnemyTurn();
} }
@@ -19804,6 +19905,8 @@ export class BattleScene extends Phaser.Scene {
const targetY = defenderView?.sprite.y ?? this.tileCenterY(result.defender.y); const targetY = defenderView?.sprite.y ?? this.tileCenterY(result.defender.y);
const accent = result.critical || result.defeated ? 0xff8f5f : result.action === 'strategy' ? 0x83d6ff : 0xffdf7b; const accent = result.critical || result.defeated ? 0xff8f5f : result.action === 'strategy' ? 0x83d6ff : 0xffdf7b;
const duration = this.scaledBattleDuration(result.critical || result.defeated ? 300 : 220, 95); 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[] = []; const objects: Phaser.GameObjects.GameObject[] = [];
let actorFramePromise: Promise<void> = Promise.resolve(); let actorFramePromise: Promise<void> = Promise.resolve();
@@ -19836,11 +19939,8 @@ export class BattleScene extends Phaser.Scene {
} else { } else {
soundDirector.playMeleeSwing(result.hit, result.critical); soundDirector.playMeleeSwing(result.hit, result.critical);
} }
if (rangedAttack) { await this.waitSceneDuration(contactDelayMs);
soundDirector.playArrowImpact(result.hit, result.critical); this.playCombatContactSound(result);
} else if (result.hit) {
soundDirector.playCombatImpact(result.critical, result.action === 'strategy');
}
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28); 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); ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94);
@@ -19883,15 +19983,21 @@ export class BattleScene extends Phaser.Scene {
} }
objects.push(badge); 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: 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, 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(duration * 0.45), duration: Math.round(duration * 0.55) }); 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) { if (result.critical || result.defeated) {
this.shakeCamera(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005); this.shakeCamera(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
} }
this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth); 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) { if (attackerView) {
this.syncUnitMotion(result.attacker, 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' }); 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) { private playCombatContactSound(result: CombatResult) {
if (this.isRangedAttack(result)) { if (result.action === 'strategy') {
soundDirector.playArrowImpact(true, result.critical); if (result.hit) {
} else { const motionKind = this.specialDamageMotionKind(result);
soundDirector.playCombatImpact(result.critical, result.action === 'strategy'); 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)); const impact = this.trackCombatObject(this.add.star(x, y, 8, 12, result.critical ? 54 : 42, this.combatImpactColor(result), 0.92));
impact.setDepth(depth); impact.setDepth(depth);
this.tweens.add({ this.tweens.add({
@@ -22859,6 +22983,9 @@ export class BattleScene extends Phaser.Scene {
const direction = this.directionFromDelta(x - fromX, y - fromY); const direction = this.directionFromDelta(x - fromX, y - fromY);
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
const movementDuration = this.movementDuration(duration, movementDistance); const movementDuration = this.movementDuration(duration, movementDistance);
if (this.facingIndicatorUnitId === unit.id) {
this.clearFacingIndicator();
}
if (showSortieTrail) { if (showSortieTrail) {
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
} }
@@ -22866,6 +22993,9 @@ export class BattleScene extends Phaser.Scene {
if (view.roleSeal) { if (view.roleSeal) {
this.tweens.killTweensOf(view.roleSeal); this.tweens.killTweensOf(view.roleSeal);
} }
if (view.actedMarker) {
this.tweens.killTweensOf(view.actedMarker);
}
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container)); view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
this.setUnitBasePosition(view, startX, startY); this.setUnitBasePosition(view, startX, startY);
this.resetUnitSpritePose(view); this.resetUnitSpritePose(view);
@@ -22903,6 +23033,15 @@ export class BattleScene extends Phaser.Scene {
ease: 'Sine.easeInOut' 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) => { view.statusBadges.forEach((badge, index) => {
const position = this.unitStatusBadgePosition(view, targetX, targetY, index); const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
this.tweens.add({ this.tweens.add({
@@ -22927,6 +23066,7 @@ export class BattleScene extends Phaser.Scene {
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0; const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) { if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
if (statusResult.burnTicked) { if (statusResult.burnTicked) {
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
soundDirector.playBurnTickCue(); soundDirector.playBurnTickCue();
} }
return; return;
@@ -22945,7 +23085,7 @@ export class BattleScene extends Phaser.Scene {
const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size; const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size;
this.checkBattleEvents(); this.checkBattleEvents();
const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck; const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck;
this.playTurnStartFeedbackCue('ally', statusResult.burnTicked, recoveryResult.applied, operationCuePlayed); this.playTurnStartFeedbackCue('ally', statusResult, recoveryResult, operationCuePlayed);
this.centerCameraOnAllyLine(); this.centerCameraOnAllyLine();
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'] const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
.filter(Boolean) .filter(Boolean)
@@ -22975,6 +23115,7 @@ export class BattleScene extends Phaser.Scene {
private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult { private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult {
const messages: string[] = []; const messages: string[] = [];
const offscreenRecoverySources: BattleRecoveryCaptionSource[] = [];
battleUnits battleUnits
.filter((unit) => unit.faction === faction && unit.hp > 0) .filter((unit) => unit.faction === faction && unit.hp > 0)
@@ -23001,10 +23142,17 @@ export class BattleScene extends Phaser.Scene {
].filter(Boolean); ].filter(Boolean);
messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`); messages.push(`${unit.name} ${terrainRule.label}: ${parts.join(' / ')}`);
this.showTerrainRecoveryPopup(unit, 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) { if (messages.length === 0) {
return { applied: false }; return { applied: false, offscreenRecoverySources };
}
if (playCue) {
this.showOffscreenRecoveryCaption(offscreenRecoverySources);
} }
if (playCue) { 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}` : ''}`; const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}` : ''}`;
this.pushBattleLog(message); 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( private playTurnStartFeedbackCue(
faction: ActiveFaction, faction: ActiveFaction,
burnTicked: boolean, statusResult: BattleStatusTickResult,
recoveryApplied: boolean, recoveryResult: TerrainRecoveryResult,
operationCuePlayed = false operationCuePlayed = false
) { ) {
if (operationCuePlayed) { if (operationCuePlayed) {
return 'operation'; return 'operation';
} }
if (burnTicked) { if (statusResult.burnTicked) {
this.showOffscreenBurnCaption(statusResult.offscreenBurnSources);
soundDirector.playBurnTickCue(); soundDirector.playBurnTickCue();
return 'status'; return 'status';
} }
if (recoveryApplied) { if (recoveryResult.applied) {
this.showOffscreenRecoveryCaption(recoveryResult.offscreenRecoverySources);
soundDirector.playRecoveryCue(); soundDirector.playRecoveryCue();
return 'recovery'; return 'recovery';
} }
@@ -23112,19 +23279,12 @@ export class BattleScene extends Phaser.Scene {
this.applyDefeatedStyle(unit); this.applyDefeatedStyle(unit);
return; return;
} }
this.clearUnitSpriteEffects(view.sprite);
view.sprite.setAlpha(1);
if (unit) { if (unit) {
this.syncUnitMotion(unit, view); this.syncUnitMotion(unit, view);
this.applyUnitLegibilityStyle(unit, view); this.applyUnitLegibilityStyle(unit, view);
} else { } else {
this.stopUnitWalk(view, view.direction); 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 { private tickBattleStatuses(faction: ActiveFaction, playCue = true): BattleStatusTickResult {
const messages: string[] = []; const messages: string[] = [];
const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0); const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0);
const offscreenBurnSources: BattleCaptionSource[] = [];
let burnTicked = false; let burnTicked = false;
units.forEach((unit) => { units.forEach((unit) => {
@@ -23678,6 +23839,8 @@ export class BattleScene extends Phaser.Scene {
17, 17,
30 30
); );
} else {
offscreenBurnSources.push({ x: unit.x, y: unit.y });
} }
if (unit.hp <= 0) { if (unit.hp <= 0) {
this.applyDefeatedStyle(unit); this.applyDefeatedStyle(unit);
@@ -23715,7 +23878,11 @@ export class BattleScene extends Phaser.Scene {
}); });
if (messages.length === 0) { if (messages.length === 0) {
return { burnTicked: false }; return { burnTicked: false, offscreenBurnSources };
}
if (playCue) {
this.showOffscreenBurnCaption(offscreenBurnSources);
} }
if (playCue && burnTicked) { if (playCue && burnTicked) {
@@ -23724,7 +23891,7 @@ export class BattleScene extends Phaser.Scene {
this.updateMiniMap(); this.updateMiniMap();
const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}` : ''}`; const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}` : ''}`;
this.pushBattleLog(message); this.pushBattleLog(message);
return { message, burnTicked }; return { message, burnTicked, offscreenBurnSources };
} }
private tickBattleBuffs() { private tickBattleBuffs() {
@@ -23840,6 +24007,9 @@ export class BattleScene extends Phaser.Scene {
const direction = this.directionFromDelta(x - fromX, y - fromY); const direction = this.directionFromDelta(x - fromX, y - fromY);
const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y);
const movementDuration = this.movementDuration(duration, movementDistance); const movementDuration = this.movementDuration(duration, movementDistance);
if (this.facingIndicatorUnitId === unit.id) {
this.clearFacingIndicator();
}
if (showSortieTrail) { if (showSortieTrail) {
this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration);
} }
@@ -23847,6 +24017,9 @@ export class BattleScene extends Phaser.Scene {
if (view.roleSeal) { if (view.roleSeal) {
this.tweens.killTweensOf(view.roleSeal); this.tweens.killTweensOf(view.roleSeal);
} }
if (view.actedMarker) {
this.tweens.killTweensOf(view.actedMarker);
}
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container)); view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
this.setUnitBasePosition(view, startX, startY); this.setUnitBasePosition(view, startX, startY);
this.resetUnitSpritePose(view); this.resetUnitSpritePose(view);
@@ -23883,6 +24056,15 @@ export class BattleScene extends Phaser.Scene {
ease: 'Sine.easeInOut' 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) => { view.statusBadges.forEach((badge, index) => {
const position = this.unitStatusBadgePosition(view, targetX, targetY, index); const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
this.tweens.add({ this.tweens.add({
@@ -24044,9 +24226,75 @@ export class BattleScene extends Phaser.Scene {
private showFacingIndicator(unit: UnitData) { private showFacingIndicator(unit: UnitData) {
this.clearFacingIndicator(); this.clearFacingIndicator();
this.facingIndicatorUnitId = unit.id; 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() { private clearFacingIndicator() {
this.tweens?.killTweensOf(this.facingIndicatorObjects);
this.facingIndicatorObjects.forEach((object) => object.destroy()); this.facingIndicatorObjects.forEach((object) => object.destroy());
this.facingIndicatorObjects = []; this.facingIndicatorObjects = [];
this.facingIndicatorUnitId = undefined; this.facingIndicatorUnitId = undefined;
@@ -24134,9 +24382,17 @@ export class BattleScene extends Phaser.Scene {
view.sprite.setPosition(x, y); view.sprite.setPosition(x, y);
view.hitZone.setPosition(x, y); view.hitZone.setPosition(x, y);
view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize); view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize);
this.setUnitActedMarkerPosition(view, x, y);
this.setUnitStatusBadgePositions(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) { private setUnitRoleSealPosition(view: UnitView, x: number, y: number) {
view.roleSeal?.setPosition( view.roleSeal?.setPosition(
x - this.layout.tileSize * 0.34, x - this.layout.tileSize * 0.34,
@@ -24180,6 +24436,10 @@ export class BattleScene extends Phaser.Scene {
} }
private startUnitIdleMotion(unit: UnitData, view: UnitView, direction: UnitDirection) { private startUnitIdleMotion(unit: UnitData, view: UnitView, direction: UnitDirection) {
if (isVisualMotionReduced()) {
return;
}
const mounted = this.isMountedUnit(unit); const mounted = this.isMountedUnit(unit);
const seed = this.hashString(unit.id) % 160; const seed = this.hashString(unit.id) % 160;
const bob = mounted ? 2.35 : 1.9; const bob = mounted ? 2.35 : 1.9;
@@ -24475,6 +24735,7 @@ export class BattleScene extends Phaser.Scene {
label.setBackgroundColor(''); label.setBackgroundColor('');
}); });
view.roleSeal?.setAlpha(0).setVisible(false); view.roleSeal?.setAlpha(0).setVisible(false);
view.actedMarker?.setAlpha(0).setVisible(false);
view.statusBadges.forEach((badge) => badge.container.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); const direction = this.directionFromDelta(target.x - unit.x, target.y - unit.y);
this.syncUnitMotion(unit, view, direction); this.syncUnitMotion(unit, view, direction);
if (this.facingIndicatorUnitId === unit.id) {
this.refreshFacingIndicator(unit.id);
}
} }
private flashDamage(unit: UnitData, displayedHp = unit.hp) { private flashDamage(unit: UnitData, displayedHp = unit.hp) {
@@ -24720,6 +24984,131 @@ export class BattleScene extends Phaser.Scene {
return duration + delay; 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( private showObjectiveMapFeedback(
objective: BattleObjectiveDefinition, objective: BattleObjectiveDefinition,
state: BattleObjectiveState, state: BattleObjectiveState,
@@ -24728,7 +25117,11 @@ export class BattleScene extends Phaser.Scene {
stroke: string stroke: string
) { ) {
const anchor = this.objectiveFeedbackAnchor(objective, state); 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; return;
} }
@@ -27761,6 +28154,16 @@ export class BattleScene extends Phaser.Scene {
objectiveDot.lineWidth >= this.battleUiLength(2) && objectiveDot.lineWidth >= this.battleUiLength(2) &&
objectiveDot.strokeColor === palette.gold 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, selectedUnitId: this.selectedUnit?.id ?? null,
selectedHighlighted: Boolean( selectedHighlighted: Boolean(
selectedDot?.visible && selectedDot?.visible &&
@@ -28093,6 +28496,21 @@ export class BattleScene extends Phaser.Scene {
} }
: null : 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), pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible),
pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null, pointerFeedbackTile: this.pointerFeedbackTile ? { ...this.pointerFeedbackTile } : null,
keyboardBattleCursor: this.keyboardBattleCursor ? { ...this.keyboardBattleCursor } : 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), roleSealPresent: Boolean(this.unitViews.get(unit.id)?.roleSeal),
roleSealText: this.unitViews.get(unit.id)?.roleSeal?.text ?? null, roleSealText: this.unitViews.get(unit.id)?.roleSeal?.text ?? null,
roleSealVisible: this.unitViews.get(unit.id)?.roleSeal?.visible ?? false, 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) => ({ statusBadges: (this.unitViews.get(unit.id)?.statusBadges ?? []).map((badge) => ({
kind: badge.kind, kind: badge.kind,
visible: badge.container.visible, visible: badge.container.visible,