feat: strengthen semantic battle feedback

This commit is contained in:
2026-07-22 12:57:42 +09:00
parent 9d767e864c
commit d3af88c2a9
14 changed files with 803 additions and 34 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -57,7 +57,7 @@ export type MusicDuckOptions = {
releaseMs?: number;
};
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative';
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative' | 'recovery' | 'status' | 'reward';
type MusicDuckState = {
multiplier: number;
@@ -103,7 +103,10 @@ export class SoundDirector {
turn: 900,
operation: 650,
objective: 1200,
narrative: 220
narrative: 220,
recovery: 1900,
status: 280,
reward: 1100
};
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number };
@@ -442,6 +445,28 @@ export class SoundDirector {
});
}
playRecoveryCue() {
return this.playSemanticCue('recovery', 'recovery-cue', {
volume: 0.26,
stopAfterMs: 1900
});
}
playBurnTickCue() {
return this.playSemanticCue('status', 'burn-tick', {
volume: 0.24,
stopAfterMs: 680
});
}
playRewardRevealCue() {
return this.playSemanticCue('reward', 'reward-reveal', {
volume: 0.26,
stopAfterMs: 1400,
duck: { multiplier: 0.76, attackMs: 36, holdMs: 420, releaseMs: 260 }
});
}
playStrategyPulse() {
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
this.playTone(460, 0.08, 0.012, 'sine');

View File

@@ -14,6 +14,7 @@ import arrowSwishUrl from '../../assets/audio/sfx/arrow-swish.mp3';
import arrowWoodImpactUrl from '../../assets/audio/sfx/arrow-wood-impact.mp3';
import bondResonanceUrl from '../../assets/audio/sfx/bond-resonance.wav';
import bowReleaseUrl from '../../assets/audio/sfx/bow-release.mp3';
import burnTickUrl from '../../assets/audio/sfx/burn-tick.mp3';
import cartRollUrl from '../../assets/audio/sfx/cart-roll.mp3';
import combatImpactUrl from '../../assets/audio/sfx/combat-impact.mp3';
import criticalBoomUrl from '../../assets/audio/sfx/critical-boom.mp3';
@@ -27,6 +28,8 @@ import levelUpUrl from '../../assets/audio/sfx/level-up.wav';
import objectiveFailureUrl from '../../assets/audio/sfx/objective-failure.mp3';
import objectiveSuccessUrl from '../../assets/audio/sfx/objective-success.mp3';
import operationAlertUrl from '../../assets/audio/sfx/operation-alert.mp3';
import recoveryCueUrl from '../../assets/audio/sfx/recovery-cue.mp3';
import rewardRevealUrl from '../../assets/audio/sfx/reward-reveal.mp3';
import shieldBlockUrl from '../../assets/audio/sfx/shield-block.mp3';
import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
import storyPageTurnUrl from '../../assets/audio/sfx/story-page-turn.mp3';
@@ -69,6 +72,9 @@ export const effectTracks = {
'operation-alert': operationAlertUrl,
'objective-success': objectiveSuccessUrl,
'objective-failure': objectiveFailureUrl,
'recovery-cue': recoveryCueUrl,
'burn-tick': burnTickUrl,
'reward-reveal': rewardRevealUrl,
'sword-slash': swordSlashUrl,
'sword-swing-variant': swordSwingVariantUrl,
'combat-impact': combatImpactUrl,

View File

@@ -1401,11 +1401,21 @@ type TerrainPanelLayout = {
messageBounds: HudBounds;
};
type UnitStatusBadgeView = {
container: Phaser.GameObjects.Container;
background: Phaser.GameObjects.Arc;
icon: Phaser.GameObjects.Image;
turnsText: Phaser.GameObjects.Text;
kind: BattleStatusKind;
};
type UnitView = {
sprite: Phaser.GameObjects.Sprite;
hitZone: Phaser.GameObjects.Zone;
label: Phaser.GameObjects.Text;
roleSeal?: Phaser.GameObjects.Text;
statusBadges: UnitStatusBadgeView[];
statusBadgeSide: -1 | 1;
textureBase: string;
direction: UnitDirection;
baseX: number;
@@ -1426,6 +1436,8 @@ type DamageCommand = Exclude<BattleCommand, 'wait'>;
type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar';
type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
type BattleStatusTickResult = { message?: string; burnTicked: boolean };
type TerrainRecoveryResult = { message?: string; applied: boolean };
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';
@@ -5563,6 +5575,8 @@ export class BattleScene extends Phaser.Scene {
sprite,
hitZone,
label,
statusBadges: [],
statusBadgeSide: unit.faction === 'ally' ? 1 : -1,
textureBase,
direction: 'south' as UnitDirection,
baseX: sprite.x,
@@ -6238,6 +6252,7 @@ export class BattleScene extends Phaser.Scene {
const statusesCleared = this.battleStatuses.get(supportTarget.id)?.length ?? 0;
supportTarget.hp += healAmount;
this.battleStatuses.delete(supportTarget.id);
this.syncUnitStatusBadges(supportTarget);
this.tacticalCommand.effectValue = healAmount;
this.tacticalCommand.statusesCleared = statusesCleared;
this.tacticalCommand.effectPending = false;
@@ -6253,7 +6268,11 @@ export class BattleScene extends Phaser.Scene {
const previousPhase: BattlePhase = this.phase === 'moving' ? 'moving' : 'idle';
this.phase = 'animating';
this.hideTacticalInitiativePanel();
soundDirector.playStrategyPulse();
if (role === 'support') {
soundDirector.playRecoveryCue();
} else {
soundDirector.playStrategyPulse();
}
this.pushBattleLog(message);
this.renderTacticalInitiativeChip();
this.renderSortieOrderHud();
@@ -7124,6 +7143,7 @@ export class BattleScene extends Phaser.Scene {
}
this.syncUnitRoleSeal(unit, view);
this.syncUnitStatusBadges(unit, view);
this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y));
view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52);
this.setUnitRoleSealPosition(view, view.baseX, view.baseY);
@@ -7132,6 +7152,7 @@ export class BattleScene extends Phaser.Scene {
this.setUnitHitZoneEnabled(view, visible);
view.label.setVisible(visible);
view.roleSeal?.setVisible(visible);
view.statusBadges.forEach((badge) => badge.container.setVisible(visible));
this.syncUnitMotion(unit, view);
this.applyUnitLegibilityStyle(unit, view);
}
@@ -7174,6 +7195,56 @@ export class BattleScene extends Phaser.Scene {
this.setUnitRoleSealPosition(view, view.baseX, view.baseY);
}
private syncUnitStatusBadges(unit: UnitData, view = this.unitViews.get(unit.id)) {
if (!view) {
return;
}
const statuses = unit.hp > 0 ? this.unitStatuses(unit).slice(0, 2) : [];
while (view.statusBadges.length > statuses.length) {
view.statusBadges.pop()?.container.destroy();
}
statuses.forEach((status, index) => {
let badge = view.statusBadges[index];
const size = Math.max(this.battleUiLength(16), Math.round(this.layout.tileSize * 0.3));
if (!badge?.container.active) {
const container = this.add.container(0, 0);
const background = this.add.circle(0, 0, size * 0.56, 0x071018, 0.96);
const icon = this.createBattleUiIcon(0, 0, this.statusEffectIcon(status.kind), size);
const turnsText = this.add.text(size * 0.42, size * 0.34, `${status.turns}`, {
fontFamily: 'Arial, sans-serif',
fontSize: this.battleUiFontSize(7),
color: '#fff8e8',
fontStyle: '700',
backgroundColor: 'rgba(57, 14, 10, 0.96)',
stroke: '#120704',
strokeThickness: this.battleUiLength(1),
padding: { x: this.battleUiLength(1), y: 0 }
});
turnsText.setOrigin(0.5);
container.add([background, icon, turnsText]);
container.setDepth(11.22);
container.setName(`unit-status-${unit.id}-${index}`);
if (this.mapMask) {
container.setMask(this.mapMask);
}
badge = { container, background, icon, turnsText, kind: status.kind };
view.statusBadges[index] = badge;
}
const tone = this.statusEffectTone(status.kind);
badge.kind = status.kind;
badge.background.setStrokeStyle(this.battleUiLength(2), tone, 0.96);
badge.icon.setFrame(battleUiIconFrames[this.statusEffectIcon(status.kind)]);
badge.turnsText.setText(`${status.turns}`);
});
this.setUnitStatusBadgePositions(view, view.baseX, view.baseY);
const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y);
view.statusBadges.forEach((badge) => badge.container.setVisible(visible));
}
private refreshUnitLegibilityStyles() {
battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit));
}
@@ -7201,6 +7272,8 @@ export class BattleScene extends Phaser.Scene {
label.setBackgroundColor(this.unitLabelBackground(unit));
});
view.roleSeal?.setVisible(visible).setAlpha(1);
this.syncUnitStatusBadges(unit, view);
view.statusBadges.forEach((badge) => badge.container.setVisible(visible).setAlpha(1));
}
private unitLabelBackground(unit: UnitData) {
@@ -8944,7 +9017,6 @@ export class BattleScene extends Phaser.Scene {
this.refreshEnemyIntentForecast();
this.showOpeningBattleEvent();
this.scheduleFirstBattleTutorial();
soundDirector.playSelect();
}
private deploymentSignature() {
@@ -18777,12 +18849,18 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
this.renderBattleSpeedControl();
this.updateObjectiveTracker();
const statusMessage = this.tickBattleStatuses('enemy');
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
const statusResult = this.tickBattleStatuses('enemy', false);
const statusMessage = statusResult.message;
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
if (statusResult.burnTicked) {
soundDirector.playBurnTickCue();
}
return;
}
const recoveryMessage = this.applyTerrainRecovery('enemy');
soundDirector.playEnemyTurnCue();
const recoveryResult = this.applyTerrainRecovery('enemy', false);
const recoveryMessage = recoveryResult.message;
this.playTurnStartFeedbackCue('enemy', statusResult.burnTicked, recoveryResult.applied);
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
void this.runEnemyTurn();
}
@@ -19854,10 +19932,14 @@ export class BattleScene extends Phaser.Scene {
});
}
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
volume: result.usable.command === 'strategy' ? 0.3 : 0.24,
stopAfterMs: 620
});
if (result.usable.effect === 'heal') {
soundDirector.playRecoveryCue();
} else {
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
volume: result.usable.command === 'strategy' ? 0.3 : 0.24,
stopAfterMs: 620
});
}
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.18, this.layout.tileSize * 0.3);
ring.setStrokeStyle(4, accent, 0.92);
ring.setDepth(12.8);
@@ -20629,10 +20711,14 @@ export class BattleScene extends Phaser.Scene {
effect.setDepth(depth + 9);
this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true });
this.showSortieSupportCombatEffect(result, left + panelWidth / 2, top + 156, depth + 10);
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
volume: result.usable.command === 'strategy' ? 0.38 : 0.28,
stopAfterMs: 900
});
if (result.usable.effect === 'heal') {
soundDirector.playRecoveryCue();
} else {
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
volume: result.usable.command === 'strategy' ? 0.38 : 0.28,
stopAfterMs: 900
});
}
const targetStatus = this.renderCombatStatusPanel(result.target, left + panelWidth / 2 - 196, top + 202, 392);
targetStatus.hpFill.setScale(result.previousTargetHp / result.target.maxHp, 1);
@@ -22780,6 +22866,7 @@ export class BattleScene extends Phaser.Scene {
if (view.roleSeal) {
this.tweens.killTweensOf(view.roleSeal);
}
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
this.setUnitBasePosition(view, startX, startY);
this.resetUnitSpritePose(view);
view.label.setPosition(startX, startY + this.layout.tileSize * 0.52);
@@ -22816,6 +22903,16 @@ export class BattleScene extends Phaser.Scene {
ease: 'Sine.easeInOut'
});
}
view.statusBadges.forEach((badge, index) => {
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
this.tweens.add({
targets: badge.container,
x: position.x,
y: position.y,
duration: movementDuration,
ease: 'Sine.easeInOut'
});
});
});
}
@@ -22825,8 +22922,13 @@ export class BattleScene extends Phaser.Scene {
}
this.turnNumber += 1;
const statusMessage = this.tickBattleStatuses('ally');
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
const statusResult = this.tickBattleStatuses('ally', false);
const statusMessage = statusResult.message;
const statusOutcomeDelayMs = statusResult.burnTicked ? 720 : 0;
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded(statusOutcomeDelayMs)) {
if (statusResult.burnTicked) {
soundDirector.playBurnTickCue();
}
return;
}
const expiredBuffMessage = this.tickBattleBuffs();
@@ -22834,16 +22936,16 @@ export class BattleScene extends Phaser.Scene {
this.fastForwardHeld = false;
this.actedUnitIds.clear();
this.attackIntents = this.emptyAllyTurnAttackIntents();
const recoveryMessage = this.applyTerrainRecovery('ally');
const recoveryResult = this.applyTerrainRecovery('ally', false);
const recoveryMessage = recoveryResult.message;
this.resetActedStyles();
this.updateTurnText();
this.renderBattleSpeedControl();
this.updateObjectiveTracker();
const eventCountBeforeTurnCheck = this.triggeredBattleEvents.size;
this.checkBattleEvents();
if (this.triggeredBattleEvents.size === eventCountBeforeTurnCheck) {
soundDirector.playAllyTurnCue();
}
const operationCuePlayed = this.triggeredBattleEvents.size !== eventCountBeforeTurnCheck;
this.playTurnStartFeedbackCue('ally', statusResult.burnTicked, recoveryResult.applied, operationCuePlayed);
this.centerCameraOnAllyLine();
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
.filter(Boolean)
@@ -22871,7 +22973,7 @@ export class BattleScene extends Phaser.Scene {
return [];
}
private applyTerrainRecovery(faction: ActiveFaction) {
private applyTerrainRecovery(faction: ActiveFaction, playCue = true): TerrainRecoveryResult {
const messages: string[] = [];
battleUnits
@@ -22902,13 +23004,40 @@ export class BattleScene extends Phaser.Scene {
});
if (messages.length === 0) {
return undefined;
return { applied: false };
}
soundDirector.playEffect('exp-gain', { volume: 0.18, stopAfterMs: 520 });
if (playCue) {
soundDirector.playRecoveryCue();
}
const message = `거점 효과\n${messages.slice(0, 3).join('\n')}${messages.length > 3 ? `\n외 ${messages.length - 3}` : ''}`;
this.pushBattleLog(message);
return message;
return { message, applied: true };
}
private playTurnStartFeedbackCue(
faction: ActiveFaction,
burnTicked: boolean,
recoveryApplied: boolean,
operationCuePlayed = false
) {
if (operationCuePlayed) {
return 'operation';
}
if (burnTicked) {
soundDirector.playBurnTickCue();
return 'status';
}
if (recoveryApplied) {
soundDirector.playRecoveryCue();
return 'recovery';
}
if (faction === 'ally') {
soundDirector.playAllyTurnCue();
} else {
soundDirector.playEnemyTurnCue();
}
return 'turn';
}
private turnStartEquipmentRecovery(unit: UnitData) {
@@ -23516,12 +23645,14 @@ export class BattleScene extends Phaser.Scene {
}
this.battleStatuses.set(unit.id, unitStatuses);
this.syncUnitStatusBadges(unit);
return { ...nextStatus };
}
private tickBattleStatuses(faction: ActiveFaction) {
private tickBattleStatuses(faction: ActiveFaction, playCue = true): BattleStatusTickResult {
const messages: string[] = [];
const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0);
let burnTicked = false;
units.forEach((unit) => {
const statuses = this.unitStatuses(unit);
@@ -23535,10 +23666,25 @@ export class BattleScene extends Phaser.Scene {
const previousHp = unit.hp;
const damage = Math.min(unit.hp, Math.max(1, status.power));
unit.hp = Math.max(0, unit.hp - damage);
burnTicked = true;
messages.push(`${unit.name} ${status.label}: 지속 피해 ${damage} · HP ${previousHp}${unit.hp}`);
const visibleOnMap = this.isTileVisible(unit.x, unit.y);
if (visibleOnMap) {
this.showMapResultPopup(
unit,
[`${status.label} -${damage}`, `HP ${previousHp}${unit.hp}`],
'#ffb07c',
'#2b0b05',
17,
30
);
}
if (unit.hp <= 0) {
this.applyDefeatedStyle(unit);
} else {
if (visibleOnMap) {
this.flashDamage(unit, previousHp);
}
this.syncUnitMotion(unit);
}
}
@@ -23565,16 +23711,20 @@ export class BattleScene extends Phaser.Scene {
} else {
this.battleStatuses.delete(unit.id);
}
this.syncUnitStatusBadges(unit);
});
if (messages.length === 0) {
return undefined;
return { burnTicked: false };
}
if (playCue && burnTicked) {
soundDirector.playBurnTickCue();
}
this.updateMiniMap();
const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}` : ''}`;
this.pushBattleLog(message);
return message;
return { message, burnTicked };
}
private tickBattleBuffs() {
@@ -23697,6 +23847,7 @@ export class BattleScene extends Phaser.Scene {
if (view.roleSeal) {
this.tweens.killTweensOf(view.roleSeal);
}
view.statusBadges.forEach((badge) => this.tweens.killTweensOf(badge.container));
this.setUnitBasePosition(view, startX, startY);
this.resetUnitSpritePose(view);
view.label.setPosition(startX, startY + this.layout.tileSize * 0.52);
@@ -23732,6 +23883,16 @@ export class BattleScene extends Phaser.Scene {
ease: 'Sine.easeInOut'
});
}
view.statusBadges.forEach((badge, index) => {
const position = this.unitStatusBadgePosition(view, targetX, targetY, index);
this.tweens.add({
targets: badge.container,
x: position.x,
y: position.y,
duration: movementDuration,
ease: 'Sine.easeInOut'
});
});
}
private movementTileDistance(unit: UnitData, x: number, y: number) {
@@ -23973,6 +24134,7 @@ 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.setUnitStatusBadgePositions(view, x, y);
}
private setUnitRoleSealPosition(view: UnitView, x: number, y: number) {
@@ -23982,6 +24144,21 @@ export class BattleScene extends Phaser.Scene {
);
}
private setUnitStatusBadgePositions(view: UnitView, x: number, y: number) {
view.statusBadges.forEach((badge, index) => {
const position = this.unitStatusBadgePosition(view, x, y, index);
badge.container.setPosition(position.x, position.y);
});
}
private unitStatusBadgePosition(view: UnitView, x: number, y: number, index: number) {
const spacing = this.layout.tileSize * 0.29;
return {
x: x + view.statusBadgeSide * (this.layout.tileSize * 0.34 + index * spacing),
y: y - this.layout.tileSize * 0.38
};
}
private setUnitHitZoneEnabled(view: UnitView, enabled: boolean) {
view.hitZone.setVisible(enabled);
if (view.hitZone.input) {
@@ -24283,6 +24460,7 @@ export class BattleScene extends Phaser.Scene {
if (!view) {
return;
}
this.syncUnitStatusBadges(unit, view);
view.sprite.stop();
view.sprite.disableInteractive();
@@ -24297,6 +24475,7 @@ export class BattleScene extends Phaser.Scene {
label.setBackgroundColor('');
});
view.roleSeal?.setAlpha(0).setVisible(false);
view.statusBadges.forEach((badge) => badge.container.setAlpha(0).setVisible(false));
}
private faceUnitToward(unit: UnitData, target: UnitData) {
@@ -24462,7 +24641,7 @@ export class BattleScene extends Phaser.Scene {
private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) {
const view = this.unitViews.get(unit.id);
if (!view) {
if (!view || !this.isTileVisible(unit.x, unit.y)) {
return 0;
}
@@ -28012,6 +28191,13 @@ 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,
statusBadges: (this.unitViews.get(unit.id)?.statusBadges ?? []).map((badge) => ({
kind: badge.kind,
visible: badge.container.visible,
x: badge.container.x,
y: badge.container.y,
turns: Number.parseInt(badge.turnsText.text, 10) || 0
})),
level: unit.level,
exp: unit.exp,
hp: unit.hp,

View File

@@ -13352,6 +13352,7 @@ export class CampScene extends Phaser.Scene {
});
this.victoryRewardActions.push({ id: definition.id, label: definition.label, button });
});
soundDirector.playRewardRevealCue();
}
private completeVictoryRewardAcknowledgement(
@@ -22875,7 +22876,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });
soundDirector.playRecoveryCue();
const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
const bondText = bondGain > 0 ? `연결 공명 총 +${bondGain}` : '';