Add first battle outcome screen

This commit is contained in:
2026-06-22 13:52:52 +09:00
parent e33b0cf7b8
commit a54c64ce93
3 changed files with 426 additions and 11 deletions

View File

@@ -59,13 +59,14 @@ type UnitView = {
type UnitDirection = 'south' | 'east' | 'north' | 'west';
type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating';
type BattlePhase = 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'bgm' | 'close';
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
type BattleOutcome = 'victory' | 'defeat';
type CommandButton = {
command: BattleCommand;
@@ -151,6 +152,8 @@ type BattleSaveState = {
const maxEquipmentLevel = 9;
const maxCharacterLevel = 99;
const battleSaveStorageKey = 'heros-web:first-battle-state';
const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData);
const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond);
const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
archer: 2
@@ -216,6 +219,21 @@ const statLabels: Array<{ key: keyof UnitStats; label: string }> = [
{ key: 'luck', label: '운' }
];
function cloneUnitData(unit: UnitData): UnitData {
return {
...unit,
stats: { ...unit.stats },
equipment: JSON.parse(JSON.stringify(unit.equipment)) as UnitData['equipment']
};
}
function cloneBattleBond(bond: BattleBond): BattleBond {
return {
...bond,
unitIds: [...bond.unitIds] as [string, string]
};
}
export class BattleScene extends Phaser.Scene {
private layout!: BattleLayout;
private phase: BattlePhase = 'idle';
@@ -234,6 +252,7 @@ export class BattleScene extends Phaser.Scene {
private alertDismissEvent?: Phaser.Time.TimerEvent;
private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private resultObjects: Phaser.GameObjects.GameObject[] = [];
private unitViews = new Map<string, UnitView>();
private actedUnitIds = new Set<string>();
private bondStates = new Map<string, BondState>();
@@ -241,6 +260,7 @@ export class BattleScene extends Phaser.Scene {
private battleLog: string[] = [];
private enemyHomeTiles = new Map<string, { x: number; y: number }>();
private pendingMove?: PendingMove;
private battleOutcome?: BattleOutcome;
private debugOverlay?: Phaser.GameObjects.Text;
constructor() {
@@ -248,11 +268,19 @@ export class BattleScene extends Phaser.Scene {
}
create() {
this.resetBattleData();
const { width, height } = this.scale;
this.layout = this.createLayout(width, height);
this.bondStates = this.createBondStates();
this.enemyHomeTiles = this.createEnemyHomeTiles();
this.battleLog = [];
this.actedUnitIds.clear();
this.attackIntents = [];
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.targetingAction = undefined;
this.battleOutcome = undefined;
this.phase = 'idle';
soundDirector.playMusic('battle-prep');
this.input.mouse?.disableContextMenu();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
@@ -275,6 +303,11 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
private resetBattleData() {
firstBattleUnits.splice(0, firstBattleUnits.length, ...initialFirstBattleUnits.map(cloneUnitData));
firstBattleBonds.splice(0, firstBattleBonds.length, ...initialFirstBattleBonds.map(cloneBattleBond));
}
private createLayout(width: number, height: number): BattleLayout {
const margin = 24;
const gap = 20;
@@ -415,6 +448,10 @@ export class BattleScene extends Phaser.Scene {
this.hideMapMenu();
this.hideTurnEndPrompt();
if (this.battleOutcome) {
return;
}
if (this.phase === 'animating') {
return;
}
@@ -855,13 +892,17 @@ export class BattleScene extends Phaser.Scene {
this.hideTurnEndPrompt();
this.applyActedStyle(unit);
soundDirector.playSelect();
this.pushBattleLog(message);
if (this.resolveBattleOutcomeIfNeeded()) {
return;
}
const remaining = this.remainingAllyCount();
const turnHint =
remaining > 0
? `${remaining}명의 아군이 아직 행동할 수 있습니다.`
: '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.';
this.pushBattleLog(message);
this.renderRosterPanel('ally', `${message}\n${turnHint}`);
if (remaining === 0) {
@@ -869,6 +910,289 @@ export class BattleScene extends Phaser.Scene {
}
}
private resolveBattleOutcomeIfNeeded() {
if (this.battleOutcome) {
return true;
}
const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei');
if (!liuBei || liuBei.hp <= 0) {
this.completeBattle('defeat');
return true;
}
const enemiesAlive = firstBattleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (!enemiesAlive) {
this.completeBattle('victory');
return true;
}
return false;
}
private completeBattle(outcome: BattleOutcome) {
if (this.battleOutcome) {
return;
}
this.battleOutcome = outcome;
this.phase = 'resolved';
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.targetingAction = undefined;
this.clearMarkers();
this.hideCommandMenu();
this.hideMapMenu();
this.hideTurnEndPrompt();
this.hideBattleAlert();
const message =
outcome === 'victory'
? '승리! 황건적을 모두 물리쳤습니다.'
: '패배... 유비가 패주했습니다.';
this.pushBattleLog(message);
this.renderSituationPanel(message);
soundDirector.playEffect(outcome === 'victory' ? 'exp-gain' : 'combat-impact', {
volume: outcome === 'victory' ? 0.38 : 0.28,
stopAfterMs: 1200
});
this.showBattleResult(outcome);
}
private showBattleResult(outcome: BattleOutcome) {
this.hideBattleResult();
const depth = 120;
const panelWidth = 920;
const panelHeight = 590;
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 shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
shade.setOrigin(0);
shade.setDepth(depth);
shade.setInteractive();
const panel = this.trackResultObject(this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.98));
panel.setOrigin(0);
panel.setDepth(depth + 1);
panel.setStrokeStyle(3, outcome === 'victory' ? palette.gold : 0xb86b55, 0.94);
const title = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 26, outcome === 'victory' ? '전투 승리' : '전투 패배', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '34px',
color: outcome === 'victory' ? '#f4dfad' : '#ffb6a6',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 5
}));
title.setOrigin(0.5, 0);
title.setDepth(depth + 2);
const subtitleText =
outcome === 'victory'
? '탁현 의용군은 첫 싸움에서 황건적을 몰아내고 마을을 지켜냈습니다.'
: '유비가 전장을 이탈했습니다. 진형을 가다듬고 다시 도전해야 합니다.';
const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d4dce6',
align: 'center'
}));
subtitle.setOrigin(0.5, 0);
subtitle.setDepth(depth + 2);
const metricTop = top + 112;
const metricWidth = 196;
const metricGap = 18;
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' ? '획득' : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2);
const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 178, '장수 성장', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#f2e3bf',
fontStyle: '700'
}));
unitTitle.setDepth(depth + 2);
allies.forEach((unit, index) => {
this.renderResultUnitRow(unit, left + 44, top + 214 + index * 66, panelWidth - 88, depth + 2);
});
const bondTitleTop = top + 424;
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);
});
const rewardLines =
outcome === 'victory'
? ['군자금 300', '마을 민심 +1', '의용군 명망 +1']
: ['보상 없음', '유비를 보호하며 다시 진군하세요.'];
const reward = this.trackResultObject(this.add.text(left + panelWidth - 44, top + 520, rewardLines.join(' '), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px',
color: outcome === 'victory' ? '#d8b15f' : '#aeb7c2',
fontStyle: '700'
}));
reward.setOrigin(1, 0);
reward.setDepth(depth + 2);
this.addResultButton('다시 하기', left + panelWidth - 286, top + 552, 124, () => {
soundDirector.playSelect();
this.scene.restart();
}, depth + 3);
this.addResultButton('타이틀로', left + panelWidth - 150, top + 552, 124, () => {
soundDirector.playSelect();
this.scene.start('TitleScene');
}, depth + 3);
}
private hideBattleResult() {
this.resultObjects.forEach((object) => object.destroy());
this.resultObjects = [];
}
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);
bg.setDepth(depth);
bg.setStrokeStyle(1, 0x53606c, 0.6);
const labelText = this.trackResultObject(this.add.text(x + 12, y + 8, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: '#9fb0bf'
}));
labelText.setDepth(depth + 1);
const valueText = this.trackResultObject(this.add.text(x + width - 12, y + 22, value, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f2e3bf',
fontStyle: '700'
}));
valueText.setOrigin(1, 0);
valueText.setDepth(depth + 1);
}
private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) {
const alive = unit.hp > 0;
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 56, 0x101820, alive ? 0.94 : 0.66));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5);
const name = this.trackResultObject(this.add.text(x + 14, y + 8, `${unit.name} Lv ${unit.level}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: alive ? '#e9f0f8' : '#9a9a9a',
fontStyle: '700'
}));
name.setDepth(depth + 1);
const classText = this.trackResultObject(this.add.text(x + 150, y + 10, getUnitClass(unit.classKey).name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: alive ? '#9fb0bf' : '#777777'
}));
classText.setDepth(depth + 1);
const hpText = this.trackResultObject(this.add.text(x + 270, y + 9, `HP ${unit.hp}/${unit.maxHp}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: alive ? '#f0e4c8' : '#8c8c8c',
fontStyle: '700'
}));
hpText.setDepth(depth + 1);
const expText = this.trackResultObject(this.add.text(x + 392, y + 9, `경험 ${unit.exp}/100`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#d8b15f',
fontStyle: '700'
}));
expText.setDepth(depth + 1);
const equipmentText = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
return `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level} ${state.exp}/${equipmentExpToNext(state.level)}`;
})
.join(' ');
const equipment = this.trackResultObject(this.add.text(x + 14, y + 32, equipmentText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '12px',
color: alive ? '#c8d2dd' : '#7e858d',
wordWrap: { width: width - 28, useAdvancedWrap: true }
}));
equipment.setDepth(depth + 1);
}
private renderResultBondRow(bond: BondState, x: number, y: number, width: number, depth: number) {
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 58, 0x101820, 0.88));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, bond.battleExp > 0 ? palette.gold : 0x53606c, bond.battleExp > 0 ? 0.72 : 0.5);
const [first, second] = bond.unitIds.map((unitId) => this.unitName(unitId));
const title = this.trackResultObject(this.add.text(x + 12, y + 8, `${first} · ${second}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px',
color: '#e8dfca',
fontStyle: '700'
}));
title.setDepth(depth + 1);
const value = this.trackResultObject(this.add.text(x + 12, y + 31, `공명 ${bond.level} 경험 ${bond.exp}/100 전투 +${bond.battleExp}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf'
}));
value.setDepth(depth + 1);
}
private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96));
bg.setDepth(depth);
bg.setStrokeStyle(1, label === '다시 하기' ? palette.blue : palette.gold, 0.78);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
bg.on('pointerdown', action);
const text = this.trackResultObject(this.add.text(x, y, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#f2e3bf',
fontStyle: '700'
}));
text.setOrigin(0.5);
text.setDepth(depth + 1);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
private trackResultObject<T extends Phaser.GameObjects.GameObject>(object: T) {
this.resultObjects.push(object);
return object;
}
private attackableTargets(attacker: UnitData) {
return this.damageableTargets(attacker, 'attack');
}
@@ -1045,6 +1369,10 @@ export class BattleScene extends Phaser.Scene {
}
private handleRightClick(pointer: Phaser.Input.Pointer) {
if (this.battleOutcome) {
return;
}
if (this.phase === 'animating') {
return;
}
@@ -1066,6 +1394,10 @@ export class BattleScene extends Phaser.Scene {
}
private handleLeftClick(pointer: Phaser.Input.Pointer) {
if (this.battleOutcome) {
return;
}
if (this.phase !== 'idle' || !this.isPointerOnEmptyMapTile(pointer)) {
return;
}
@@ -1283,9 +1615,11 @@ export class BattleScene extends Phaser.Scene {
this.hideCommandMenu();
this.hideTurnEndPrompt();
this.hideCombatCutIn();
this.hideBattleResult();
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.targetingAction = undefined;
this.battleOutcome = undefined;
this.phase = 'idle';
this.turnNumber = Math.max(1, state.turnNumber || 1);
@@ -1356,6 +1690,9 @@ export class BattleScene extends Phaser.Scene {
this.renderSituationPanel('이미 적군 행동 중입니다.');
return;
}
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
return;
}
if (this.phase === 'command' || this.phase === 'targeting' || this.phase === 'animating') {
this.setInfo('현재 장수의 행동을 먼저 결정하세요.');
return;
@@ -1372,30 +1709,39 @@ export class BattleScene extends Phaser.Scene {
this.updateTurnText();
this.renderSituationPanel('아군 턴을 종료했습니다.\n적군이 행동을 시작합니다.');
void this.runEnemyTurn();
return;
this.activeFaction = 'enemy';
this.updateTurnText();
const pressureMessage = this.resolveEnemyPressure();
this.renderSituationPanel(`아군 턴을 종료했습니다.\n${pressureMessage}`);
this.time.delayedCall(700, () => this.beginNextAllyTurn());
}
private async runEnemyTurn() {
if (this.battleOutcome) {
return;
}
const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0);
const messages: string[] = [];
for (const enemy of enemies) {
if (this.battleOutcome) {
return;
}
const message = await this.executeEnemyAction(enemy);
if (message) {
messages.push(message);
this.pushBattleLog(message);
this.renderSituationPanel(messages.slice(-3).join('\n'));
}
if (this.resolveBattleOutcomeIfNeeded()) {
return;
}
await this.delay(260);
}
if (this.resolveBattleOutcomeIfNeeded()) {
return;
}
this.time.delayedCall(520, () => this.beginNextAllyTurn());
}
@@ -1871,6 +2217,10 @@ export class BattleScene extends Phaser.Scene {
}
private beginNextAllyTurn() {
if (this.battleOutcome || this.resolveBattleOutcomeIfNeeded()) {
return;
}
this.turnNumber += 1;
this.activeFaction = 'ally';
this.actedUnitIds.clear();
@@ -2901,6 +3251,8 @@ export class BattleScene extends Phaser.Scene {
turnNumber: this.turnNumber,
activeFaction: this.activeFaction,
phase: this.phase,
battleOutcome: this.battleOutcome ?? null,
resultVisible: this.resultObjects.length > 0,
selectedUnitId: this.selectedUnit?.id ?? null,
pendingMove: this.pendingMove
? {
@@ -2939,6 +3291,29 @@ export class BattleScene extends Phaser.Scene {
};
}
debugForceBattleOutcome(outcome: BattleOutcome = 'victory') {
if (!import.meta.env.DEV || this.battleOutcome) {
return;
}
if (outcome === 'victory') {
firstBattleUnits
.filter((unit) => unit.faction === 'enemy')
.forEach((unit) => {
unit.hp = 0;
this.applyDefeatedStyle(unit);
});
} else {
const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei');
if (liuBei) {
liuBei.hp = 0;
this.applyDefeatedStyle(liuBei);
}
}
this.completeBattle(outcome);
}
toggleDebugOverlay() {
if (this.debugOverlay) {
this.debugOverlay.destroy();