Add Guangzong camp battle and camp events

This commit is contained in:
2026-06-22 23:44:05 +09:00
parent 97f8efc2e1
commit 421b95011e
10 changed files with 803 additions and 57 deletions

View File

@@ -25,6 +25,14 @@ type CampDialogue = {
bondId: string;
rewardExp: number;
lines: string[];
choices: CampDialogueChoice[];
};
type CampDialogueChoice = {
id: string;
label: string;
response: string;
rewardExp: number;
};
type CampTabButtonView = {
@@ -41,6 +49,13 @@ type CampSupplyDefinition = {
bondExp: number;
};
type MerchantItemDefinition = {
label: string;
title: string;
description: string;
price: number;
};
type SortieChecklistItem = {
label: string;
complete: boolean;
@@ -73,11 +88,39 @@ const campSupplies: CampSupplyDefinition[] = [
description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
healHp: 8,
bondExp: 2
},
{
label: '상처약',
title: '상처약',
description: '선택 장수의 병력을 22 회복합니다.',
healHp: 22,
bondExp: 0
}
];
const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));
const merchantItems: MerchantItemDefinition[] = [
{
label: '콩',
title: '콩',
description: '값싸고 가벼운 회복 도구입니다.',
price: 40
},
{
label: '탁주',
title: '탁주',
description: '병력 회복과 작은 공명 상승에 유용합니다.',
price: 70
},
{
label: '상처약',
title: '상처약',
description: '큰 부상을 빠르게 회복합니다.',
price: 110
}
];
const campDialogues: CampDialogue[] = [
{
id: 'liu-guan-after-first-battle',
@@ -89,6 +132,20 @@ const campDialogues: CampDialogue[] = [
'유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.',
'관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.',
'유비: 우리의 맹세가 전장에서 더 단단해지는구려.'
],
choices: [
{
id: 'trust-vanguard',
label: '앞길을 맡긴다',
response: '관우는 말없이 고개를 숙였다. 다음 전장에서도 형님의 길을 열겠다는 결의가 깊어졌다.',
rewardExp: 6
},
{
id: 'protect-people',
label: '백성 보호를 당부한다',
response: '관우는 칼끝보다 백성의 숨결을 먼저 살피겠다고 답했다. 두 사람의 뜻이 한층 가까워졌다.',
rewardExp: 4
}
]
},
{
@@ -101,6 +158,20 @@ const campDialogues: CampDialogue[] = [
'장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!',
'유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.',
'장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.'
],
choices: [
{
id: 'temper-courage',
label: '용맹을 다독인다',
response: '장비는 답답하다는 듯 웃었지만, 형님의 말에 맞춰 창을 거두는 법도 익히겠다고 했다.',
rewardExp: 5
},
{
id: 'lead-charge',
label: '선봉을 약속한다',
response: '장비의 눈빛이 밝아졌다. 믿고 맡긴다는 말은 그에게 무엇보다 큰 격려였다.',
rewardExp: 7
}
]
},
{
@@ -113,6 +184,20 @@ const campDialogues: CampDialogue[] = [
'관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.',
'장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?',
'관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.'
],
choices: [
{
id: 'cover-flanks',
label: '서로의 측면을 맡긴다',
response: '칼과 창이 같은 호흡으로 움직일 길을 찾았다. 두 맹장의 공명이 단단해졌다.',
rewardExp: 6
},
{
id: 'compete-boldly',
label: '누가 먼저 베는지 겨룬다',
response: '장비가 크게 웃고 관우도 미소를 감추지 못했다. 경쟁심마저 전장의 힘이 되었다.',
rewardExp: 4
}
]
}
];
@@ -613,7 +698,7 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
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')));
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
campDialogues.forEach((dialogue, index) => {
const completed = this.completedCampDialogues().includes(dialogue.id);
@@ -629,7 +714,8 @@ export class CampScene extends Phaser.Scene {
this.render();
});
this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}`, this.textStyle(12, '#9fb0bf')));
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf')));
});
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
@@ -651,19 +737,29 @@ export class CampScene extends Phaser.Scene {
})
);
const buttonLabel = completed ? '대화 완료' : `대화 보기 공명 +${dialogue.rewardExp}`;
const button = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, completed ? 0x17231d : 0x1a2630, 0.96));
button.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.76);
if (!completed) {
if (completed) {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true)));
text.setOrigin(0.5);
return;
}
dialogue.choices.forEach((choice, index) => {
const choiceY = y + height - 80 + index * 42;
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
button.setStrokeStyle(1, palette.gold, 0.76);
button.setInteractive({ useHandCursor: true });
button.on('pointerdown', () => this.completeDialogue(dialogue));
}
const text = this.track(this.add.text(x + width / 2, y + height - 34, buttonLabel, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
text.setOrigin(0.5);
if (!completed) {
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
button.on('pointerdown', () => this.completeDialogue(dialogue, choice));
const label = `${choice.label} 공명 +${dialogue.rewardExp + choice.rewardExp}`;
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(14, '#f2e3bf', true)));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', () => this.completeDialogue(dialogue));
}
text.on('pointerdown', () => this.completeDialogue(dialogue, choice));
});
}
private renderBondList(x: number, y: number, width: number) {
@@ -695,30 +791,70 @@ export class CampScene extends Phaser.Scene {
this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390);
});
this.track(this.add.text(x + 24, y + 202, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
this.renderMerchantPanel(x + 24, y + 202, 342);
this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
const trophies = this.nonConsumableInventoryLabels();
if (trophies.length === 0) {
this.track(this.add.text(x + 24, y + 236, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
} else {
trophies.forEach((reward, index) => {
this.renderSupplyBox(reward, x + 24 + index * 154, y + 232);
this.renderSupplyBox(reward, x + 390 + index * 128, y + 360);
});
}
}
this.track(this.add.text(x + 24, y + 306, '부대 장비 요약', this.textStyle(19, '#f2e3bf', true)));
this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.forEach((unit, index) => {
const rowY = y + 342 + index * 30;
this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(14, '#f2e3bf', true)));
equipmentSlots.forEach((slot, slotIndex) => {
const state = unit.equipment[slot];
const item = getItem(state.itemId);
const slotX = x + 104 + slotIndex * 220;
this.track(this.add.text(slotX, rowY - 2, `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`, this.textStyle(11, '#d4dce6')));
this.drawBar(slotX, rowY + 17, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue);
});
});
private renderMerchantPanel(x: number, y: number, width: number) {
const campaign = this.campaign ?? getCampaignState();
const bg = this.track(this.add.rectangle(x, y, width, 210, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.48);
this.track(this.add.text(x + 16, y + 10, `군영 상인 · 군자금 ${campaign.gold}`, this.textStyle(17, '#f2e3bf', true)));
merchantItems.forEach((item, index) => {
this.renderMerchantItem(item, x + 16, y + 44 + index * 52, width - 32);
});
}
private renderMerchantItem(item: MerchantItemDefinition, x: number, y: number, width: number) {
const campaign = this.campaign ?? getCampaignState();
const canBuy = campaign.gold >= item.price;
const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
bg.setOrigin(0);
bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36);
this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true)));
this.track(this.add.text(x + 10, y + 23, `${item.price}`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true)));
const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72));
button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4);
const text = this.track(this.add.text(x + width - 42, y + 20, '구매', this.textStyle(12, canBuy ? '#f2e3bf' : '#7f8994', true)));
text.setOrigin(0.5);
if (canBuy) {
const action = () => this.buyMerchantItem(item);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
button.on('pointerdown', action);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', action);
}
}
private buyMerchantItem(item: MerchantItemDefinition) {
const campaign = this.campaign ?? getCampaignState();
if (campaign.gold < item.price) {
this.showCampNotice('군자금이 부족합니다.');
return;
}
campaign.gold -= item.price;
campaign.inventory[item.label] = (campaign.inventory[item.label] ?? 0) + 1;
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
soundDirector.playSelect();
this.showCampNotice(`${item.title} 구입 · 군자금 -${item.price}`);
this.render();
}
private renderSupplyBox(label: string, x: number, y: number) {
@@ -932,8 +1068,9 @@ export class CampScene extends Phaser.Scene {
return bonuses.join(' / ') || '특수 보정 없음';
}
private completeDialogue(dialogue: CampDialogue) {
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp);
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
if (updated) {
this.report = updated;
this.campaign = getCampaignState();
@@ -941,27 +1078,32 @@ export class CampScene extends Phaser.Scene {
this.report.completedCampDialogues.push(dialogue.id);
const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
if (bond) {
bond.battleExp += dialogue.rewardExp;
bond.exp += dialogue.rewardExp;
bond.battleExp += rewardExp;
bond.exp += rewardExp;
}
}
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
this.showDialogueReward(dialogue);
this.showDialogueReward(dialogue, choice, rewardExp);
this.render();
}
private showDialogueReward(dialogue: CampDialogue) {
this.showCampNotice(`${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`);
private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`);
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
}
private showCampNotice(message: string) {
this.dialogueObjects.forEach((object) => object.destroy());
this.dialogueObjects = [];
const box = this.add.rectangle(this.scale.width / 2, 104, 440, 52, 0x101820, 0.96);
const box = this.add.rectangle(this.scale.width / 2, 104, 720, 58, 0x101820, 0.96);
box.setStrokeStyle(2, palette.gold, 0.84);
box.setDepth(30);
const text = this.add.text(this.scale.width / 2, 104, message, this.textStyle(17, '#f2e3bf', true));
const text = this.add.text(this.scale.width / 2, 104, message, {
...this.textStyle(16, '#f2e3bf', true),
align: 'center',
wordWrap: { width: 670, useAdvancedWrap: true }
});
text.setOrigin(0.5);
text.setDepth(31);
this.dialogueObjects.push(box, text);