diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 93d3e7f..f1a31b1 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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>; battleBuffs?: BattleBuffState[]; + battleStatuses?: BattleStatusState[]; battleStats?: Record; triggeredBattleEvents?: string[]; }; @@ -1521,6 +1537,9 @@ const usableCatalog: Record = { power: 12, accuracyBonus: 6, criticalBonus: 0, + statusEffect: 'burn', + statusDuration: 2, + statusPower: 4, description: '적 한 부대에 책략 피해를 준다.' }, roar: { @@ -1533,6 +1552,9 @@ const usableCatalog: Record = { 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(); private itemStocks = new Map>(); private battleBuffs = new Map(); + private battleStatuses = new Map(); private attackIntents: AttackIntent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); @@ -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(); + (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) => ({