Add Liu Biao camp visit events
This commit is contained in:
@@ -36,12 +36,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
|
||||
- Fourteenth battle Cao Cao break route, adding Sun Qian as a recruitable strategist and making expanded sortie selection matter before Liu Bei heads toward Yuan Shao
|
||||
- Fifteenth battle Yuan Shao refuge road, pushing Liu Bei through Cao Cao's pursuit into Yuan Shao's camp while preserving the six-officer sortie roster
|
||||
- Sixteenth battle Liu Biao refuge road, adding Zhao Yun as a recruitable cavalry officer and forcing a seven-officer roster into a six-officer sortie choice
|
||||
- Liu Biao camp visit events with one-time local choices, persisted completion, bond/gold/item rewards, and Wolong clue preparation before Zhuge Liang's recruitment arc
|
||||
- Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer
|
||||
- Flow verification script from title through the sixteenth battle victory, recruit sortie selection, and camp save state
|
||||
- Flow verification script from title through the sixteenth battle victory, recruit sortie selection, Liu Biao visit rewards, and camp save state
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Build the Zhuge Liang recruitment chapter and add stronger between-battle visit/dialogue choices in Liu Biao's territory
|
||||
1. Build the Zhuge Liang recruitment chapter using the Wolong clues gathered from Liu Biao camp visits
|
||||
2. Add a chapter/progress view so long campaign arcs are easier to read between battles
|
||||
3. Apply treasure equipment effects to damage, defense, recovery, and command rules
|
||||
4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang
|
||||
|
||||
@@ -294,7 +294,7 @@ try {
|
||||
});
|
||||
campScene.render();
|
||||
});
|
||||
await page.mouse.click(918, 38);
|
||||
await page.mouse.click(956, 38);
|
||||
await page.waitForTimeout(160);
|
||||
await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true });
|
||||
await page.mouse.click(1120, 245);
|
||||
@@ -321,7 +321,7 @@ try {
|
||||
throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`);
|
||||
}
|
||||
|
||||
await page.mouse.click(830, 38);
|
||||
await page.mouse.click(784, 38);
|
||||
await page.waitForTimeout(120);
|
||||
await page.mouse.click(974, 482);
|
||||
await page.waitForTimeout(260);
|
||||
@@ -1607,11 +1607,35 @@ try {
|
||||
sixteenthCampState.campTitle !== '형주 의탁 후 군영' ||
|
||||
sixteenthCampState.availableDialogueIds?.length !== 3 ||
|
||||
!sixteenthCampState.availableDialogueIds.every((id) => id.endsWith('liu-biao-refuge')) ||
|
||||
sixteenthCampState.availableVisitIds?.length !== 3 ||
|
||||
!sixteenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhao-yun')
|
||||
) {
|
||||
throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue set and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`);
|
||||
throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue/visit sets and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`);
|
||||
}
|
||||
|
||||
await page.mouse.click(870, 38);
|
||||
await page.waitForTimeout(160);
|
||||
const sixteenthVisitTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (
|
||||
sixteenthVisitTabState?.activeTab !== 'visit' ||
|
||||
!sixteenthVisitTabState.availableVisitIds?.includes('jingzhou-scholar-rumors') ||
|
||||
sixteenthVisitTabState.completedAvailableVisits?.length !== 0
|
||||
) {
|
||||
throw new Error(`Expected Liu Biao camp visit tab to expose local visit choices: ${JSON.stringify(sixteenthVisitTabState)}`);
|
||||
}
|
||||
|
||||
await page.mouse.click(974, 436);
|
||||
await page.waitForTimeout(260);
|
||||
const sixteenthVisitRewardState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (
|
||||
!sixteenthVisitRewardState.completedAvailableVisits?.includes('jingzhou-scholar-rumors') ||
|
||||
!sixteenthVisitRewardState.campaign?.completedCampVisits?.includes('jingzhou-scholar-rumors') ||
|
||||
(sixteenthVisitRewardState.campaign?.inventory?.['와룡 소문'] ?? 0) < 1
|
||||
) {
|
||||
throw new Error(`Expected completed visit to persist and award Wolong clue inventory: ${JSON.stringify(sixteenthVisitRewardState)}`);
|
||||
}
|
||||
await page.screenshot({ path: 'dist/verification-liu-biao-visits.png', fullPage: true });
|
||||
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene'));
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
|
||||
@@ -2520,6 +2520,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
: undefined,
|
||||
itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type UnitData
|
||||
} from '../data/scenario';
|
||||
import {
|
||||
applyCampVisitReward,
|
||||
applyCampBondExp,
|
||||
ensureCampaignRosterUnits,
|
||||
getCampaignState,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from '../state/campaignState';
|
||||
import { palette } from '../ui/palette';
|
||||
|
||||
type CampTab = 'status' | 'dialogue' | 'supplies';
|
||||
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies';
|
||||
|
||||
type CampDialogue = {
|
||||
id: string;
|
||||
@@ -48,6 +49,26 @@ type CampDialogueChoice = {
|
||||
rewardExp: number;
|
||||
};
|
||||
|
||||
type CampVisitDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
availableAfterBattleIds: string[];
|
||||
description: string;
|
||||
bondId?: string;
|
||||
lines: string[];
|
||||
choices: CampVisitChoice[];
|
||||
};
|
||||
|
||||
type CampVisitChoice = {
|
||||
id: string;
|
||||
label: string;
|
||||
response: string;
|
||||
bondExp?: number;
|
||||
gold?: number;
|
||||
itemRewards?: string[];
|
||||
};
|
||||
|
||||
type CampTabButtonView = {
|
||||
tab: CampTab;
|
||||
bg: Phaser.GameObjects.Rectangle;
|
||||
@@ -1720,12 +1741,104 @@ const campDialogues: CampDialogue[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const campVisits: CampVisitDefinition[] = [
|
||||
{
|
||||
id: 'jingzhou-scholar-rumors',
|
||||
title: '형주 선비들의 모임',
|
||||
location: '신야 객사',
|
||||
availableAfterBattleIds: [campBattleIds.sixteenth],
|
||||
bondId: 'liu-bei__sun-qian',
|
||||
description: '형주의 선비들이 모이는 객사에서 와룡과 봉추에 관한 소문을 모읍니다.',
|
||||
lines: [
|
||||
'손건: 형주의 선비들은 겉으로는 술과 시를 말하지만, 속으로는 천하의 흐름을 재고 있습니다.',
|
||||
'유비: 그들의 말을 듣되 우리 뜻을 팔지는 말아야 하오. 와룡이라는 이름이 참이라면, 예로 찾아가야 하겠지.',
|
||||
'객사의 선비들은 유비군이 오래 머물 손님인지, 천하를 논할 사람인지 조심스럽게 살핍니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'ask-about-wolong',
|
||||
label: '와룡의 거처를 묻는다',
|
||||
response: '손건은 무리하게 캐묻지 않고 융중 근처의 초가와 제갈씨 집안에 관한 소문을 얻어 냈다.',
|
||||
bondExp: 18,
|
||||
itemRewards: ['와룡 소문 1']
|
||||
},
|
||||
{
|
||||
id: 'share-liu-bei-intent',
|
||||
label: '유비의 뜻을 조용히 전한다',
|
||||
response: '손건은 유비가 형주의 땅보다 사람을 먼저 찾는다는 뜻을 전했고, 선비들의 경계가 조금 누그러졌다.',
|
||||
bondExp: 20,
|
||||
gold: 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'xinye-market-supplies',
|
||||
title: '신야 장터 정비',
|
||||
location: '신야 장터',
|
||||
availableAfterBattleIds: [campBattleIds.sixteenth],
|
||||
bondId: 'liu-bei__mi-zhu',
|
||||
description: '피난민과 상인이 뒤섞인 장터에서 다음 여정을 위한 군량과 약을 챙깁니다.',
|
||||
lines: [
|
||||
'미축: 형주의 장터는 물자가 많지만 값도 빠르게 오릅니다. 지금 무엇을 먼저 챙길지 정해야 합니다.',
|
||||
'유비: 백성의 몫을 빼앗지 않는 선에서 군을 살릴 물자를 구하시오. 다음 길은 길어질 것이오.',
|
||||
'상인들은 유비군의 명분을 믿고 값을 낮출지, 낯선 객군이라며 셈을 올릴지 눈치를 봅니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'buy-field-medicine',
|
||||
label: '상처약을 먼저 구한다',
|
||||
response: '미축은 좋은 약재를 골라 상처약을 더 확보했고, 병사들은 긴 행군에 조금 안심했다.',
|
||||
bondExp: 12,
|
||||
itemRewards: ['상처약 2']
|
||||
},
|
||||
{
|
||||
id: 'stock-camp-grain',
|
||||
label: '군량을 넉넉히 산다',
|
||||
response: '미축은 콩과 마른 양식을 챙기며 군자금을 아꼈고, 남은 돈으로 다음 협상 여지도 마련했다.',
|
||||
bondExp: 10,
|
||||
gold: 120,
|
||||
itemRewards: ['콩 2']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'refugee-village-patrol',
|
||||
title: '피난민 마을 순행',
|
||||
location: '형주 북쪽 마을',
|
||||
availableAfterBattleIds: [campBattleIds.sixteenth],
|
||||
bondId: 'liu-bei__zhao-yun',
|
||||
description: '조운과 함께 형주 북쪽 마을을 돌아보며 민심과 길목의 위험을 살핍니다.',
|
||||
lines: [
|
||||
'조운: 조조군을 피해 내려온 백성들이 마을 바깥에 모여 있습니다. 관문은 열렸지만 마음은 아직 닫혀 있습니다.',
|
||||
'유비: 의탁한 몸이라도 백성을 외면할 수는 없소. 우리가 머무는 동안 폐가 아니라 힘이 되게 합시다.',
|
||||
'피난민들은 유비의 이름을 듣고도 쉽게 다가오지 못하지만, 조운의 차분한 호위에 조금씩 안정을 찾습니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'guard-refugee-road',
|
||||
label: '피난길을 호위한다',
|
||||
response: '조운은 피난민 행렬을 안전한 길로 이끌었고, 유비군의 깃발은 형주 북쪽 마을에 좋은 소문으로 남았다.',
|
||||
bondExp: 22,
|
||||
itemRewards: ['민심 기록 1']
|
||||
},
|
||||
{
|
||||
id: 'listen-to-village-elders',
|
||||
label: '마을 어른들의 말을 듣는다',
|
||||
response: '유비는 마을 어른들의 걱정을 오래 들었고, 조운은 다음 행군에서 피해야 할 샛길을 지도에 표시했다.',
|
||||
bondExp: 18,
|
||||
itemRewards: ['탁주 1']
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export class CampScene extends Phaser.Scene {
|
||||
private campaign?: CampaignState;
|
||||
private report?: FirstBattleReport;
|
||||
private selectedUnitId = 'liu-bei';
|
||||
private activeTab: CampTab = 'status';
|
||||
private selectedDialogueId = campDialogues[0].id;
|
||||
private selectedVisitId = campVisits[0].id;
|
||||
private contentObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
|
||||
@@ -1749,6 +1862,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
|
||||
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
|
||||
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
|
||||
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
|
||||
soundDirector.playMusic('militia-theme');
|
||||
|
||||
const { width, height } = this.scale;
|
||||
@@ -1789,6 +1903,7 @@ export class CampScene extends Phaser.Scene {
|
||||
bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })),
|
||||
itemRewards: [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -1910,17 +2025,28 @@ export class CampScene extends Phaser.Scene {
|
||||
return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
|
||||
}
|
||||
|
||||
private availableCampVisits() {
|
||||
const battleId = this.currentCampBattleId();
|
||||
return campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId));
|
||||
}
|
||||
|
||||
private completedAvailableVisits() {
|
||||
const completed = this.completedCampVisits();
|
||||
return this.availableCampVisits().filter((visit) => completed.includes(visit.id));
|
||||
}
|
||||
|
||||
private renderStaticButtons() {
|
||||
this.addTabButton('장수', 'status', 742, 38);
|
||||
this.addTabButton('대화', 'dialogue', 830, 38);
|
||||
this.addTabButton('정비', 'supplies', 918, 38);
|
||||
this.addCommandButton('저장', 1002, 38, 76, () => {
|
||||
this.addTabButton('장수', 'status', 698, 38);
|
||||
this.addTabButton('대화', 'dialogue', 784, 38);
|
||||
this.addTabButton('방문', 'visit', 870, 38);
|
||||
this.addTabButton('정비', 'supplies', 956, 38);
|
||||
this.addCommandButton('저장', 1044, 38, 76, () => {
|
||||
soundDirector.playSelect();
|
||||
this.campaign = saveCampaignState(getCampaignState());
|
||||
this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
|
||||
this.render();
|
||||
});
|
||||
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
|
||||
this.addCommandButton('다음 이야기', 1160, 38, 142, () => {
|
||||
soundDirector.playSelect();
|
||||
this.showSortiePrep();
|
||||
});
|
||||
@@ -1993,6 +2119,11 @@ export class CampScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTab === 'visit') {
|
||||
this.renderVisitPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTab === 'supplies') {
|
||||
this.renderSuppliesPanel();
|
||||
return;
|
||||
@@ -2467,11 +2598,13 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setStrokeStyle(1, palette.gold, 0.4);
|
||||
const availableDialogues = this.availableCampDialogues();
|
||||
const completedDialogues = this.completedAvailableDialogues().length;
|
||||
const availableVisits = this.availableCampVisits();
|
||||
const completedVisits = this.completedAvailableVisits().length;
|
||||
const inventory = this.inventoryLabels().join(', ');
|
||||
const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).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}/${availableDialogues.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
|
||||
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
|
||||
}
|
||||
|
||||
private nextSortieBriefing() {
|
||||
@@ -2490,6 +2623,8 @@ export class CampScene extends Phaser.Scene {
|
||||
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
|
||||
const availableDialogues = this.availableCampDialogues();
|
||||
const completedDialogues = this.completedAvailableDialogues().length;
|
||||
const availableVisits = this.availableCampVisits();
|
||||
const completedVisits = this.completedAvailableVisits().length;
|
||||
const selected = this.selectedSortieUnits();
|
||||
return [
|
||||
{
|
||||
@@ -2517,6 +2652,11 @@ export class CampScene extends Phaser.Scene {
|
||||
complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
|
||||
detail: `${completedDialogues}/${availableDialogues.length} 완료`
|
||||
},
|
||||
{
|
||||
label: '현지 방문',
|
||||
complete: availableVisits.length === 0 || completedVisits > 0,
|
||||
detail: availableVisits.length === 0 ? '방문 없음' : `${completedVisits}/${availableVisits.length} 완료`
|
||||
},
|
||||
{
|
||||
label: '장비 상태',
|
||||
complete: this.visitedTabs.has('status'),
|
||||
@@ -2816,6 +2956,112 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private renderVisitPanel() {
|
||||
const x = 394;
|
||||
const y = 120;
|
||||
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
|
||||
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')));
|
||||
|
||||
const visits = this.availableCampVisits();
|
||||
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
|
||||
this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
|
||||
}
|
||||
|
||||
if (visits.length === 0) {
|
||||
const empty = this.track(this.add.rectangle(x + 24, y + 104, 780, 250, 0x0d141c, 0.92));
|
||||
empty.setOrigin(0);
|
||||
empty.setStrokeStyle(1, palette.blue, 0.42);
|
||||
this.track(this.add.text(x + 48, y + 130, '방문 가능한 장소 없음', this.textStyle(20, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 48, y + 170, '현재 군영에서는 따로 방문할 장소가 없습니다. 장수 대화와 정비를 진행하십시오.', this.textStyle(14, '#9fb0bf')));
|
||||
return;
|
||||
}
|
||||
|
||||
visits.forEach((visit, index) => {
|
||||
const completed = this.completedCampVisits().includes(visit.id);
|
||||
const rowY = y + 96 + index * 66;
|
||||
const selected = this.selectedVisitId === visit.id;
|
||||
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 50, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
|
||||
row.setOrigin(0);
|
||||
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
|
||||
row.setInteractive({ useHandCursor: true });
|
||||
row.on('pointerdown', () => {
|
||||
soundDirector.playSelect();
|
||||
this.selectedVisitId = visit.id;
|
||||
this.render();
|
||||
});
|
||||
this.track(this.add.text(x + 38, rowY + 8, visit.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 38, rowY + 29, visit.location, this.textStyle(12, '#9fb0bf')));
|
||||
});
|
||||
|
||||
this.renderSelectedVisit(x + 372, y + 96, 416, 300);
|
||||
this.renderVisitPreparationSummary(x + 24, y + 322, 320);
|
||||
}
|
||||
|
||||
private renderSelectedVisit(x: number, y: number, width: number, height: number) {
|
||||
const visits = this.availableCampVisits();
|
||||
const visit = visits.find((candidate) => candidate.id === this.selectedVisitId) ?? visits[0];
|
||||
if (!visit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = this.completedCampVisits().includes(visit.id);
|
||||
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
|
||||
this.track(this.add.text(x + 18, y + 14, visit.title, this.textStyle(20, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 18, y + 42, visit.description, {
|
||||
...this.textStyle(13, '#d8b15f', true),
|
||||
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
||||
lineSpacing: 2
|
||||
}));
|
||||
this.track(
|
||||
this.add.text(x + 18, y + 88, visit.lines.join('\n\n'), {
|
||||
...this.textStyle(14, '#d4dce6'),
|
||||
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
||||
lineSpacing: 4
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
visit.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('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
||||
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
||||
button.on('pointerdown', () => this.completeVisit(visit, choice));
|
||||
|
||||
const label = `${choice.label} ${this.visitRewardText(choice)}`;
|
||||
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(13, '#f2e3bf', true)));
|
||||
text.setOrigin(0.5);
|
||||
text.setInteractive({ useHandCursor: true });
|
||||
text.on('pointerdown', () => this.completeVisit(visit, choice));
|
||||
});
|
||||
}
|
||||
|
||||
private renderVisitPreparationSummary(x: number, y: number, width: number) {
|
||||
const visits = this.availableCampVisits();
|
||||
const completed = this.completedAvailableVisits();
|
||||
const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9));
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.blue, 0.42);
|
||||
this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
|
||||
const insightCount = this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
|
||||
this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
|
||||
}
|
||||
|
||||
private renderSuppliesPanel() {
|
||||
const x = 394;
|
||||
const y = 120;
|
||||
@@ -3111,6 +3357,43 @@ export class CampScene extends Phaser.Scene {
|
||||
return bonuses.join(' / ') || '특수 보정 없음';
|
||||
}
|
||||
|
||||
private completeVisit(visit: CampVisitDefinition, choice: CampVisitChoice) {
|
||||
if (this.completedCampVisits().includes(visit.id)) {
|
||||
this.showCampNotice('이미 완료한 방문입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = applyCampVisitReward(visit.id, {
|
||||
bondId: visit.bondId,
|
||||
bondExp: choice.bondExp,
|
||||
gold: choice.gold,
|
||||
itemRewards: choice.itemRewards
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
this.campaign = updated;
|
||||
this.report = this.campaign.firstBattleReport ?? this.report;
|
||||
}
|
||||
|
||||
soundDirector.playEffect('exp-gain', { volume: 0.26, stopAfterMs: 700 });
|
||||
this.showVisitReward(choice);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private showVisitReward(choice: CampVisitChoice) {
|
||||
this.showCampNotice(`${choice.label} · ${this.visitRewardText(choice)}`);
|
||||
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
|
||||
}
|
||||
|
||||
private visitRewardText(choice: CampVisitChoice) {
|
||||
const rewards = [
|
||||
choice.bondExp ? `공명 +${choice.bondExp}` : '',
|
||||
choice.gold ? `군자금 +${choice.gold}` : '',
|
||||
...(choice.itemRewards ?? []).map((reward) => `${reward}`)
|
||||
].filter(Boolean);
|
||||
return rewards.join(' / ') || '정보 획득';
|
||||
}
|
||||
|
||||
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
|
||||
@@ -3270,6 +3553,13 @@ export class CampScene extends Phaser.Scene {
|
||||
return this.report?.completedCampDialogues ?? [];
|
||||
}
|
||||
|
||||
private completedCampVisits() {
|
||||
if (this.campaign) {
|
||||
return this.campaign.completedCampVisits;
|
||||
}
|
||||
return this.report?.completedCampVisits ?? [];
|
||||
}
|
||||
|
||||
private inventoryLabels() {
|
||||
const inventory = this.campaign?.inventory ?? {};
|
||||
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
|
||||
@@ -3336,6 +3626,7 @@ export class CampScene extends Phaser.Scene {
|
||||
sortieVisible: this.sortieObjects.length > 0,
|
||||
selectedUnitId: this.selectedUnitId,
|
||||
selectedDialogueId: this.selectedDialogueId,
|
||||
selectedVisitId: this.selectedVisitId,
|
||||
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
|
||||
sortieRoster: this.sortieAllies().map((unit) => {
|
||||
const summary = this.sortieUnitTacticalSummary(unit);
|
||||
@@ -3361,6 +3652,8 @@ export class CampScene extends Phaser.Scene {
|
||||
campTitle: this.currentCampTitle(),
|
||||
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
|
||||
completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
|
||||
availableVisitIds: this.availableCampVisits().map((visit) => visit.id),
|
||||
completedAvailableVisits: this.completedAvailableVisits().map((visit) => visit.id),
|
||||
campaign: this.campaign
|
||||
? {
|
||||
step: this.campaign.step,
|
||||
@@ -3373,7 +3666,8 @@ export class CampScene extends Phaser.Scene {
|
||||
hp: unit.hp,
|
||||
maxHp: unit.maxHp
|
||||
})),
|
||||
completedCampDialogues: this.campaign.completedCampDialogues
|
||||
completedCampDialogues: this.campaign.completedCampDialogues,
|
||||
completedCampVisits: this.campaign.completedCampVisits
|
||||
}
|
||||
: null,
|
||||
report: this.report
|
||||
@@ -3381,6 +3675,7 @@ export class CampScene extends Phaser.Scene {
|
||||
rewardGold: this.report.rewardGold,
|
||||
objectives: this.report.objectives,
|
||||
completedCampDialogues: this.report.completedCampDialogues,
|
||||
completedCampVisits: this.report.completedCampVisits,
|
||||
bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -33,6 +33,7 @@ export type FirstBattleReport = {
|
||||
mvp?: CampMvpSnapshot;
|
||||
itemRewards: string[];
|
||||
completedCampDialogues: string[];
|
||||
completedCampVisits: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -114,6 +115,7 @@ export type CampaignState = {
|
||||
inventory: Record<string, number>;
|
||||
selectedSortieUnitIds: string[];
|
||||
completedCampDialogues: string[];
|
||||
completedCampVisits: string[];
|
||||
battleHistory: Record<string, CampaignBattleSettlement>;
|
||||
latestBattleId?: string;
|
||||
firstBattleReport?: FirstBattleReport;
|
||||
@@ -164,6 +166,7 @@ export function createInitialCampaignState(): CampaignState {
|
||||
inventory: {},
|
||||
selectedSortieUnitIds: [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
battleHistory: {}
|
||||
};
|
||||
}
|
||||
@@ -244,8 +247,11 @@ export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
const previousSettlement = state.battleHistory[battleId];
|
||||
const completedCampDialogues =
|
||||
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
|
||||
const completedCampVisits =
|
||||
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
|
||||
|
||||
reportClone.completedCampDialogues = completedCampDialogues;
|
||||
reportClone.completedCampVisits = completedCampVisits;
|
||||
state.firstBattleReport = reportClone;
|
||||
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
|
||||
const victoryStep: CampaignStep = stepRule.victory;
|
||||
@@ -255,6 +261,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
|
||||
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
|
||||
state.completedCampDialogues = completedCampDialogues;
|
||||
state.completedCampVisits = completedCampVisits;
|
||||
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
|
||||
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
|
||||
state.battleHistory[battleId] = createBattleSettlement(reportClone);
|
||||
@@ -298,6 +305,50 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
|
||||
return cloneReport(state.firstBattleReport);
|
||||
}
|
||||
|
||||
export function applyCampVisitReward(
|
||||
visitId: string,
|
||||
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }
|
||||
) {
|
||||
const state = ensureCampaignState();
|
||||
if (!state.firstBattleReport) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (state.completedCampVisits.includes(visitId)) {
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
state.completedCampVisits.push(visitId);
|
||||
if (!state.firstBattleReport.completedCampVisits.includes(visitId)) {
|
||||
state.firstBattleReport.completedCampVisits.push(visitId);
|
||||
}
|
||||
|
||||
if (reward.bondId && reward.bondExp && reward.bondExp > 0) {
|
||||
const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId);
|
||||
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId);
|
||||
if (campaignBond) {
|
||||
advanceBond(campaignBond, reward.bondExp);
|
||||
if (reportBond) {
|
||||
reportBond.level = campaignBond.level;
|
||||
reportBond.exp = campaignBond.exp;
|
||||
reportBond.battleExp = campaignBond.battleExp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reward.gold && reward.gold > 0) {
|
||||
state.gold += reward.gold;
|
||||
}
|
||||
|
||||
if (reward.itemRewards?.length) {
|
||||
state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1);
|
||||
}
|
||||
|
||||
state.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(state);
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) {
|
||||
const state = ensureCampaignState();
|
||||
let changed = false;
|
||||
@@ -368,6 +419,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState {
|
||||
normalized.roster = (normalized.roster ?? []).map(cloneUnit);
|
||||
normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot);
|
||||
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])];
|
||||
normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])];
|
||||
normalized.inventory = { ...(normalized.inventory ?? {}) };
|
||||
normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])];
|
||||
normalized.battleHistory = normalized.battleHistory ?? {};
|
||||
@@ -501,7 +553,9 @@ function cloneCampaignState(state: CampaignState): CampaignState {
|
||||
}
|
||||
|
||||
function cloneReport(report: FirstBattleReport): FirstBattleReport {
|
||||
return JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
||||
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
||||
cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])];
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneUnit(unit: UnitData): UnitData {
|
||||
|
||||
Reference in New Issue
Block a user