From e063154e26b5e91e952bac7e769eb82f546e0ec4 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 19:00:35 +0900 Subject: [PATCH] Add first battle objective tracking --- scripts/verify-flow.mjs | 13 ++ src/game/data/battles.ts | 10 ++ src/game/scenes/BattleScene.ts | 285 +++++++++++++++++++++++++-------- 3 files changed, 240 insertions(+), 68 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index cd19b40..5009b31 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -80,6 +80,15 @@ try { throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`); } + if (result.battleState.victoryConditionLabel !== '두령 한석 격파' || result.battleState.defeatConditionLabel !== '유비 퇴각') { + throw new Error(`Expected first battle objective labels: ${JSON.stringify(result.battleState)}`); + } + + const openingLeaderObjective = result.battleState.objectives.find((objective) => objective.id === 'leader'); + if (!openingLeaderObjective || openingLeaderObjective.status !== 'active' || openingLeaderObjective.achieved) { + throw new Error(`Expected live leader objective to start active: ${JSON.stringify(result.battleState.objectives)}`); + } + const actionTexturesLoaded = await page.evaluate(() => { const textures = window.__HEROS_GAME__?.textures; return [ @@ -165,6 +174,10 @@ try { if (livingEnemiesAfterVictory.length > 0) { throw new Error(`Expected no living enemies after forced victory: ${JSON.stringify(livingEnemiesAfterVictory)}`); } + const victoryLeaderObjective = victoryState.objectives.find((objective) => objective.id === 'leader'); + if (!victoryLeaderObjective || victoryLeaderObjective.status !== 'done' || !victoryLeaderObjective.achieved) { + throw new Error(`Expected leader objective to resolve after victory: ${JSON.stringify(victoryState.objectives)}`); + } await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index b0c3697..bb9c982 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -31,6 +31,9 @@ export type BattleDefeatConditionDefinition = { export type BattleScenarioDefinition = { id: BattleScenarioId; title: string; + victoryConditionLabel: string; + defeatConditionLabel: string; + openingObjectiveLines: string[]; map: BattleMap; units: UnitData[]; bonds: BattleBond[]; @@ -48,6 +51,13 @@ export type BattleScenarioDefinition = { export const firstBattleScenario: BattleScenarioDefinition = { id: 'first-battle-zhuo-commandery', title: '탁현의 전투', + victoryConditionLabel: '두령 한석 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '두령 한석을 격파하면 황건적의 전열이 무너집니다.', + '유비가 퇴각하면 의용군은 전투를 지속할 수 없습니다.', + '마을을 확보하고 8턴 안에 승리하면 추가 보상이 붙습니다.' + ], map: firstBattleMap, units: firstBattleUnits, bonds: firstBattleBonds, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7eba73a..5b1e322 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; -import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles'; +import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinition, type BattleScenarioDefinition } from '../data/battles'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { equipmentExpToNext, @@ -152,6 +152,10 @@ type BattleObjectiveResult = { rewardGold: number; }; +type BattleObjectiveState = BattleObjectiveResult & { + status: 'active' | 'done' | 'failed'; +}; + type EquipmentGrowthResult = { slot: EquipmentSlot; itemName: string; @@ -515,6 +519,8 @@ export class BattleScene extends Phaser.Scene { private targetingAction?: DamageCommand; private selectedUsable?: BattleUsable; private turnText?: Phaser.GameObjects.Text; + private objectiveTrackerText?: Phaser.GameObjects.Text; + private objectiveTrackerSubText?: Phaser.GameObjects.Text; private markers: Phaser.GameObjects.Rectangle[] = []; private rosterTab: RosterTab = 'ally'; private sidePanelObjects: Phaser.GameObjects.GameObject[] = []; @@ -734,28 +740,36 @@ export class BattleScene extends Phaser.Scene { this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); this.add.rectangle(panelX, panelY, 3, panelHeight, palette.gold, 0.82).setOrigin(0); - this.add.text(panelX + 24, panelY + 28, '첫 전투', { + this.add.text(panelX + 24, panelY + 24, battleScenario.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '30px', + fontSize: '28px', color: '#e8dfca', fontStyle: '700', padding: { top: 4, bottom: 4 } }); - this.turnText = this.add.text(panelX + 24, panelY + 88, '1턴 / 아군', { + this.turnText = this.add.text(panelX + 24, panelY + 76, '1턴 / 아군', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#d8b15f' }); - this.add.text(panelX + 24, panelY + panelHeight - 196, '승리: 적 전멸 패배: 유비 퇴각', { + this.objectiveTrackerText = this.add.text(panelX + 24, panelY + 108, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '15px', - color: '#9aa3ad' + fontSize: '13px', + color: '#d4dce6', + wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } + }); + this.objectiveTrackerSubText = this.add.text(panelX + 24, panelY + 126, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#9aa3ad', + wordWrap: { width: panelWidth - 48, useAdvancedWrap: true } }); this.add.text(panelX + 24, panelY + panelHeight - 172, '가장자리 커서 / 미니맵 클릭으로 시야 이동', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#9aa3ad' }); + this.updateObjectiveTracker(); this.drawMiniMap(); } @@ -1699,6 +1713,7 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); this.pushBattleLog(message); this.checkBattleEvents(); + this.updateObjectiveTracker(); if (this.resolveBattleOutcomeIfNeeded()) { return; @@ -1761,10 +1776,11 @@ export class BattleScene extends Phaser.Scene { this.hideTurnEndPrompt(); this.hideBattleAlert(); this.hideBattleEventBanner(); + this.updateObjectiveTracker(); const message = outcome === 'victory' - ? '승리! 황건적을 모두 물리쳤습니다.' + ? `승리! ${battleScenario.victoryConditionLabel}에 성공했습니다.` : '패배... 유비가 패주했습니다.'; this.pushBattleLog(message); this.renderSituationPanel(message); @@ -1869,53 +1885,73 @@ export class BattleScene extends Phaser.Scene { } private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] { - return battleScenario.objectives.map((objective) => { - if (objective.kind === 'defeat-leader') { - const targetId = objective.unitId ?? leaderUnitId; - const defeated = this.isUnitDefeated(targetId); - return { - id: objective.id, - label: objective.label, - achieved: outcome === 'victory' && defeated, - detail: defeated ? `${this.unitName(targetId)} 격파` : `${this.unitName(targetId)} 생존`, - rewardGold: objective.rewardGold - }; - } + return this.objectiveStates(outcome).map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: objective.achieved, + detail: objective.detail, + rewardGold: objective.rewardGold + })); + } - if (objective.kind === 'keep-unit-alive') { - const targetId = objective.unitId ?? 'liu-bei'; - const alive = this.isUnitAlive(targetId); - return { - id: objective.id, - label: objective.label, - achieved: alive, - detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`, - rewardGold: objective.rewardGold - }; - } + private objectiveStates(outcome?: BattleOutcome): BattleObjectiveState[] { + return battleScenario.objectives.map((objective) => this.objectiveState(objective, outcome)); + } - if (objective.kind === 'secure-terrain') { - const terrain = objective.terrain ?? 'village'; - const secured = !this.hasEnemyOnTerrain(terrain); - return { - id: objective.id, - label: objective.label, - achieved: outcome === 'victory' && secured, - detail: secured ? '점거한 적 없음' : '적이 점거 중', - rewardGold: objective.rewardGold - }; - } - - const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit; - const achieved = outcome === 'victory' && this.turnNumber <= maxTurn; + private objectiveState(objective: BattleObjectiveDefinition, outcome?: BattleOutcome): BattleObjectiveState { + if (objective.kind === 'defeat-leader') { + const targetId = objective.unitId ?? leaderUnitId; + const target = battleUnits.find((unit) => unit.id === targetId); + const defeated = this.isUnitDefeated(targetId); + const achieved = outcome ? outcome === 'victory' && defeated : defeated; return { id: objective.id, label: objective.label, achieved, - detail: `${this.turnNumber}턴 종료`, + status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', + detail: defeated ? `${this.unitName(targetId)} 격파` : `${this.unitName(targetId)} 병력 ${target?.hp ?? 0}/${target?.maxHp ?? 0}`, rewardGold: objective.rewardGold }; - }); + } + + if (objective.kind === 'keep-unit-alive') { + const targetId = objective.unitId ?? 'liu-bei'; + const alive = this.isUnitAlive(targetId); + return { + id: objective.id, + label: objective.label, + achieved: alive, + status: alive ? 'active' : 'failed', + detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`, + rewardGold: objective.rewardGold + }; + } + + if (objective.kind === 'secure-terrain') { + const terrain = objective.terrain ?? 'village'; + const secured = !this.hasEnemyOnTerrain(terrain); + const achieved = outcome ? outcome === 'victory' && secured : secured; + return { + id: objective.id, + label: objective.label, + achieved, + status: achieved ? 'done' : outcome === 'defeat' ? 'failed' : 'active', + detail: secured ? '마을 주변 안전' : '마을 주변 교전 중', + rewardGold: objective.rewardGold + }; + } + + const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit; + const inTime = this.turnNumber <= maxTurn; + const achieved = outcome ? outcome === 'victory' && inTime : inTime; + return { + id: objective.id, + label: objective.label, + achieved, + status: achieved ? (outcome === 'victory' ? 'done' : 'active') : 'failed', + detail: inTime ? `${this.turnNumber}/${maxTurn}턴 진행 중` : `${this.turnNumber}/${maxTurn}턴 초과`, + rewardGold: objective.rewardGold + }; } private resultGold(outcome: BattleOutcome) { @@ -2644,11 +2680,7 @@ export class BattleScene extends Phaser.Scene { } private showOpeningBattleEvent() { - this.triggerBattleEvent('opening', '첫 전투 목표', [ - '황건 두령을 격파하면 승리합니다.', - '유비가 퇴각하면 패배합니다.', - '마을을 확보하고 빠르게 승리하면 추가 보상이 붙습니다.' - ]); + this.triggerBattleEvent('opening', '첫 전투 목표', battleScenario.openingObjectiveLines); } private triggerBattleEvent(key: string, title: string, lines: string[]) { @@ -3201,6 +3233,7 @@ export class BattleScene extends Phaser.Scene { } this.resetActedStyles(); this.updateTurnText(); + this.updateObjectiveTracker(); this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); } @@ -3290,6 +3323,7 @@ export class BattleScene extends Phaser.Scene { this.activeFaction = 'enemy'; this.updateTurnText(); + this.updateObjectiveTracker(); const recoveryMessage = this.applyTerrainRecovery('enemy'); this.renderSituationPanel(['아군 턴을 종료했습니다.', recoveryMessage, '적군이 행동을 시작합니다.'].filter(Boolean).join('\n')); void this.runEnemyTurn(); @@ -3312,6 +3346,7 @@ export class BattleScene extends Phaser.Scene { if (message) { messages.push(message); this.pushBattleLog(message); + this.updateObjectiveTracker(); this.renderSituationPanel(messages.slice(-3).join('\n')); } @@ -4360,6 +4395,7 @@ export class BattleScene extends Phaser.Scene { const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); + this.updateObjectiveTracker(); this.checkBattleEvents(); this.renderRosterPanel('ally', [`${this.turnNumber}턴 아군 차례입니다.`, recoveryMessage, '행동할 장수를 선택하세요.'].filter(Boolean).join('\n')); } @@ -4446,6 +4482,7 @@ export class BattleScene extends Phaser.Scene { private updateTurnText() { this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`); + this.updateObjectiveTracker(); } private resetActedStyles() { @@ -5070,12 +5107,16 @@ export class BattleScene extends Phaser.Scene { this.battleLog = [firstLine, ...this.battleLog].slice(0, 8); } + private sideContentTop() { + return this.layout.panelY + 152; + } + private renderBondPanel(message?: string) { this.clearSidePanelContent(); - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const top = panelY + 132; + const top = this.sideContentTop(); this.trackSideObject(this.add.text(left, top, '공명 관계', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -5130,14 +5171,15 @@ export class BattleScene extends Phaser.Scene { private renderSituationPanel(message?: string) { this.clearSidePanelContent(); - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const top = panelY + 132; + const top = this.sideContentTop(); const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length; const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0]; + const objectiveStates = this.objectiveStates(this.battleOutcome); this.trackSideObject(this.add.text(left, top, '전황', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -5146,29 +5188,133 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' })); - this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 48, width); - this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 92, width); - this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 136, width); - this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 180, width); + this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + 46, width); + this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + 84, width); + this.renderSituationLine('남은 적', `${enemyCount}`, left, top + 122, width); + this.renderSituationLine('BGM', soundDirector.isMuted() ? '꺼짐' : '켜짐', left, top + 160, width); this.renderSituationLine( '최고 공명', bestBond ? `${this.unitName(bestBond.unitIds[0])}·${this.unitName(bestBond.unitIds[1])} ${bestBond.level}` : '-', left, - top + 224, + top + 198, width ); - this.renderPanelMessage(message ?? '빈 맵에서 우클릭하면 전술 메뉴를 다시 열 수 있습니다.', left, top + 286, width); + if (message) { + this.renderPanelMessage(this.compactSituationMessage(message), left, top + 246, width, 72); + return; + } + + this.trackSideObject(this.add.text(left, top + 238, '목표 현황', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#f2e3bf', + fontStyle: '700' + })); + objectiveStates.forEach((objective, index) => { + this.renderObjectiveStateRow(objective, left, top + 264 + index * 28, width); + }); + } + + private compactSituationMessage(message: string) { + return message + .split('\n') + .slice(0, 2) + .map((line) => (line.length > 18 ? `${line.slice(0, 17)}...` : line)) + .join('\n'); + } + + private updateObjectiveTracker() { + if (!this.objectiveTrackerText || !this.objectiveTrackerSubText) { + return; + } + + const leader = battleUnits.find((unit) => unit.id === leaderUnitId); + const leaderProgress = leader && leader.hp > 0 ? `${leader.hp}/${leader.maxHp}` : '완료'; + const quickObjective = this.objectiveStates(this.battleOutcome).find((objective) => objective.id === 'quick'); + const quickText = quickObjective ? ` · ${quickObjective.label} ${this.objectiveStatusText(quickObjective)}` : ''; + const victoryText = + this.battleOutcome === 'victory' + ? `승리: ${battleScenario.victoryConditionLabel} 완료` + : `승리: ${battleScenario.victoryConditionLabel} (${leaderProgress})`; + const defeatText = + this.battleOutcome === 'defeat' + ? `패배: ${battleScenario.defeatConditionLabel} 발생` + : `패배: ${battleScenario.defeatConditionLabel}`; + + this.objectiveTrackerText.setText(victoryText); + this.objectiveTrackerSubText.setText(`${defeatText}${quickText}`); + } + + private renderObjectiveStateRow(objective: BattleObjectiveState, x: number, y: number, width: number) { + const bg = this.trackSideObject(this.add.rectangle(x, y, width, 25, 0x101820, 0.86)); + bg.setOrigin(0); + bg.setStrokeStyle(1, this.objectiveStatusStroke(objective), 0.5); + + this.trackSideObject(this.add.text(x + 10, y + 5, this.objectiveStatusText(objective), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: this.objectiveStatusColor(objective), + fontStyle: '700' + })); + this.trackSideObject(this.add.text(x + 54, y + 4, objective.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#d4dce6', + fontStyle: '700' + })); + const detail = this.trackSideObject(this.add.text(x + width - 10, y + 4, objective.detail, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#9fb0bf' + })); + detail.setOrigin(1, 0); + } + + private objectiveStatusText(objective: BattleObjectiveState) { + if (objective.status === 'done') { + return '완료'; + } + if (objective.status === 'failed') { + return '실패'; + } + if (objective.id === 'liu-bei') { + return '유지'; + } + if (objective.id === 'quick') { + return '가능'; + } + return '진행'; + } + + private objectiveStatusColor(objective: BattleObjectiveState) { + if (objective.status === 'done') { + return '#a8ffd0'; + } + if (objective.status === 'failed') { + return '#ffb6a6'; + } + return '#f4dfad'; + } + + private objectiveStatusStroke(objective: BattleObjectiveState) { + if (objective.status === 'done') { + return 0x59d18c; + } + if (objective.status === 'failed') { + return 0xb86b55; + } + return palette.gold; } private renderTerrainDetail(x: number, y: number) { this.clearSidePanelContent(); const terrain = battleMap.terrain[y][x]; const terrainRule = getTerrainRule(terrain); - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const top = panelY + 132; + const top = this.sideContentTop(); const attackDefense = Math.floor(terrainRule.defenseBonus / 3); const strategyDefense = Math.floor(terrainRule.defenseBonus / 4); const itemDefense = Math.floor(terrainRule.defenseBonus / 5); @@ -5244,7 +5390,7 @@ export class BattleScene extends Phaser.Scene { const { panelX, panelY, panelWidth, panelHeight } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const top = panelY + 132; + const top = this.sideContentTop(); const tabGap = 8; const tabWidth = (width - tabGap) / 2; @@ -5326,10 +5472,10 @@ export class BattleScene extends Phaser.Scene { private renderUnitDetail(unit: UnitData, message?: string) { this.clearSidePanelContent(); - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const left = panelX + 24; const width = panelWidth - 48; - const top = panelY + 124; + const top = this.sideContentTop(); const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; const unitClass = getUnitClass(unit.classKey); @@ -5605,6 +5751,9 @@ export class BattleScene extends Phaser.Scene { return { scene: this.scene.key, battleId: battleScenario.id, + battleTitle: battleScenario.title, + victoryConditionLabel: battleScenario.victoryConditionLabel, + defeatConditionLabel: battleScenario.defeatConditionLabel, turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, @@ -5629,7 +5778,7 @@ 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 })), + objectives: this.objectiveStates(this.battleOutcome).map((objective) => ({ ...objective })), battleStats: this.serializeBattleStats(), triggeredBattleEvents: Array.from(this.triggeredBattleEvents), selectedUsable: this.selectedUsable?.id ?? null,