Add first battle objectives and rewards
This commit is contained in:
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user