Polish battle result feedback

This commit is contained in:
2026-07-03 21:18:08 +09:00
parent d7bfdfb508
commit cc373f22e4
8 changed files with 304 additions and 98 deletions

View File

@@ -6545,6 +6545,11 @@ export class BattleScene extends Phaser.Scene {
await this.delay(180);
await this.playCombatCutIn(result.counter);
}
this.showCombatMapResult(result);
if (result.counter) {
this.showCombatMapResult(result.counter);
}
await this.delay(result.counter ? 220 : 160);
this.finishUnitAction(attacker, this.formatCombatResult(result));
}
@@ -6569,6 +6574,8 @@ export class BattleScene extends Phaser.Scene {
const result = this.resolveSupportAction(user, target, usable);
this.clearMarkers();
await this.playSupportCutIn(result);
this.showSupportMapResult(result);
await this.delay(160);
this.finishUnitAction(user, this.formatSupportResult(result));
}
@@ -8619,9 +8626,9 @@ export class BattleScene extends Phaser.Scene {
this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0);
this.faceUnitToward(attacker, defender);
if (hit) {
this.flashDamage(defender, damage, critical);
this.flashDamage(defender, damage, critical, previousDefenderHp);
} else {
this.flashMiss(defender);
this.flashMiss(defender, this.missFeedbackLabel(action, usable));
}
if (defender.hp <= 0) {
@@ -8684,7 +8691,7 @@ export class BattleScene extends Phaser.Scene {
if (healAmount > 0) {
target.hp = Math.min(target.maxHp, target.hp + healAmount);
this.syncUnitMotion(target);
this.flashSupport(target, `+${healAmount}`, '#a8ffd0');
this.flashSupport(target, `회복 +${healAmount}`, '#a8ffd0', `HP ${previousTargetHp}${target.hp}`);
this.updateMiniMap();
}
@@ -8698,7 +8705,7 @@ export class BattleScene extends Phaser.Scene {
criticalBonus: usable.criticalBonus ?? 0
};
this.battleBuffs.set(target.id, buff);
this.flashSupport(target, usable.name, '#ffdf7b');
this.flashSupport(target, `강화 ${usable.duration ?? 1}`, '#ffdf7b', this.supportBuffCompactText(buff));
}
const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable));
@@ -8893,7 +8900,7 @@ export class BattleScene extends Phaser.Scene {
}
private formatCombatResult(result: CombatResult) {
const headline = this.combatResultHeadline(result);
const headline = this.combatResultSummaryLine(result);
const resolution = this.combatResolutionLine(result);
const summaryBondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : '';
const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : '';
@@ -8907,10 +8914,42 @@ export class BattleScene extends Phaser.Scene {
return result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action];
}
private combatActionLogName(result: CombatResult) {
if (result.isCounter) {
return '반격';
}
if (result.usable) {
return result.usable.name;
}
if (result.action === 'strategy') {
return '책략';
}
if (result.action === 'item') {
return '도구';
}
return '공격';
}
private combatDamageValue(result: CombatResult) {
return result.hit ? result.damage : 0;
}
private combatResultSummaryLine(result: CombatResult) {
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : '';
const counter = !result.isCounter && result.counter ? ' · 반격 발생' : '';
return `${result.attacker.name}${result.defender.name} | ${this.combatActionLogName(result)} | ${this.combatOutcomeSummary(result)}${hpLine}${counter}`;
}
private combatOutcomeSummary(result: CombatResult) {
if (!result.hit) {
return this.missFeedbackLabel(result.action, result.usable);
}
const critical = result.critical ? '치명 ' : '';
const defeated = result.defeated ? ' · 퇴각' : '';
return `${critical}피해 ${result.damage}${defeated}`;
}
private combatResultHeadline(result: CombatResult) {
const damage = this.combatDamageValue(result);
const outcome = !result.hit
@@ -8926,11 +8965,11 @@ export class BattleScene extends Phaser.Scene {
}
private combatResolutionLine(result: CombatResult) {
const successLabel = result.action === 'strategy' ? '성공' : '명중';
const successLabel = result.action === 'strategy' || result.usable?.command === 'strategy' ? '성공' : '명중';
const actual = this.combatDamageValue(result);
const verdict = result.hit ? `${successLabel} ${result.hitRate}%` : `${successLabel} 실패(${result.hitRate}%)`;
const critical = result.critical ? '치명 발생' : `치명 ${result.criticalRate}%`;
return `판정: ${verdict} · ${critical} · 예 ${result.expectedDamage}/실제 ${actual}`;
const verdict = result.hit ? `${successLabel} ${result.hitRate}% 통과` : `${successLabel} ${result.hitRate}% 실패`;
const critical = result.hit ? (result.critical ? '치명 발생' : `치명 ${result.criticalRate}% 미발동`) : '치명 없음';
return `판정: ${verdict} · ${critical} · 예 ${result.expectedDamage} / 실제 ${actual}`;
}
private formatCombatOutcome(result: CombatResult) {
@@ -8943,10 +8982,7 @@ export class BattleScene extends Phaser.Scene {
}
private formatCounterLine(result: CombatResult) {
const counterOutcome = result.hit ? `${result.critical ? '치명 ' : ''}${result.damage} 피해` : '회피됨';
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : '';
const defeated = result.defeated ? ' · 퇴각' : '';
return `반격: ${result.attacker.name}${result.defender.name}: ${counterOutcome}${hpLine}${defeated}`;
return `반격 결과: ${this.combatResultSummaryLine(result)}`;
}
private combatGrowthLines(result: CombatResult) {
@@ -8971,15 +9007,36 @@ export class BattleScene extends Phaser.Scene {
private formatSupportResult(result: SupportResult) {
const supportEffect =
result.usable.effect === 'heal'
? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})`
: `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`;
return `${result.user.name} ${result.usable.name}\n${supportEffect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
? `결과: 회복 +${result.healAmount} · HP ${result.previousTargetHp}${result.target.hp}`
: `결과: 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`;
return `${this.supportResultSummaryLine(result)}\n${supportEffect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
const effect =
result.usable.effect === 'heal'
? `${result.target.name} 병력 +${result.healAmount} (${result.target.hp}/${result.target.maxHp})`
: `${result.target.name} 강화: 공격 +${result.buff?.attackBonus ?? 0} / 명중 +${result.buff?.hitBonus ?? 0} / 치명 +${result.buff?.criticalBonus ?? 0}`;
return `${result.user.name} ${result.usable.name}\n${effect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`;
}
private supportResultSummaryLine(result: SupportResult) {
if (result.usable.effect === 'heal') {
return `${result.user.name} ${result.target.name} | ${result.usable.name} | 회복 +${result.healAmount} · HP ${result.previousTargetHp}${result.target.hp}`;
}
return `${result.user.name}${result.target.name} | ${result.usable.name} | 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`;
}
private supportBuffCompactText(buff?: BattleBuffState) {
if (!buff) {
return '강화 유지';
}
return [`공+${buff.attackBonus}`, `명+${buff.hitBonus}`, `치+${buff.criticalBonus}`].join(' ');
}
private missFeedbackLabel(action: DamageCommand, usable?: BattleUsable) {
if (action === 'strategy' || usable?.command === 'strategy') {
return '책략 실패';
}
if (action === 'item' || usable?.command === 'item') {
return '효과 실패';
}
return '회피';
}
private commandResultMessage(unit: UnitData, command: BattleCommand) {
@@ -9922,6 +9979,11 @@ export class BattleScene extends Phaser.Scene {
await this.delay(180);
await this.playCombatCutIn(result.counter);
}
this.showCombatMapResult(result);
if (result.counter) {
this.showCombatMapResult(result.counter);
}
await this.delay(result.counter ? 220 : 160);
return this.formatEnemyCombatResult(result, behavior);
}
@@ -10179,10 +10241,18 @@ export class BattleScene extends Phaser.Scene {
}
private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) {
const outcome = result.hit ? `${result.critical ? '치명타 ' : ''}${result.damage} 피해` : '회피됨';
const defeated = result.defeated ? ` / ${result.defender.name} 퇴각` : ` / ${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`;
const counter = result.counter ? ` / ${this.formatCounterLine(result.counter)}` : '';
return `${result.attacker.name} ${this.enemyBehaviorLabel(behavior)} 공격: ${result.defender.name} ${outcome}${defeated}${counter}`;
const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : '';
return `${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${counter}`;
}
private enemyBehaviorResultLabel(behavior: EnemyAiBehavior) {
if (behavior === 'aggressive') {
return '추격';
}
if (behavior === 'hold') {
return '고수';
}
return '경계';
}
private async playCombatCutIn(result: CombatResult) {
@@ -13397,22 +13467,15 @@ export class BattleScene extends Phaser.Scene {
this.syncUnitMotion(unit, view, direction);
}
private flashDamage(unit: UnitData, damage: number, critical = false) {
private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number) {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, critical ? `치명 -${damage}` : `-${damage}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: critical ? '22px' : '20px',
color: critical ? '#ff8f5f' : '#ffdf7b',
stroke: '#220909',
strokeThickness: 4,
fontStyle: '700'
});
popup.setOrigin(0.5);
popup.setDepth(30);
const title = unit.hp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`;
const detail = previousHp === undefined ? '' : `HP ${previousHp}${unit.hp}`;
this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30);
view.sprite.setTintFill(0xffe0a3);
this.time.delayedCall(120, () => {
@@ -13424,33 +13487,15 @@ export class BattleScene extends Phaser.Scene {
this.applyUnitLegibilityStyle(unit, view);
}
});
this.tweens.add({
targets: popup,
y: popup.y - 28,
alpha: 0,
duration: 520,
ease: 'Sine.easeOut',
onComplete: () => popup.destroy()
});
}
private flashMiss(unit: UnitData) {
private flashMiss(unit: UnitData, label = '회피') {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, '회피', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#d9f1ff',
stroke: '#071623',
strokeThickness: 4,
fontStyle: '700'
});
popup.setOrigin(0.5);
popup.setDepth(30);
this.showMapResultPopup(unit, [label], '#d9f1ff', '#071623', label.length > 4 ? 17 : 19, 26);
this.tweens.add({
targets: [view.sprite, view.label],
@@ -13459,33 +13504,15 @@ export class BattleScene extends Phaser.Scene {
yoyo: true,
ease: 'Sine.easeOut'
});
this.tweens.add({
targets: popup,
y: popup.y - 24,
alpha: 0,
duration: 500,
ease: 'Sine.easeOut',
onComplete: () => popup.destroy()
});
}
private flashSupport(unit: UnitData, label: string, color: string) {
private flashSupport(unit: UnitData, label: string, color: string, detail = '') {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
const popup = this.add.text(view.sprite.x, view.sprite.y - this.layout.tileSize * 0.5, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color,
stroke: '#071623',
strokeThickness: 4,
fontStyle: '700'
});
popup.setOrigin(0.5);
popup.setDepth(30);
this.showMapResultPopup(unit, [label, detail], color, '#071623', 18, 28);
this.tweens.add({
targets: view.sprite,
@@ -13494,12 +13521,91 @@ export class BattleScene extends Phaser.Scene {
yoyo: true,
ease: 'Sine.easeOut'
});
}
private showCombatMapResult(result: CombatResult) {
if (!result.hit) {
this.showMapResultPopup(
result.defender,
[this.missFeedbackLabel(result.action, result.usable)],
'#d9f1ff',
'#071623',
19,
26
);
return;
}
this.showMapResultPopup(
result.defender,
[this.combatMapDamageTitle(result), `HP ${result.previousDefenderHp}${result.defender.hp}`],
result.critical ? '#ff8f5f' : '#ffdf7b',
'#220909',
result.critical ? 20 : 18,
result.critical ? 34 : 30
);
}
private combatMapDamageTitle(result: CombatResult) {
const damage = `-${result.damage}`;
if (result.defeated) {
return `${result.critical ? '치명 ' : ''}퇴각 ${damage}`;
}
return `${result.critical ? '치명 ' : '피해 '}${damage}`;
}
private showSupportMapResult(result: SupportResult) {
if (result.usable.effect === 'heal') {
this.showMapResultPopup(
result.target,
[`회복 +${result.healAmount}`, `HP ${result.previousTargetHp}${result.target.hp}`],
'#a8ffd0',
'#071623',
18,
28
);
return;
}
this.showMapResultPopup(
result.target,
[`강화 ${result.buff?.turns ?? result.usable.duration ?? 1}`, this.supportBuffCompactText(result.buff)],
'#ffdf7b',
'#071623',
18,
28
);
}
private showMapResultPopup(unit: UnitData, lines: string[], color: string, stroke: string, fontSize: number, lift: number) {
const view = this.unitViews.get(unit.id);
if (!view) {
return;
}
const popup = this.add.text(
view.sprite.x,
view.sprite.y - this.layout.tileSize * 0.64,
lines.filter(Boolean).join('\n'),
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: `${fontSize}px`,
color,
stroke,
strokeThickness: 4,
fontStyle: '700',
align: 'center',
lineSpacing: -3
}
);
popup.setOrigin(0.5, 1);
popup.setDepth(35);
this.tweens.add({
targets: popup,
y: popup.y - 24,
y: popup.y - lift,
alpha: 0,
duration: 560,
duration: 680,
delay: 80,
ease: 'Sine.easeOut',
onComplete: () => popup.destroy()
});
@@ -13592,11 +13698,7 @@ export class BattleScene extends Phaser.Scene {
}
private compactBattleLogEntry(message: string) {
const firstLine = message
.split('\n')
.map((line) => line.trim())
.find(Boolean);
const firstLine = this.firstMessageLine(message);
if (!firstLine) {
return '';
}
@@ -13605,6 +13707,31 @@ export class BattleScene extends Phaser.Scene {
return this.truncateBattleLogText(normalized, 42);
}
private firstMessageLine(message: string) {
return (
message
.split('\n')
.map((line) => line.trim())
.find(Boolean) ?? ''
);
}
private actionResultSummaryParts(line: string) {
const parts = line
.split('|')
.map((part) => part.trim())
.filter(Boolean);
if (parts.length < 3 || !parts[0].includes('→')) {
return undefined;
}
return {
subject: parts[0],
action: parts[1],
outcome: parts.slice(2).join(' · ')
};
}
private truncateBattleLogText(text: string, maxLength: number) {
return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 3))}...` : text;
}
@@ -13619,21 +13746,27 @@ export class BattleScene extends Phaser.Scene {
private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } {
const { text } = this.battleLogDisplayParts(entry);
if (text.includes('반격')) {
return { icon: 'counter', tone: 0xb64a45 };
}
if (text.includes('퇴각') || text.includes('격파') || text.includes('전투 불능')) {
return { icon: 'critical', tone: 0xd8732c };
}
if (text.includes('치명')) {
return { icon: 'critical', tone: 0xd8732c };
}
if (text.includes('반격')) {
return { icon: 'counter', tone: 0xb64a45 };
if (text.includes('책략 실패') || text.includes('효과 실패') || text.includes('회피')) {
return { icon: 'move', tone: 0x83d6ff };
}
if (text.includes('책략') || text.includes('화계') || text.includes('교란') || text.includes('고무')) {
return { icon: 'strategy', tone: palette.blue };
}
if (text.includes('회복') || text.includes('병력') || text.includes('거점') || text.includes('상태효과')) {
if (text.includes('회복') || text.includes('병력') || text.includes('거점')) {
return { icon: 'heal', tone: 0x59d18c };
}
if (text.includes('강화') || text.includes('상태효과')) {
return { icon: 'focus', tone: palette.gold };
}
if (text.includes('이동') || text.includes('접근')) {
return { icon: 'move', tone: 0x83d6ff };
}
@@ -13678,7 +13811,7 @@ export class BattleScene extends Phaser.Scene {
countText.setOrigin(1, 0);
countText.setDepth(31);
const entries = this.battleLog.slice(0, 4);
const entries = this.battleLog.slice(0, 3);
if (entries.length === 0) {
const emptyText = this.trackSideObject(this.add.text(x + 12, y + 45, '아직 기록된 행동이 없습니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -13690,21 +13823,21 @@ export class BattleScene extends Phaser.Scene {
}
entries.forEach((entry, index) => {
const rowY = y + 32 + index * 20;
const rowY = y + 32 + index * 28;
const { turn, text } = this.battleLogDisplayParts(entry);
const visual = this.battleLogVisual(entry);
const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, 18, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.86));
const row = this.trackSideObject(this.add.rectangle(x + 8, rowY, width - 16, 24, index === 0 ? 0x17232e : 0x101820, index === 0 ? 0.98 : 0.88));
row.setOrigin(0);
row.setDepth(31);
row.setStrokeStyle(1, visual.tone, index === 0 ? 0.62 : 0.28);
row.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.34);
const iconFrame = this.trackSideObject(this.add.rectangle(x + 21, rowY + 9, 18, 18, 0x0a0f14, 0.96));
const iconFrame = this.trackSideObject(this.add.rectangle(x + 23, rowY + 12, 22, 22, 0x0a0f14, 0.96));
iconFrame.setDepth(32);
iconFrame.setStrokeStyle(1, visual.tone, 0.5);
const icon = this.trackSideIcon(x + 21, rowY + 9, visual.icon, 17);
iconFrame.setStrokeStyle(1, visual.tone, index === 0 ? 0.72 : 0.52);
const icon = this.trackSideIcon(x + 23, rowY + 12, visual.icon, 21);
icon.setDepth(33);
const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + 3, turn, {
const turnText = this.trackSideObject(this.add.text(x + width - 12, rowY + 6, turn, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: '#9fb0bf',
@@ -13713,12 +13846,12 @@ export class BattleScene extends Phaser.Scene {
turnText.setOrigin(1, 0);
turnText.setDepth(32);
const eventText = this.trackSideObject(this.add.text(x + 36, rowY + 2, this.truncateBattleLogText(text, 32), {
const eventText = this.trackSideObject(this.add.text(x + 42, rowY + 5, this.truncateBattleLogText(text, index === 0 ? 39 : 34), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
fontSize: index === 0 ? '13px' : '12px',
color: index === 0 ? '#e8dfca' : '#c8d2dd',
fontStyle: index === 0 ? '700' : '400',
fixedWidth: width - 84
fixedWidth: width - 92
}));
eventText.setDepth(32);
});
@@ -14289,7 +14422,12 @@ export class BattleScene extends Phaser.Scene {
}
this.renderEquipmentSummary(unit, left, equipmentTop, width, hasEffects);
if (message) {
this.renderPanelMessage(message, left, equipmentTop + 156, width, 42);
const resultLine = this.actionResultSummaryParts(this.firstMessageLine(message));
if (resultLine) {
this.renderPanelActionResultMessage(resultLine, left, equipmentTop + 150, width);
} else {
this.renderPanelMessage(message, left, equipmentTop + 156, width, 42);
}
}
this.showFacingIndicator(unit);
}
@@ -14622,6 +14760,43 @@ export class BattleScene extends Phaser.Scene {
fill.setOrigin(0);
}
private renderPanelActionResultMessage(
result: { subject: string; action: string; outcome: string },
x: number,
y: number,
width: number
) {
const height = 54;
const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom() - height));
const visual = this.battleLogVisual(`0T ${result.subject} | ${result.action} | ${result.outcome}`);
const bg = this.trackSideObject(this.add.rectangle(x, safeY, width, height, 0x101820, 0.97));
bg.setOrigin(0);
bg.setStrokeStyle(1, visual.tone, 0.72);
const iconFrame = this.trackSideObject(this.add.rectangle(x + 24, safeY + 27, 34, 34, 0x0a0f14, 0.94));
iconFrame.setStrokeStyle(1, visual.tone, 0.64);
const icon = this.trackSideIcon(x + 24, safeY + 27, visual.icon, 31);
icon.setDepth(33);
const title = this.trackSideObject(this.add.text(x + 48, safeY + 7, this.truncateBattleLogText(`${result.subject} · ${result.action}`, 31), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: '#9fb0bf',
fontStyle: '700',
fixedWidth: width - 62
}));
title.setDepth(33);
const outcome = this.trackSideObject(this.add.text(x + 48, safeY + 27, this.truncateBattleLogText(result.outcome, 34), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#f2e3bf',
fontStyle: '700',
fixedWidth: width - 62
}));
outcome.setDepth(33);
}
private renderPanelMessage(text: string, x: number, y: number, width: number, height = 74) {
const safeY = Math.max(this.sideContentTop(), Math.min(y, this.sideContentBottom() - height));
const compact = height < 60;