Add first battle objectives and rewards

This commit is contained in:
2026-06-22 16:38:33 +09:00
parent 4999b67d0b
commit 7135ac8e55

View File

@@ -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<string, Record<string, number>>;
battleBuffs?: BattleBuffState[];
battleStats?: Record<string, UnitBattleStats>;
triggeredBattleEvents?: string[];
};
const maxEquipmentLevel = 9;
@@ -395,6 +413,9 @@ const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
};
const guardDetectionRange = 5;
const leaderUnitId = 'rebel-leader';
const quickVictoryTurnLimit = 8;
const baseVictoryGold = 300;
const commandLabels: Record<BattleCommand, string> = {
attack: '공격',
@@ -474,6 +495,7 @@ export class BattleScene extends Phaser.Scene {
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
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<string, { x: number; y: number }>();
private battleStats = new Map<string, UnitBattleStats>();
private triggeredBattleEvents = new Set<string>();
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<string, UnitBattleStats>) {
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 })),