Improve battle status effect UI

This commit is contained in:
2026-07-03 21:46:57 +09:00
parent cc373f22e4
commit 69d9346c53

View File

@@ -1336,6 +1336,7 @@ type CombatResult = CombatPreview & {
defenderGrowth: EquipmentGrowthResult;
bondExp: number;
bondGrowth: BondGrowthResult[];
statusApplied?: BattleStatusState;
counter?: CombatResult;
};
@@ -1352,6 +1353,9 @@ type BattleUsable = {
attackBonus?: number;
hitBonus?: number;
duration?: number;
statusEffect?: BattleStatusKind;
statusDuration?: number;
statusPower?: number;
description: string;
};
@@ -1397,6 +1401,16 @@ type SupportResult = {
equipmentGrowth: EquipmentGrowthResult;
};
type BattleStatusKind = 'burn' | 'confusion';
type BattleStatusState = {
unitId: string;
kind: BattleStatusKind;
label: string;
turns: number;
power: number;
};
type BattleBuffState = {
unitId: string;
label: string;
@@ -1412,6 +1426,7 @@ type UnitEffectSummary = {
value: string;
tone: number;
turns?: number;
polarity?: 'buff' | 'debuff' | 'terrain';
};
type PreviewModifierSummary = {
@@ -1471,6 +1486,7 @@ type BattleSaveState = {
bonds: BondState[];
itemStocks?: Record<string, Record<string, number>>;
battleBuffs?: BattleBuffState[];
battleStatuses?: BattleStatusState[];
battleStats?: Record<string, UnitBattleStats>;
triggeredBattleEvents?: string[];
};
@@ -1521,6 +1537,9 @@ const usableCatalog: Record<string, BattleUsable> = {
power: 12,
accuracyBonus: 6,
criticalBonus: 0,
statusEffect: 'burn',
statusDuration: 2,
statusPower: 4,
description: '적 한 부대에 책략 피해를 준다.'
},
roar: {
@@ -1533,6 +1552,9 @@ const usableCatalog: Record<string, BattleUsable> = {
power: 9,
accuracyBonus: 8,
criticalBonus: 4,
statusEffect: 'confusion',
statusDuration: 1,
statusPower: 1,
description: '가까운 적을 위압해 피해를 준다.'
},
bean: {
@@ -3011,6 +3033,7 @@ export class BattleScene extends Phaser.Scene {
private bondStates = new Map<string, BondState>();
private itemStocks = new Map<string, Map<string, number>>();
private battleBuffs = new Map<string, BattleBuffState>();
private battleStatuses = new Map<string, BattleStatusState[]>();
private attackIntents: AttackIntent[] = [];
private battleLog: string[] = [];
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
@@ -3068,6 +3091,8 @@ export class BattleScene extends Phaser.Scene {
this.bondStates = this.createBondStates();
this.itemStocks = this.createItemStocks();
this.battleBuffs.clear();
this.battleStatuses.clear();
this.applyDebugStatusUiSetup();
this.enemyHomeTiles = this.createEnemyHomeTiles();
this.battleStats = this.createBattleStats();
this.triggeredBattleEvents.clear();
@@ -3259,7 +3284,8 @@ export class BattleScene extends Phaser.Scene {
}
private applyDebugBattleSetup() {
if (this.debugBattleSetupKey() !== 'attack-preview') {
const setupKey = this.debugBattleSetupKey();
if (setupKey !== 'attack-preview' && setupKey !== 'status-ui') {
return;
}
@@ -3279,6 +3305,34 @@ export class BattleScene extends Phaser.Scene {
target.hp = Math.max(1, target.hp);
}
private applyDebugStatusUiSetup() {
if (this.debugBattleSetupKey() !== 'status-ui') {
return;
}
const zhangFei = battleUnits.find((unit) => unit.id === 'zhang-fei' && unit.hp > 0);
if (zhangFei) {
this.battleBuffs.set(zhangFei.id, {
unitId: zhangFei.id,
label: '술',
turns: 2,
attackBonus: 3,
hitBonus: 6,
criticalBonus: 10
});
}
const guanYu = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0);
if (guanYu) {
this.applyBattleStatus(guanYu, 'burn', 2, 4);
}
const target = battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (target) {
this.applyBattleStatus(target, 'confusion', 1, 1);
}
}
private debugBattleSetupKey() {
if (!this.debugToolsEnabled() || typeof window === 'undefined') {
return undefined;
@@ -6650,6 +6704,14 @@ export class BattleScene extends Phaser.Scene {
});
}
}
this.unitStatuses(preview.attacker).forEach((status) => {
summaries.push({
icon: this.statusEffectIcon(status.kind),
label: status.label,
value: `${this.statusEffectShortText(status)} · ${status.turns}`,
tone: this.statusEffectTone(status.kind)
});
});
if (buff) {
summaries.push({
icon: 'focus',
@@ -8623,6 +8685,15 @@ export class BattleScene extends Phaser.Scene {
const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0);
defender.hp = Math.max(0, defender.hp - damage);
const statusApplied =
hit && defender.hp > 0 && usable?.statusEffect
? this.applyBattleStatus(
defender,
usable.statusEffect,
usable.statusDuration ?? usable.duration ?? 1,
usable.statusPower ?? this.defaultStatusPower(usable.statusEffect)
)
: undefined;
this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0);
this.faceUnitToward(attacker, defender);
if (hit) {
@@ -8651,7 +8722,8 @@ export class BattleScene extends Phaser.Scene {
attackerGrowth,
defenderGrowth: armorGrowth,
bondExp,
bondGrowth
bondGrowth,
statusApplied
};
}
@@ -8947,7 +9019,8 @@ export class BattleScene extends Phaser.Scene {
const critical = result.critical ? '치명 ' : '';
const defeated = result.defeated ? ' · 퇴각' : '';
return `${critical}피해 ${result.damage}${defeated}`;
const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : '';
return `${critical}피해 ${result.damage}${defeated}${status}`;
}
private combatResultHeadline(result: CombatResult) {
@@ -8961,7 +9034,8 @@ export class BattleScene extends Phaser.Scene {
: `${damage} 피해`;
const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}${result.defender.hp}` : '';
const defeated = result.defeated ? ' · 퇴각' : '';
return `${result.attacker.name} ${this.combatActionName(result)}${result.defender.name}: ${outcome}${hpLine}${defeated}`;
const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : '';
return `${result.attacker.name} ${this.combatActionName(result)}${result.defender.name}: ${outcome}${hpLine}${defeated}${status}`;
}
private combatResolutionLine(result: CombatResult) {
@@ -8969,7 +9043,8 @@ export class BattleScene extends Phaser.Scene {
const actual = this.combatDamageValue(result);
const verdict = result.hit ? `${successLabel} ${result.hitRate}% 통과` : `${successLabel} ${result.hitRate}% 실패`;
const critical = result.hit ? (result.critical ? '치명 발생' : `치명 ${result.criticalRate}% 미발동`) : '치명 없음';
return `판정: ${verdict} · ${critical} · 예측 ${result.expectedDamage} / 실제 ${actual}`;
const status = result.statusApplied ? ` · 상태 ${this.statusEffectMapText(result.statusApplied)} 적용` : '';
return `판정: ${verdict} · ${critical} · 예측 ${result.expectedDamage} / 실제 ${actual}${status}`;
}
private formatCombatOutcome(result: CombatResult) {
@@ -9744,6 +9819,7 @@ export class BattleScene extends Phaser.Scene {
})),
itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
battleStatuses: this.serializeBattleStatuses(),
battleStats: this.serializeBattleStats(),
triggeredBattleEvents: Array.from(this.triggeredBattleEvents)
};
@@ -9781,6 +9857,7 @@ export class BattleScene extends Phaser.Scene {
);
this.itemStocks = this.deserializeItemStocks(state.itemStocks);
this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }]));
this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses);
this.battleStats = this.deserializeBattleStats(state.battleStats);
this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []);
@@ -9851,6 +9928,20 @@ export class BattleScene extends Phaser.Scene {
);
}
private serializeBattleStatuses() {
return Array.from(this.battleStatuses.values()).flatMap((statuses) => statuses.map((status) => ({ ...status })));
}
private deserializeBattleStatuses(savedStatuses?: BattleStatusState[]) {
const statuses = new Map<string, BattleStatusState[]>();
(savedStatuses ?? []).forEach((status) => {
const unitStatuses = statuses.get(status.unitId) ?? [];
unitStatuses.push({ ...status });
statuses.set(status.unitId, unitStatuses);
});
return statuses;
}
private serializeBattleStats() {
return Object.fromEntries(Array.from(this.battleStats.entries()).map(([unitId, stats]) => [unitId, { ...stats }]));
}
@@ -9902,8 +9993,12 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
this.renderBattleSpeedControl();
this.updateObjectiveTracker();
const statusMessage = this.tickBattleStatuses('enemy');
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
return;
}
const recoveryMessage = this.applyTerrainRecovery('enemy');
this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
this.renderSituationPanel(['아군 턴을 종료했습니다.', statusMessage, recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n'));
void this.runEnemyTurn();
}
@@ -12366,6 +12461,10 @@ export class BattleScene extends Phaser.Scene {
}
this.turnNumber += 1;
const statusMessage = this.tickBattleStatuses('ally');
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
return;
}
const expiredBuffMessage = this.tickBattleBuffs();
this.activeFaction = 'ally';
this.fastForwardHeld = false;
@@ -12378,15 +12477,23 @@ export class BattleScene extends Phaser.Scene {
this.updateObjectiveTracker();
this.checkBattleEvents();
this.centerCameraOnAllyLine();
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n');
const turnMessage = [`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '행동할 장수를 선택하세요.']
.filter(Boolean)
.join('\n');
this.renderRosterPanel('ally', turnMessage);
const firstUnit = this.firstActionableAlly();
if (firstUnit) {
this.renderSituationPanel([`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, `다음 행동: ${firstUnit.name}`].filter(Boolean).join('\n'));
this.renderSituationPanel(
[`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, `다음 행동: ${firstUnit.name}`]
.filter(Boolean)
.join('\n')
);
this.focusActionableAlly(
firstUnit,
[`${this.turnNumber}턴 아군 차례입니다.`, expiredBuffMessage, recoveryMessage, '이동할 파란 칸을 선택하세요.'].filter(Boolean).join('\n')
[`${this.turnNumber}턴 아군 차례입니다.`, statusMessage, expiredBuffMessage, recoveryMessage, '이동할 파란 칸을 선택하세요.']
.filter(Boolean)
.join('\n')
);
}
}
@@ -12861,15 +12968,144 @@ export class BattleScene extends Phaser.Scene {
}
private attackBuff(unit: UnitData) {
return this.battleBuffs.get(unit.id)?.attackBonus ?? 0;
return (this.battleBuffs.get(unit.id)?.attackBonus ?? 0) - this.statusAttackPenalty(unit);
}
private hitBuff(unit: UnitData) {
return this.battleBuffs.get(unit.id)?.hitBonus ?? 0;
return (this.battleBuffs.get(unit.id)?.hitBonus ?? 0) - this.statusHitPenalty(unit);
}
private criticalBuff(unit: UnitData) {
return this.battleBuffs.get(unit.id)?.criticalBonus ?? 0;
return (this.battleBuffs.get(unit.id)?.criticalBonus ?? 0) - this.statusCriticalPenalty(unit);
}
private unitStatuses(unit: UnitData) {
return this.battleStatuses.get(unit.id) ?? [];
}
private hasBattleStatus(unit: UnitData, kind: BattleStatusKind) {
return this.unitStatuses(unit).some((status) => status.kind === kind && status.turns > 0);
}
private statusAttackPenalty(unit: UnitData) {
return this.hasBattleStatus(unit, 'confusion') ? 2 : 0;
}
private statusHitPenalty(unit: UnitData) {
return this.hasBattleStatus(unit, 'confusion') ? 12 : 0;
}
private statusCriticalPenalty(unit: UnitData) {
return this.hasBattleStatus(unit, 'confusion') ? 6 : 0;
}
private statusEffectLabel(kind: BattleStatusKind) {
return kind === 'burn' ? '화상' : '혼란';
}
private statusEffectIcon(kind: BattleStatusKind): BattleUiIconKey {
return kind === 'burn' ? 'fire' : 'confusion';
}
private statusEffectTone(kind: BattleStatusKind) {
return kind === 'burn' ? 0xd8732c : 0xb36dff;
}
private statusEffectShortText(status: BattleStatusState) {
if (status.kind === 'burn') {
return `지속 -${status.power}`;
}
return '공-2 명-12 치-6';
}
private statusEffectMapText(status?: BattleStatusState) {
return status ? `${status.label} ${status.turns}` : '';
}
private defaultStatusPower(kind: BattleStatusKind) {
return kind === 'burn' ? 4 : 1;
}
private applyBattleStatus(unit: UnitData, kind: BattleStatusKind, turns = 1, power = this.defaultStatusPower(kind)) {
const label = this.statusEffectLabel(kind);
const unitStatuses = [...this.unitStatuses(unit)];
const existing = unitStatuses.find((status) => status.kind === kind);
const nextStatus: BattleStatusState = {
unitId: unit.id,
kind,
label,
turns: Math.max(existing?.turns ?? 0, turns),
power: Math.max(existing?.power ?? 0, power)
};
if (existing) {
existing.turns = nextStatus.turns;
existing.power = nextStatus.power;
existing.label = nextStatus.label;
} else {
unitStatuses.push(nextStatus);
}
this.battleStatuses.set(unit.id, unitStatuses);
return { ...nextStatus };
}
private tickBattleStatuses(faction: ActiveFaction) {
const messages: string[] = [];
const units = battleUnits.filter((unit) => unit.faction === faction && unit.hp > 0);
units.forEach((unit) => {
const statuses = this.unitStatuses(unit);
if (statuses.length === 0) {
return;
}
const remaining: BattleStatusState[] = [];
for (const status of statuses) {
if (status.kind === 'burn' && unit.hp > 0) {
const previousHp = unit.hp;
const damage = Math.min(unit.hp, Math.max(1, status.power));
unit.hp = Math.max(0, unit.hp - damage);
messages.push(`${unit.name} ${status.label}: 지속 피해 ${damage} · HP ${previousHp}${unit.hp}`);
if (unit.hp <= 0) {
this.applyDefeatedStyle(unit);
} else {
this.syncUnitMotion(unit);
}
}
if (unit.hp <= 0) {
messages.push(`${unit.name} 전투 불능`);
break;
}
const nextTurns = status.turns - 1;
if (nextTurns > 0 && unit.hp > 0) {
remaining.push({ ...status, turns: nextTurns });
} else {
messages.push(`${unit.name} ${status.label} 해제`);
}
if (unit.hp <= 0) {
break;
}
}
if (remaining.length > 0) {
this.battleStatuses.set(unit.id, remaining);
} else {
this.battleStatuses.delete(unit.id);
}
});
if (messages.length === 0) {
return undefined;
}
this.updateMiniMap();
const message = `상태효과 변화\n${messages.slice(0, 4).join('\n')}${messages.length > 4 ? `\n외 ${messages.length - 4}` : ''}`;
this.pushBattleLog(message);
return message;
}
private tickBattleBuffs() {
@@ -13440,6 +13676,8 @@ export class BattleScene extends Phaser.Scene {
}
private applyDefeatedStyle(unit: UnitData, view = this.unitViews.get(unit.id)) {
this.battleBuffs.delete(unit.id);
this.battleStatuses.delete(unit.id);
if (!view) {
return;
}
@@ -13536,13 +13774,19 @@ export class BattleScene extends Phaser.Scene {
return;
}
const lines = [
this.combatMapDamageTitle(result),
`HP ${result.previousDefenderHp}${result.defender.hp}`,
this.statusEffectMapText(result.statusApplied)
].filter(Boolean);
this.showMapResultPopup(
result.defender,
[this.combatMapDamageTitle(result), `HP ${result.previousDefenderHp}${result.defender.hp}`],
lines,
result.critical ? '#ff8f5f' : '#ffdf7b',
'#220909',
result.critical ? 20 : 18,
result.critical ? 34 : 30
lines.length > 2 ? 38 : result.critical ? 34 : 30
);
}
@@ -13746,6 +13990,12 @@ export class BattleScene extends Phaser.Scene {
private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } {
const { text } = this.battleLogDisplayParts(entry);
if (text.includes('화상') || text.includes('지속 피해')) {
return { icon: 'fire', tone: 0xd8732c };
}
if (text.includes('혼란') || text.includes('위압')) {
return { icon: 'confusion', tone: 0xb36dff };
}
if (text.includes('반격')) {
return { icon: 'counter', tone: 0xb64a45 };
}
@@ -13764,7 +14014,7 @@ export class BattleScene extends Phaser.Scene {
if (text.includes('회복') || text.includes('병력') || text.includes('거점')) {
return { icon: 'heal', tone: 0x59d18c };
}
if (text.includes('강화') || text.includes('상태효과')) {
if (text.includes('강화') || text.includes('상태효과') || text.includes('해제') || text.includes('만료')) {
return { icon: 'focus', tone: palette.gold };
}
if (text.includes('이동') || text.includes('접근')) {
@@ -14416,7 +14666,7 @@ export class BattleScene extends Phaser.Scene {
}));
const effectTop = top + (hasEffects ? 302 : 326);
const equipmentTop = hasEffects ? top + 350 : effectTop;
const equipmentTop = hasEffects ? top + 360 : effectTop;
if (hasEffects) {
this.renderUnitEffectCards(effects, left, effectTop, width);
}
@@ -14441,13 +14691,24 @@ export class BattleScene extends Phaser.Scene {
terrainRule.moraleBonus ? `사기 +${terrainRule.moraleBonus}` : ''
].filter(Boolean);
this.unitStatuses(unit).forEach((status) => {
effects.push({
icon: this.statusEffectIcon(status.kind),
label: status.label,
value: this.statusEffectShortText(status),
tone: this.statusEffectTone(status.kind),
turns: status.turns,
polarity: 'debuff'
});
});
if (buff) {
effects.push({
icon: 'focus',
label: buff.label,
value: this.battleBuffShortText(buff),
value: this.supportBuffCompactText(buff),
tone: palette.gold,
turns: buff.turns
turns: buff.turns,
polarity: 'buff'
});
}
if (turnStartParts.length > 0) {
@@ -14455,7 +14716,8 @@ export class BattleScene extends Phaser.Scene {
icon: 'terrain',
label: `${terrainRule.label} 턴 시작`,
value: turnStartParts.join(' / '),
tone: 0x59d18c
tone: 0x59d18c,
polarity: 'terrain'
});
}
@@ -14463,38 +14725,40 @@ export class BattleScene extends Phaser.Scene {
}
private renderUnitEffectCards(effects: UnitEffectSummary[], x: number, y: number, width: number) {
const visible = effects.slice(0, 2);
const visible = effects.slice(0, 3);
const gap = 6;
const cardWidth = (width - gap) / 2;
const cardHeight = 50;
const cardWidth = (width - gap * (visible.length - 1)) / Math.max(1, visible.length);
visible.forEach((effect, index) => {
const cardX = x + index * (cardWidth + gap);
const bg = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, 42, 0x101820, 0.97));
const fill = effect.polarity === 'debuff' ? 0x1d1516 : effect.polarity === 'terrain' ? 0x101d18 : 0x101820;
const bg = this.trackSideObject(this.add.rectangle(cardX, y, cardWidth, cardHeight, fill, 0.98));
bg.setOrigin(0);
bg.setStrokeStyle(1, effect.tone, 0.62);
const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 20, y + 21, 32, 32, 0x0a0f14, 0.92));
bg.setStrokeStyle(1, effect.tone, effect.polarity === 'debuff' ? 0.78 : 0.62);
const iconFrame = this.trackSideObject(this.add.rectangle(cardX + 23, y + 25, 36, 36, 0x0a0f14, 0.94));
iconFrame.setStrokeStyle(1, effect.tone, 0.56);
this.trackSideIcon(cardX + 20, y + 21, effect.icon, 30);
this.trackSideObject(this.add.text(cardX + 42, y + 4, effect.label, {
this.trackSideIcon(cardX + 23, y + 25, effect.icon, 34);
this.trackSideObject(this.add.text(cardX + 47, y + 6, effect.label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: '#9fb0bf',
fontSize: '11px',
color: effect.polarity === 'debuff' ? '#ffb9a8' : '#9fb0bf',
fontStyle: '700',
fixedWidth: cardWidth - 52
fixedWidth: Math.max(36, cardWidth - 56)
}));
this.trackSideObject(this.add.text(cardX + 42, y + 20, this.truncateUiText(effect.value, 15), {
this.trackSideObject(this.add.text(cardX + 47, y + 25, this.truncateUiText(effect.value, 15), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
fontSize: '13px',
color: '#f2e3bf',
fontStyle: '700',
fixedWidth: cardWidth - 52
fixedWidth: Math.max(36, cardWidth - 56)
}));
if (effect.turns) {
const badge = this.trackSideObject(this.add.rectangle(cardX + cardWidth - 18, y + 14, 26, 18, 0x211624, 0.96));
const badge = this.trackSideObject(this.add.rectangle(cardX + cardWidth - 20, y + 16, 30, 19, 0x211624, 0.96));
badge.setStrokeStyle(1, effect.tone, 0.78);
const turnText = this.trackSideObject(this.add.text(cardX + cardWidth - 18, y + 14, `${effect.turns}`, {
const turnText = this.trackSideObject(this.add.text(cardX + cardWidth - 20, y + 16, `${effect.turns}T`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '11px',
fontSize: '10px',
color: '#fff2b8',
fontStyle: '700'
}));
@@ -14909,6 +15173,7 @@ export class BattleScene extends Phaser.Scene {
audio: soundDirector.getDebugState(),
itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
battleStatuses: this.serializeBattleStatuses(),
treasureEffectPreviews: this.debugTreasureEffectPreviews(),
combatMechanicsProbe: this.debugCombatMechanicsProbe(),
units: battleUnits.map((unit) => ({