Add battle-specific camp conversations

This commit is contained in:
2026-06-23 00:08:25 +09:00
parent fe93b70a56
commit 8e9bddcacf
3 changed files with 418 additions and 11 deletions

View File

@@ -21,6 +21,7 @@ type CampTab = 'status' | 'dialogue' | 'supplies';
type CampDialogue = {
id: string;
title: string;
availableAfterBattleIds: string[];
unitIds: [string, string];
bondId: string;
rewardExp: number;
@@ -121,10 +122,19 @@ const merchantItems: MerchantItemDefinition[] = [
}
];
const campBattleIds = {
first: defaultBattleScenario.id,
second: 'second-battle-yellow-turban-pursuit',
third: 'third-battle-guangzong-road',
fourth: 'fourth-battle-guangzong-camp',
fifth: 'fifth-battle-sishui-vanguard'
} as const;
const campDialogues: CampDialogue[] = [
{
id: 'liu-guan-after-first-battle',
title: '의리를 다지는 밤',
availableAfterBattleIds: [campBattleIds.first],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 14,
@@ -151,6 +161,7 @@ const campDialogues: CampDialogue[] = [
{
id: 'liu-zhang-after-first-battle',
title: '거친 용기를 붙잡다',
availableAfterBattleIds: [campBattleIds.first],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 14,
@@ -177,6 +188,7 @@ const campDialogues: CampDialogue[] = [
{
id: 'guan-zhang-after-first-battle',
title: '칼과 창의 약속',
availableAfterBattleIds: [campBattleIds.first],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 12,
@@ -199,6 +211,330 @@ const campDialogues: CampDialogue[] = [
rewardExp: 4
}
]
},
{
id: 'liu-guan-after-pursuit',
title: '추격 뒤의 절제',
availableAfterBattleIds: [campBattleIds.second],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 15,
lines: [
'유비: 잔당을 쫓는 싸움일수록 칼끝이 흐려지기 쉽소. 오늘 운장의 판단이 군을 지켰소.',
'관우: 형님이 백성의 길을 먼저 살피라 하셨기에 무리한 추격을 멈출 수 있었습니다.',
'유비: 이기되 어지럽히지 않는 것, 그것이 우리가 지켜야 할 싸움이오.'
],
choices: [
{
id: 'steady-line',
label: '전열의 절제를 칭찬한다',
response: '관우는 전공보다 전열의 안정이 먼저라는 말을 마음에 새겼다.',
rewardExp: 6
},
{
id: 'entrust-village',
label: '마을 수비를 맡긴다',
response: '관우는 다음 싸움에서도 백성이 있는 곳을 먼저 지키겠다고 답했다.',
rewardExp: 5
}
]
},
{
id: 'liu-zhang-after-pursuit',
title: '성급함과 선봉',
availableAfterBattleIds: [campBattleIds.second],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 15,
lines: [
'장비: 형님, 추격전에서는 한 발만 더 빨랐다면 놈들을 몽땅 묶어 둘 수 있었습니다.',
'유비: 익덕의 빠른 창은 큰 힘이오. 다만 혼자 먼저 닿으면 뒤따르는 병사들이 흔들리오.',
'장비: 좋소. 다음엔 앞서 달리되, 형님 깃발이 보이는 거리 안에서 치겠습니다.'
],
choices: [
{
id: 'controlled-charge',
label: '통제된 돌격을 약속받는다',
response: '장비는 선봉의 힘과 군율이 함께 가야 한다는 말을 받아들였다.',
rewardExp: 6
},
{
id: 'praise-speed',
label: '빠른 결단을 칭찬한다',
response: '믿음을 받은 장비는 다음 전장에서 더 날카로운 선봉이 되겠다고 웃었다.',
rewardExp: 5
}
]
},
{
id: 'guan-zhang-after-pursuit',
title: '길목을 나누다',
availableAfterBattleIds: [campBattleIds.second],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 13,
lines: [
'관우: 익덕, 네가 길목을 막아 준 덕에 궁병들이 숨을 돌렸다.',
'장비: 운장 형님이 옆을 눌러 주니 제가 마음 놓고 앞을 칠 수 있었지요.',
'관우: 다음에도 누가 먼저 나서든, 남은 한 사람이 뒤를 끊어 주면 된다.'
],
choices: [
{
id: 'split-roads',
label: '길목 분담을 정한다',
response: '두 사람은 좁은 길에서 서로의 창끝과 칼끝이 부딪히지 않을 간격을 익혔다.',
rewardExp: 6
},
{
id: 'bold-competition',
label: '전공을 겨루게 둔다',
response: '가벼운 경쟁심이 두 맹장의 호흡을 더 뜨겁게 만들었다.',
rewardExp: 4
}
]
},
{
id: 'liu-guan-after-guangzong-road',
title: '광종 길목의 침묵',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 16,
lines: [
'유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
'관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
'유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
],
choices: [
{
id: 'lead-narrow-road',
label: '좁은 길의 선봉을 맡긴다',
response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
rewardExp: 7
},
{
id: 'keep-discipline',
label: '군율 유지를 당부한다',
response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
rewardExp: 5
}
]
},
{
id: 'liu-zhang-after-guangzong-road',
title: '강가의 큰소리',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 16,
lines: [
'장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
'유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
'장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
],
choices: [
{
id: 'laugh-through',
label: '호방함으로 사기를 올린다',
response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
rewardExp: 6
},
{
id: 'guard-bridgehead',
label: '교두보 수비를 맡긴다',
response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
rewardExp: 5
}
]
},
{
id: 'guan-zhang-after-guangzong-road',
title: '험지의 호흡',
availableAfterBattleIds: [campBattleIds.third],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 14,
lines: [
'관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
'장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
'관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
],
choices: [
{
id: 'measure-and-break',
label: '관우가 재고 장비가 뚫는다',
response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
rewardExp: 7
},
{
id: 'watch-each-other',
label: '서로의 후방을 살핀다',
response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
rewardExp: 5
}
]
},
{
id: 'liu-guan-after-zhang-jue',
title: '황건의 깃발이 꺾인 밤',
availableAfterBattleIds: [campBattleIds.fourth],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 18,
lines: [
'유비: 장각이 쓰러졌으나 천하가 바로 맑아진 것은 아니오.',
'관우: 큰 난이 끝난 뒤에도 작은 탐욕은 남습니다. 형님의 뜻이 흔들리지 않게 제가 곁에 있겠습니다.',
'유비: 운장과 익덕이 있으니, 더 큰 혼란에도 나아갈 용기가 생기오.'
],
choices: [
{
id: 'prepare-larger-chaos',
label: '더 큰 혼란을 준비한다',
response: '관우는 반동탁의 소문을 들으며 다시 칼을 정비했다.',
rewardExp: 7
},
{
id: 'honor-fallen-people',
label: '희생된 백성을 기린다',
response: '두 사람은 전공보다 백성의 상처를 먼저 기억하기로 했다.',
rewardExp: 6
}
]
},
{
id: 'liu-zhang-after-zhang-jue',
title: '끝난 싸움, 남은 분노',
availableAfterBattleIds: [campBattleIds.fourth],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 18,
lines: [
'장비: 장각을 꺾었는데도 속이 시원하지만은 않습니다. 아직 백성을 괴롭히는 놈들이 많습니다.',
'유비: 그 분노가 백성을 향하지 않고 악한 자에게만 향하도록 붙잡아야 하오.',
'장비: 형님이 길을 가리키면, 제 창은 그 길 밖으로 나가지 않겠습니다.'
],
choices: [
{
id: 'temper-anger',
label: '분노를 다스리게 한다',
response: '장비는 분노를 창끝에만 싣고 마음에는 남기지 않겠다고 했다.',
rewardExp: 7
},
{
id: 'promise-next-vanguard',
label: '다음 선봉을 약속한다',
response: '장비는 반동탁의 길에서도 가장 먼저 적진을 흔들겠다고 웃었다.',
rewardExp: 6
}
]
},
{
id: 'guan-zhang-after-zhang-jue',
title: '맹장들의 전공',
availableAfterBattleIds: [campBattleIds.fourth],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 16,
lines: [
'장비: 운장 형님, 오늘은 제가 더 크게 적진을 흔든 것 같습니다.',
'관우: 전공을 다투는 마음이 백성을 구하는 데 쓰인다면 나쁠 것이 없다.',
'장비: 그럼 다음 전장에서도 누가 더 크게 이름을 떨치는지 봅시다!'
],
choices: [
{
id: 'clean-rivalry',
label: '깨끗한 경쟁을 인정한다',
response: '두 맹장은 웃으며 전공 경쟁을 다음 전장의 활력으로 삼았다.',
rewardExp: 6
},
{
id: 'brothers-before-glory',
label: '명성보다 형제를 앞세운다',
response: '관우와 장비는 전공보다 서로의 생존을 먼저 지키기로 했다.',
rewardExp: 7
}
]
},
{
id: 'liu-guan-after-sishui',
title: '공손찬의 깃발 아래',
availableAfterBattleIds: [campBattleIds.fifth],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 18,
lines: [
'유비: 공손찬의 진영은 크고, 제후들의 생각은 저마다 다르오. 우리 뜻을 잃지 않는 것이 더 어려워졌소.',
'관우: 큰 깃발 아래 설수록 작은 의리를 더 단단히 붙잡아야 합니다.',
'유비: 운장의 말이 옳소. 남의 군영에 있어도 우리가 지킬 길은 변하지 않소.'
],
choices: [
{
id: 'hold-oath-in-coalition',
label: '연합 속에서도 맹세를 지킨다',
response: '관우는 도원에서 맺은 뜻을 낯선 군영에서도 흐리지 않겠다고 답했다.',
rewardExp: 7
},
{
id: 'observe-warlords',
label: '제후들의 속내를 살핀다',
response: '관우는 싸움만이 아니라 사람의 뜻을 살피는 눈도 필요하다고 받아들였다.',
rewardExp: 6
}
]
},
{
id: 'liu-zhang-after-sishui',
title: '낯선 군영의 술잔',
availableAfterBattleIds: [campBattleIds.fifth],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 18,
lines: [
'장비: 형님, 공손찬 군영엔 말도 많고 술도 많고, 괜히 시비 걸 놈들도 많습니다.',
'유비: 익덕의 기개는 좋으나, 지금은 싸워야 할 적과 참아야 할 말을 구분해야 하오.',
'장비: 하하, 형님이 그렇게 말하면 술잔은 들되 주먹은 참아 보겠습니다.'
],
choices: [
{
id: 'restrain-in-camp',
label: '군영 안의 절제를 당부한다',
response: '장비는 시비보다 형님의 체면을 먼저 지키겠다고 약속했다.',
rewardExp: 7
},
{
id: 'trust-his-honesty',
label: '솔직함을 믿어 준다',
response: '믿음을 받은 장비는 낯선 제후들 사이에서도 형님의 편에 서겠다고 웃었다.',
rewardExp: 6
}
]
},
{
id: 'guan-zhang-after-sishui',
title: '연합군의 두 맹장',
availableAfterBattleIds: [campBattleIds.fifth],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 16,
lines: [
'관우: 이곳에는 이름난 장수도 많고, 허세뿐인 자도 많다.',
'장비: 그럼 우리가 전장에서 보여 주면 되지요. 말보다 칼과 창이 빠릅니다.',
'관우: 다만 형님의 이름이 먼저 서야 한다. 우리의 무공은 그 뜻을 받치는 것이니.'
],
choices: [
{
id: 'raise-liu-bei-name',
label: '유비의 이름을 세운다',
response: '두 사람은 자신의 전공보다 유비의 명성을 먼저 드러내기로 뜻을 모았다.',
rewardExp: 7
},
{
id: 'test-famous-generals',
label: '이름난 장수들을 의식한다',
response: '장비의 승부욕과 관우의 침착함이 묘한 균형을 이루었다.',
rewardExp: 5
}
]
}
];
@@ -227,6 +563,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
soundDirector.playMusic('militia-theme');
const { width, height } = this.scale;
@@ -234,7 +571,7 @@ export class CampScene extends Phaser.Scene {
this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0);
this.add.rectangle(0, 0, width, 84, 0x06090d, 0.52).setOrigin(0);
this.add.text(42, 28, '탁현 의용군 군영', {
this.add.text(42, 28, this.currentCampTitle(), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '30px',
color: '#f2e3bf',
@@ -278,7 +615,39 @@ export class CampScene extends Phaser.Scene {
const achieved = this.report.objectives.filter((objective) => objective.achieved).length;
const gold = this.campaign?.gold ?? this.report.rewardGold;
return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
return `${this.report.battleTitle} ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
}
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.fifth) {
return '공손찬 진영 군영';
}
if (battleId === campBattleIds.fourth) {
return '광종 토벌 후 군영';
}
if (battleId === campBattleIds.third) {
return '광종 구원로 군영';
}
if (battleId === campBattleIds.second) {
return '황건 잔당 추격 군영';
}
return '탁현 의용군 군영';
}
private currentCampBattleId() {
return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id;
}
private availableCampDialogues() {
const battleId = this.currentCampBattleId();
const dialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
return dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
}
private completedAvailableDialogues() {
const completed = this.completedCampDialogues();
return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
}
private renderStaticButtons() {
@@ -482,11 +851,12 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.4);
const completedDialogues = this.completedCampDialogues().length;
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const inventory = this.inventoryLabels().join(', ');
const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
private nextSortieBriefing() {
@@ -503,7 +873,8 @@ export class CampScene extends Phaser.Scene {
const liuBei = units.find((unit) => unit.id === 'liu-bei');
const injured = units.filter((unit) => unit.hp < unit.maxHp);
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const completedDialogues = this.completedCampDialogues().length;
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
return [
{
label: '유비 생존',
@@ -522,8 +893,8 @@ export class CampScene extends Phaser.Scene {
},
{
label: '공명 대화',
complete: completedDialogues >= campDialogues.length,
detail: `${completedDialogues}/${campDialogues.length} 완료`
complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
detail: `${completedDialogues}/${availableDialogues.length} 완료`
},
{
label: '장비 상태',
@@ -700,7 +1071,12 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(x + 24, y + 22, '장수 대화', this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
campDialogues.forEach((dialogue, index) => {
const dialogues = this.availableCampDialogues();
if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
}
dialogues.forEach((dialogue, index) => {
const completed = this.completedCampDialogues().includes(dialogue.id);
const rowY = y + 96 + index * 64;
const selected = this.selectedDialogueId === dialogue.id;
@@ -723,7 +1099,16 @@ export class CampScene extends Phaser.Scene {
}
private renderSelectedDialogue(x: number, y: number, width: number, height: number) {
const dialogue = campDialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? campDialogues[0];
const dialogues = this.availableCampDialogues();
const dialogue = dialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? dialogues[0];
if (!dialogue) {
const empty = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
empty.setOrigin(0);
empty.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 18, y + 16, '대화 없음', this.textStyle(20, '#f2e3bf', true)));
this.track(this.add.text(x + 18, y + 54, '현재 군영에서 가능한 장수 대화가 없습니다.', this.textStyle(14, '#9fb0bf')));
return;
}
const completed = this.completedCampDialogues().includes(dialogue.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
@@ -1207,6 +1592,10 @@ export class CampScene extends Phaser.Scene {
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
campaign: this.campaign
? {
step: this.campaign.step,