Add first battle objective tracking

This commit is contained in:
2026-06-22 19:00:35 +09:00
parent 891c89ff9e
commit e063154e26
3 changed files with 240 additions and 68 deletions

View File

@@ -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,

View File

@@ -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,