From 7135ac8e551bffecdb0c28b63527cca0d2581169 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 16:38:33 +0900 Subject: [PATCH] Add first battle objectives and rewards --- src/game/scenes/BattleScene.ts | 383 ++++++++++++++++++++++++++++++--- 1 file changed, 359 insertions(+), 24 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 791c03f..4982560 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -128,6 +128,22 @@ type BondState = BattleBond & { battleExp: number; }; +type UnitBattleStats = { + damageDealt: number; + damageTaken: number; + defeats: number; + actions: number; + support: number; +}; + +type BattleObjectiveResult = { + id: string; + label: string; + achieved: boolean; + detail: string; + rewardGold: number; +}; + type EquipmentGrowthResult = { slot: EquipmentSlot; itemName: string; @@ -264,6 +280,8 @@ type BattleSaveState = { bonds: BondState[]; itemStocks?: Record>; battleBuffs?: BattleBuffState[]; + battleStats?: Record; + triggeredBattleEvents?: string[]; }; const maxEquipmentLevel = 9; @@ -395,6 +413,9 @@ const defaultEnemyAiByClass: Partial> = { }; const guardDetectionRange = 5; +const leaderUnitId = 'rebel-leader'; +const quickVictoryTurnLimit = 8; +const baseVictoryGold = 300; const commandLabels: Record = { attack: '공격', @@ -474,6 +495,7 @@ export class BattleScene extends Phaser.Scene { private mapMenuObjects: Array = []; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; + private battleEventObjects: Phaser.GameObjects.GameObject[] = []; private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private resultObjects: Phaser.GameObjects.GameObject[] = []; @@ -485,6 +507,8 @@ export class BattleScene extends Phaser.Scene { private attackIntents: AttackIntent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); + private battleStats = new Map(); + private triggeredBattleEvents = new Set(); private pendingMove?: PendingMove; private battleOutcome?: BattleOutcome; private debugOverlay?: Phaser.GameObjects.Text; @@ -517,6 +541,8 @@ export class BattleScene extends Phaser.Scene { this.itemStocks = this.createItemStocks(); this.battleBuffs.clear(); this.enemyHomeTiles = this.createEnemyHomeTiles(); + this.battleStats = this.createBattleStats(); + this.triggeredBattleEvents.clear(); this.battleLog = []; this.actedUnitIds.clear(); this.attackIntents = []; @@ -545,6 +571,7 @@ export class BattleScene extends Phaser.Scene { this.drawUnits(); this.drawSidePanel(); this.updateTurnText(); + this.time.delayedCall(180, () => this.showOpeningBattleEvent()); this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } @@ -1561,6 +1588,10 @@ export class BattleScene extends Phaser.Scene { return; } + this.triggerBattleEvent('first-engagement', '첫 교전', [ + `${attacker.name}이 ${target.name}을 공격합니다.`, + '공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.' + ]); this.phase = 'animating'; const result = this.resolveCombatAction(attacker, target, action, false, usable); this.clearMarkers(); @@ -1629,6 +1660,7 @@ export class BattleScene extends Phaser.Scene { this.applyActedStyle(unit); soundDirector.playSelect(); this.pushBattleLog(message); + this.checkBattleEvents(); if (this.resolveBattleOutcomeIfNeeded()) { return; @@ -1657,6 +1689,12 @@ export class BattleScene extends Phaser.Scene { return true; } + const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); + if (leader && leader.hp <= 0) { + this.completeBattle('victory'); + return true; + } + const enemiesAlive = firstBattleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!enemiesAlive) { this.completeBattle('victory'); @@ -1682,6 +1720,7 @@ export class BattleScene extends Phaser.Scene { this.hideMapMenu(); this.hideTurnEndPrompt(); this.hideBattleAlert(); + this.hideBattleEventBanner(); const message = outcome === 'victory' @@ -1700,14 +1739,15 @@ export class BattleScene extends Phaser.Scene { this.hideBattleResult(); const depth = 120; - const panelWidth = 980; - const panelHeight = 620; + const panelWidth = 1040; + const panelHeight = 660; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length; const aliveAllies = allies.filter((unit) => unit.hp > 0).length; + const objectives = this.resultObjectives(outcome); const shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68)); shade.setOrigin(0); @@ -1744,14 +1784,17 @@ export class BattleScene extends Phaser.Scene { subtitle.setDepth(depth + 2); const metricTop = top + 112; - const metricWidth = 210; - const metricGap = 18; + const metricWidth = 226; + const metricGap = 16; this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); this.renderResultMetric('생존 아군', `${aliveAllies} / ${allies.length}`, left + 44 + (metricWidth + metricGap), metricTop, metricWidth, depth + 2); this.renderResultMetric('격파 적군', `${defeatedEnemies} / ${totalEnemies}`, left + 44 + (metricWidth + metricGap) * 2, metricTop, metricWidth, depth + 2); - this.renderResultMetric('전투 보상', outcome === 'victory' ? '군자금 300' : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); + this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2); - const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 184, '장수 성장', { + this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2); + this.renderResultRewardPanel(outcome, objectives, left + 526, top + 178, 470, depth + 2); + + const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 332, '장수 성장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f2e3bf', @@ -1760,33 +1803,20 @@ export class BattleScene extends Phaser.Scene { unitTitle.setDepth(depth + 2); allies.forEach((unit, index) => { - this.renderResultUnitRow(unit, left + 44, top + 220 + index * 72, panelWidth - 88, depth + 2); - }); - - const bondTitleTop = top + 452; - const bondTitle = this.trackResultObject(this.add.text(left + 44, bondTitleTop, '공명 변화', { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '21px', - color: '#f2e3bf', - fontStyle: '700' - })); - bondTitle.setDepth(depth + 2); - - Array.from(this.bondStates.values()).forEach((bond, index) => { - this.renderResultBondRow(bond, left + 44 + index * 278, bondTitleTop + 38, 258, depth + 2); + this.renderResultUnitRow(unit, left + 44, top + 366 + index * 64, panelWidth - 88, depth + 2); }); if (outcome === 'victory') { - this.addResultButton('이야기 계속', left + panelWidth - 422, top + 572, 132, () => { + this.addResultButton('이야기 계속', left + panelWidth - 422, top + 612, 132, () => { soundDirector.playSelect(); this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); }, depth + 3); } - this.addResultButton('다시 하기', left + panelWidth - 276, top + 572, 124, () => { + this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); this.scene.restart(); }, depth + 3); - this.addResultButton('타이틀로', left + panelWidth - 140, top + 572, 124, () => { + this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); this.scene.start('TitleScene'); }, depth + 3); @@ -1797,6 +1827,141 @@ export class BattleScene extends Phaser.Scene { this.resultObjects = []; } + private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] { + const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); + const leaderDefeated = !leader || leader.hp <= 0; + const allAlliesSurvived = firstBattleUnits.filter((unit) => unit.faction === 'ally').every((unit) => unit.hp > 0); + const villageSecured = !this.hasEnemyOnVillageTile(); + const quickVictory = outcome === 'victory' && this.turnNumber <= quickVictoryTurnLimit; + + return [ + { + id: 'leader', + label: '황건 두령 격파', + achieved: outcome === 'victory' && leaderDefeated, + detail: leaderDefeated ? '두령 퇴각' : '두령 생존', + rewardGold: 200 + }, + { + id: 'liu-bei', + label: '유비 생존', + achieved: allAlliesSurvived || firstBattleUnits.some((unit) => unit.id === 'liu-bei' && unit.hp > 0), + detail: allAlliesSurvived ? '삼형제 생존' : '유비 생존', + rewardGold: 100 + }, + { + id: 'village', + label: '마을 확보', + achieved: outcome === 'victory' && villageSecured, + detail: villageSecured ? '적 점거 없음' : '적 잔존', + rewardGold: 150 + }, + { + id: 'quick', + label: `${quickVictoryTurnLimit}턴 안에 승리`, + achieved: quickVictory, + detail: `${this.turnNumber}턴 종료`, + rewardGold: 120 + } + ]; + } + + private resultGold(outcome: BattleOutcome) { + if (outcome !== 'victory') { + return 0; + } + + return baseVictoryGold + this.resultObjectives(outcome).reduce((total, objective) => total + (objective.achieved ? objective.rewardGold : 0), 0); + } + + private resultMvp() { + return firstBattleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => { + const stats = this.statsFor(unit.id); + const score = stats.damageDealt + stats.defeats * 18 + Math.floor(stats.support * 0.7) + Math.floor(stats.damageTaken * 0.25) + (unit.hp > 0 ? 8 : 0); + return { unit, stats, score }; + }) + .sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0]; + } + + private renderResultObjectivePanel(objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) { + const height = 132; + const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.blue, 0.56); + + const title = this.trackResultObject(this.add.text(x + 14, y + 10, '전투 목표', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#f2e3bf', + fontStyle: '700' + })); + title.setDepth(depth + 1); + + objectives.forEach((objective, index) => { + const rowY = y + 42 + index * 21; + const mark = objective.achieved ? '완료' : '미달'; + const color = objective.achieved ? '#a8ffd0' : '#aeb7c2'; + const line = this.trackResultObject(this.add.text(x + 16, rowY, `${mark} ${objective.label} ${objective.detail}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color + })); + line.setDepth(depth + 1); + + const reward = this.trackResultObject(this.add.text(x + width - 16, rowY, objective.achieved ? `+${objective.rewardGold}` : '-', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: objective.achieved ? '#d8b15f' : '#6f7a84', + fontStyle: '700' + })); + reward.setOrigin(1, 0); + reward.setDepth(depth + 1); + }); + } + + private renderResultRewardPanel(outcome: BattleOutcome, objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) { + const height = 132; + const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.56); + + const title = this.trackResultObject(this.add.text(x + 14, y + 10, '보상 정산', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#f2e3bf', + fontStyle: '700' + })); + title.setDepth(depth + 1); + + const mvp = this.resultMvp(); + const bondSummary = Array.from(this.bondStates.values()) + .filter((bond) => bond.battleExp > 0) + .map((bond) => `${this.unitName(bond.unitIds[0])}·${this.unitName(bond.unitIds[1])} +${bond.battleExp}`) + .join(' '); + const achievedCount = objectives.filter((objective) => objective.achieved).length; + const lines = [ + outcome === 'victory' ? `군자금 ${this.resultGold(outcome)} 목표 ${achievedCount}/${objectives.length}` : '패배: 보상 없음', + mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음', + bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음', + outcome === 'victory' ? '전리품: 콩 1, 탁주 1, 의용군 명성 +1' : '재도전하면 목표 보상을 다시 노릴 수 있습니다.' + ]; + + lines.forEach((line, index) => { + const text = this.trackResultObject(this.add.text(x + 16, y + 42 + index * 21, line, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: index === 0 ? '#d8b15f' : '#d4dce6', + fontStyle: index === 0 ? '700' : '400', + wordWrap: { width: width - 32, useAdvancedWrap: true } + })); + text.setDepth(depth + 1); + }); + } + private renderResultMetric(label: string, value: string, x: number, y: number, width: number, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 48, 0x16212d, 0.94)); bg.setOrigin(0); @@ -2065,6 +2230,35 @@ export class BattleScene extends Phaser.Scene { return this.resolveCombatAction(attacker, defender, 'attack'); } + private statsFor(unitId: string) { + const existing = this.battleStats.get(unitId); + if (existing) { + return existing; + } + + const created = this.emptyBattleStats(); + this.battleStats.set(unitId, created); + return created; + } + + private recordCombatStats(attacker: UnitData, defender: UnitData, damage: number, defeated: boolean) { + const attackerStats = this.statsFor(attacker.id); + attackerStats.actions += 1; + attackerStats.damageDealt += damage; + if (defeated) { + attackerStats.defeats += 1; + } + + const defenderStats = this.statsFor(defender.id); + defenderStats.damageTaken += damage; + } + + private recordSupportStats(user: UnitData, amount: number) { + const stats = this.statsFor(user.id); + stats.actions += 1; + stats.support += amount; + } + private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { const preview = this.combatPreview(attacker, defender, action, usable); const previousDefenderHp = defender.hp; @@ -2078,6 +2272,7 @@ export class BattleScene extends Phaser.Scene { const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0); defender.hp = Math.max(0, defender.hp - damage); + this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0); this.faceUnitToward(attacker, defender); if (hit) { this.flashDamage(defender, damage, critical); @@ -2163,6 +2358,7 @@ export class BattleScene extends Phaser.Scene { const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', usable.command === 'strategy' ? 7 : 5); const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); + this.recordSupportStats(user, healAmount); return { usable, @@ -2320,6 +2516,115 @@ export class BattleScene extends Phaser.Scene { ); } + private createBattleStats() { + return new Map(firstBattleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const)); + } + + private emptyBattleStats(): UnitBattleStats { + return { + damageDealt: 0, + damageTaken: 0, + defeats: 0, + actions: 0, + support: 0 + }; + } + + private showOpeningBattleEvent() { + this.triggerBattleEvent('opening', '첫 전투 목표', [ + '황건 두령을 격파하면 승리합니다.', + '유비가 퇴각하면 패배합니다.', + '마을을 확보하고 빠르게 승리하면 추가 보상이 붙습니다.' + ]); + } + + private triggerBattleEvent(key: string, title: string, lines: string[]) { + if (this.triggeredBattleEvents.has(key) || this.battleOutcome) { + return; + } + + this.triggeredBattleEvents.add(key); + const message = `${title}\n${lines.join('\n')}`; + this.pushBattleLog(message); + this.renderSituationPanel(message); + this.showBattleEventBanner(title, lines); + } + + private showBattleEventBanner(title: string, lines: string[]) { + this.hideBattleEventBanner(); + + const width = 520; + const height = 106; + const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); + const top = this.layout.gridY + 18; + const depth = 45; + const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.94); + panel.setOrigin(0); + panel.setDepth(depth); + panel.setStrokeStyle(2, palette.gold, 0.86); + this.battleEventObjects.push(panel); + + const titleText = this.add.text(left + 20, top + 14, title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f4dfad', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + }); + titleText.setDepth(depth + 1); + this.battleEventObjects.push(titleText); + + const body = this.add.text(left + 20, top + 45, lines.join('\n'), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d4dce6', + lineSpacing: 4, + wordWrap: { width: width - 40, useAdvancedWrap: true } + }); + body.setDepth(depth + 1); + this.battleEventObjects.push(body); + + const eventObjects = [...this.battleEventObjects]; + this.tweens.add({ + targets: eventObjects, + alpha: 0, + delay: 2600, + duration: 420, + ease: 'Sine.easeIn', + onComplete: () => { + eventObjects.forEach((object) => object.destroy()); + this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); + } + }); + } + + private hideBattleEventBanner() { + this.battleEventObjects.forEach((object) => object.destroy()); + this.battleEventObjects = []; + } + + private checkBattleEvents() { + const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); + if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) { + this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); + } + + if (!this.hasEnemyOnVillageTile()) { + this.triggerBattleEvent('village-secured', '마을 확보', ['마을 주변의 황건적을 몰아냈습니다.', '승리 시 마을 확보 보상이 추가됩니다.']); + } + + if (this.turnNumber === 2 && this.activeFaction === 'ally') { + this.triggerBattleEvent('enemy-posture', '적 전술 변화', ['기병은 계속 접근하고, 궁병은 자리를 지키며 사격합니다.', '숲과 마을 지형을 이용해 피해를 줄이세요.']); + } + } + + private hasEnemyOnVillageTile() { + return firstBattleUnits.some((unit) => { + return unit.faction === 'enemy' && unit.hp > 0 && firstBattleMap.terrain[unit.y][unit.x] === 'village'; + }); + } + private handleRightClick(pointer: Phaser.Input.Pointer) { if (this.battleOutcome) { return; @@ -2560,7 +2865,9 @@ export class BattleScene extends Phaser.Scene { unitIds: [...bond.unitIds] as [string, string] })), itemStocks: this.serializeItemStocks(), - battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })) + battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), + battleStats: this.serializeBattleStats(), + triggeredBattleEvents: Array.from(this.triggeredBattleEvents) }; } @@ -2594,6 +2901,8 @@ export class BattleScene extends Phaser.Scene { ); this.itemStocks = this.deserializeItemStocks(state.itemStocks); this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }])); + this.battleStats = this.deserializeBattleStats(state.battleStats); + this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); state.units.forEach((savedUnit) => { const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id); @@ -2662,6 +2971,28 @@ export class BattleScene extends Phaser.Scene { ); } + private serializeBattleStats() { + return Object.fromEntries(Array.from(this.battleStats.entries()).map(([unitId, stats]) => [unitId, { ...stats }])); + } + + private deserializeBattleStats(savedStats?: Record) { + const stats = this.createBattleStats(); + if (!savedStats) { + return stats; + } + + Object.entries(savedStats).forEach(([unitId, value]) => { + stats.set(unitId, { + damageDealt: value.damageDealt ?? 0, + damageTaken: value.damageTaken ?? 0, + defeats: value.defeats ?? 0, + actions: value.actions ?? 0, + support: value.support ?? 0 + }); + }); + return stats; + } + private formatSavedAt(savedAt: string) { return new Date(savedAt).toLocaleString('ko-KR'); } @@ -3758,6 +4089,7 @@ export class BattleScene extends Phaser.Scene { const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); + this.checkBattleEvents(); this.renderRosterPanel('ally', [`${this.turnNumber}턴 아군 차례입니다.`, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n')); } @@ -5025,6 +5357,9 @@ export class BattleScene extends Phaser.Scene { actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), battleLog: [...this.battleLog], + objectives: this.resultObjectives(this.battleOutcome ?? 'victory').map((objective) => ({ ...objective })), + battleStats: this.serializeBattleStats(), + triggeredBattleEvents: Array.from(this.triggeredBattleEvents), selectedUsable: this.selectedUsable?.id ?? null, itemStocks: this.serializeItemStocks(), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),